add `foreign` namespace to preserve attribute case and skip HTML-specific a11y validations (#5652)

pull/5930/head
halfnelson 4 years ago committed by GitHub
parent d5f2ddc417
commit a7eff8894f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3,6 +3,7 @@
## Unreleased
* Allow multiple instances of the same action on an element ([#5516](https://github.com/sveltejs/svelte/pull/5516))
* Support `foreign` namespace, which disables certain HTML5-specific behaviour and checks ([#5652](https://github.com/sveltejs/svelte/pull/5652))
* Support inline comment sourcemaps in code from preprocessors ([#5854](https://github.com/sveltejs/svelte/pull/5854))
## 3.31.2

@ -1530,7 +1530,7 @@ The `<svelte:options>` element provides a place to specify per-component compile
* `immutable={false}` — the default. Svelte will be more conservative about whether or not mutable objects have changed
* `accessors={true}` — adds getters and setters for the component's props
* `accessors={false}` — the default
* `namespace="..."` — the namespace where this component will be used, most commonly "svg"
* `namespace="..."` — the namespace where this component will be used, most commonly "svg"; use the "foreign" namespace to opt out of case-insensitive attribute names and HTML-specific warnings
* `tag="..."` — the name to use when compiling this component as a custom element
```sv

@ -80,7 +80,7 @@ The following options can be passed to the compiler. None are required:
| `outputFilename` | `null` | A `string` used for your JavaScript sourcemap.
| `cssOutputFilename` | `null` | A `string` used for your CSS sourcemap.
| `sveltePath` | `"svelte"` | The location of the `svelte` package. Any imports from `svelte` or `svelte/[module]` will be modified accordingly.
| `namespace` | `"html"` | The namespace of the element; e.g., `"mathml"`, `"svg"`, `"foreign"`.
---

@ -1349,7 +1349,8 @@ function process_component_options(component: Component, nodes) {
'accessors' in component.compile_options
? component.compile_options.accessors
: !!component.compile_options.customElement,
preserveWhitespace: !!component.compile_options.preserveWhitespace
preserveWhitespace: !!component.compile_options.preserveWhitespace,
namespace: component.compile_options.namespace
};
const node = nodes.find(node => node.name === 'svelte:options');

@ -6,6 +6,7 @@ import { CompileOptions, Warning } from '../interfaces';
import Component from './Component';
import fuzzymatch from '../utils/fuzzymatch';
import get_name_from_filename from './utils/get_name_from_filename';
import { valid_namespaces } from '../utils/namespaces';
const valid_options = [
'format',
@ -22,6 +23,7 @@ const valid_options = [
'hydratable',
'legacy',
'customElement',
'namespace',
'tag',
'css',
'loopGuardTimeout',
@ -30,7 +32,7 @@ const valid_options = [
];
function validate_options(options: CompileOptions, warnings: Warning[]) {
const { name, filename, loopGuardTimeout, dev } = options;
const { name, filename, loopGuardTimeout, dev, namespace } = options;
Object.keys(options).forEach(key => {
if (!valid_options.includes(key)) {
@ -65,6 +67,15 @@ function validate_options(options: CompileOptions, warnings: Warning[]) {
toString: () => message
});
}
if (namespace && valid_namespaces.indexOf(namespace) === -1) {
const match = fuzzymatch(namespace, valid_namespaces);
if (match) {
throw new Error(`Invalid namespace '${namespace}' (did you mean '${match}'?)`);
} else {
throw new Error(`Invalid namespace '${namespace}'`);
}
}
}
export default function compile(source: string, options: CompileOptions = {}) {

@ -136,44 +136,45 @@ export default class Element extends Node {
this.namespace = get_namespace(parent as Element, this, component.namespace);
if (this.name === 'textarea') {
if (info.children.length > 0) {
const value_attribute = info.attributes.find(node => node.name === 'value');
if (value_attribute) {
component.error(value_attribute, {
code: 'textarea-duplicate-value',
message: 'A <textarea> can have either a value attribute or (equivalently) child content, but not both'
});
}
if (this.namespace !== namespaces.foreign) {
if (this.name === 'textarea') {
if (info.children.length > 0) {
const value_attribute = info.attributes.find(node => node.name === 'value');
if (value_attribute) {
component.error(value_attribute, {
code: 'textarea-duplicate-value',
message: 'A <textarea> can have either a value attribute or (equivalently) child content, but not both'
});
}
// this is an egregious hack, but it's the easiest way to get <textarea>
// children treated the same way as a value attribute
info.attributes.push({
type: 'Attribute',
name: 'value',
value: info.children
});
// this is an egregious hack, but it's the easiest way to get <textarea>
// children treated the same way as a value attribute
info.attributes.push({
type: 'Attribute',
name: 'value',
value: info.children
});
info.children = [];
info.children = [];
}
}
}
if (this.name === 'option') {
// Special case — treat these the same way:
// <option>{foo}</option>
// <option value={foo}>{foo}</option>
const value_attribute = info.attributes.find(attribute => attribute.name === 'value');
if (this.name === 'option') {
// Special case — treat these the same way:
// <option>{foo}</option>
// <option value={foo}>{foo}</option>
const value_attribute = info.attributes.find(attribute => attribute.name === 'value');
if (!value_attribute) {
info.attributes.push({
type: 'Attribute',
name: 'value',
value: info.children,
synthetic: true
});
if (!value_attribute) {
info.attributes.push({
type: 'Attribute',
name: 'value',
value: info.children,
synthetic: true
});
}
}
}
const has_let = info.attributes.some(node => node.type === 'Let');
if (has_let) {
scope = scope.child();
@ -253,65 +254,83 @@ export default class Element extends Node {
});
}
if (a11y_distracting_elements.has(this.name)) {
// no-distracting-elements
this.component.warn(this, {
code: 'a11y-distracting-elements',
message: `A11y: Avoid <${this.name}> elements`
});
this.validate_attributes();
this.validate_event_handlers();
if (this.namespace === namespaces.foreign) {
this.validate_bindings_foreign();
} else {
this.validate_attributes_a11y();
this.validate_special_cases();
this.validate_bindings();
this.validate_content();
}
if (this.name === 'figcaption') {
let { parent } = this;
let is_figure_parent = false;
}
while (parent) {
if ((parent as Element).name === 'figure') {
is_figure_parent = true;
break;
}
if (parent.type === 'Element') {
break;
}
parent = parent.parent;
}
validate_attributes() {
const { component, parent } = this;
if (!is_figure_parent) {
this.component.warn(this, {
code: 'a11y-structure',
message: 'A11y: <figcaption> must be an immediate child of <figure>'
this.attributes.forEach(attribute => {
if (attribute.is_spread) return;
const name = attribute.name.toLowerCase();
// Errors
if (/(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/.test(name)) {
component.error(attribute, {
code: 'illegal-attribute',
message: `'${name}' is not a valid attribute name`
});
}
}
if (this.name === 'figure') {
const children = this.children.filter(node => {
if (node.type === 'Comment') return false;
if (node.type === 'Text') return /\S/.test(node.data);
return true;
});
if (name === 'slot') {
if (!attribute.is_static) {
component.error(attribute, {
code: 'invalid-slot-attribute',
message: 'slot attribute cannot have a dynamic value'
});
}
const index = children.findIndex(child => (child as Element).name === 'figcaption');
if (component.slot_outlets.has(name)) {
component.error(attribute, {
code: 'duplicate-slot-attribute',
message: `Duplicate '${name}' slot`
});
if (index !== -1 && (index !== 0 && index !== children.length - 1)) {
this.component.warn(children[index], {
code: 'a11y-structure',
message: 'A11y: <figcaption> must be first or last child of <figure>'
});
component.slot_outlets.add(name);
}
if (!(parent.type === 'InlineComponent' || within_custom_element(parent))) {
component.error(attribute, {
code: 'invalid-slotted-content',
message: 'Element with a slot=\'...\' attribute must be a child of a component or a descendant of a custom element'
});
}
}
}
this.validate_attributes();
this.validate_special_cases();
this.validate_bindings();
this.validate_content();
this.validate_event_handlers();
}
// Warnings
validate_attributes() {
const { component, parent } = this;
if (this.namespace !== namespaces.foreign) {
if (name === 'is') {
component.warn(attribute, {
code: 'avoid-is',
message: 'The \'is\' attribute is not supported cross-browser and should be avoided'
});
}
const attribute_map = new Map();
if (react_attributes.has(attribute.name)) {
component.warn(attribute, {
code: 'invalid-html-attribute',
message: `'${attribute.name}' is not a valid HTML attribute. Did you mean '${react_attributes.get(attribute.name)}'?`
});
}
}
});
}
validate_attributes_a11y() {
const { component } = this;
this.attributes.forEach(attribute => {
if (attribute.is_spread) return;
@ -408,60 +427,13 @@ export default class Element extends Node {
});
}
}
if (/(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/.test(name)) {
component.error(attribute, {
code: 'illegal-attribute',
message: `'${name}' is not a valid attribute name`
});
}
if (name === 'slot') {
if (!attribute.is_static) {
component.error(attribute, {
code: 'invalid-slot-attribute',
message: 'slot attribute cannot have a dynamic value'
});
}
if (component.slot_outlets.has(name)) {
component.error(attribute, {
code: 'duplicate-slot-attribute',
message: `Duplicate '${name}' slot`
});
component.slot_outlets.add(name);
}
if (!(parent.type === 'InlineComponent' || within_custom_element(parent))) {
component.error(attribute, {
code: 'invalid-slotted-content',
message: 'Element with a slot=\'...\' attribute must be a child of a component or a descendant of a custom element'
});
}
}
if (name === 'is') {
component.warn(attribute, {
code: 'avoid-is',
message: 'The \'is\' attribute is not supported cross-browser and should be avoided'
});
}
if (react_attributes.has(attribute.name)) {
component.warn(attribute, {
code: 'invalid-html-attribute',
message: `'${attribute.name}' is not a valid HTML attribute. Did you mean '${react_attributes.get(attribute.name)}'?`
});
}
attribute_map.set(attribute.name, attribute);
});
}
validate_special_cases() {
const { component, attributes, handlers } = this;
const attribute_map = new Map();
const handlers_map = new Map();
@ -576,6 +548,63 @@ export default class Element extends Node {
});
}
}
if (a11y_distracting_elements.has(this.name)) {
// no-distracting-elements
component.warn(this, {
code: 'a11y-distracting-elements',
message: `A11y: Avoid <${this.name}> elements`
});
}
if (this.name === 'figcaption') {
let { parent } = this;
let is_figure_parent = false;
while (parent) {
if ((parent as Element).name === 'figure') {
is_figure_parent = true;
break;
}
if (parent.type === 'Element') {
break;
}
parent = parent.parent;
}
if (!is_figure_parent) {
component.warn(this, {
code: 'a11y-structure',
message: 'A11y: <figcaption> must be an immediate child of <figure>'
});
}
}
if (this.name === 'figure') {
const children = this.children.filter(node => {
if (node.type === 'Comment') return false;
if (node.type === 'Text') return /\S/.test(node.data);
return true;
});
const index = children.findIndex(child => (child as Element).name === 'figcaption');
if (index !== -1 && (index !== 0 && index !== children.length - 1)) {
component.warn(children[index], {
code: 'a11y-structure',
message: 'A11y: <figcaption> must be first or last child of <figure>'
});
}
}
}
validate_bindings_foreign() {
this.bindings.forEach(binding => {
this.component.error(binding, {
code: 'invalid-binding',
message: `'${binding.name}' is not a valid binding. Foreign elements only support bind:this`
});
});
}
validate_bindings() {

@ -8,6 +8,7 @@ import Expression from '../../../nodes/shared/Expression';
import Text from '../../../nodes/Text';
import handle_select_value_binding from './handle_select_value_binding';
import { Identifier, Node } from 'estree';
import { namespaces } from '../../../../utils/namespaces';
export class BaseAttributeWrapper {
node: Attribute;
@ -67,15 +68,26 @@ export default class AttributeWrapper extends BaseAttributeWrapper {
}
}
this.name = fix_attribute_casing(this.node.name);
this.metadata = this.get_metadata();
this.is_indirectly_bound_value = is_indirectly_bound_value(this);
this.property_name = this.is_indirectly_bound_value
? '__value'
: this.metadata && this.metadata.property_name;
if (this.parent.node.namespace == namespaces.foreign) {
// leave attribute case alone for elements in the "foreign" namespace
this.name = this.node.name;
this.metadata = this.get_metadata();
this.is_indirectly_bound_value = false;
this.property_name = null;
this.is_select_value_attribute = false;
this.is_input_value = false;
} else {
this.name = fix_attribute_casing(this.node.name);
this.metadata = this.get_metadata();
this.is_indirectly_bound_value = is_indirectly_bound_value(this);
this.property_name = this.is_indirectly_bound_value
? '__value'
: this.metadata && this.metadata.property_name;
this.is_select_value_attribute = this.name === 'value' && this.parent.node.name === 'select';
this.is_input_value = this.name === 'value' && this.parent.node.name === 'input';
}
this.is_src = this.name === 'src'; // TODO retire this exception in favour of https://github.com/sveltejs/svelte/issues/3750
this.is_select_value_attribute = this.name === 'value' && this.parent.node.name === 'select';
this.is_input_value = this.name === 'value' && this.parent.node.name === 'input';
this.should_cache = should_cache(this);
}

@ -124,6 +124,7 @@ export interface CompileOptions {
tag?: string;
css?: boolean;
loopGuardTimeout?: number;
namespace?: string;
preserveComments?: boolean;
preserveWhitespace?: boolean;

@ -1,3 +1,6 @@
// The `foreign` namespace covers all DOM implementations that aren't HTML5.
// It opts out of HTML5-specific a11y checks and case-insensitive attribute names.
export const foreign = 'https://svelte.dev/docs#svelte_options';
export const html = 'http://www.w3.org/1999/xhtml';
export const mathml = 'http://www.w3.org/1998/Math/MathML';
export const svg = 'http://www.w3.org/2000/svg';
@ -6,12 +9,14 @@ export const xml = 'http://www.w3.org/XML/1998/namespace';
export const xmlns = 'http://www.w3.org/2000/xmlns';
export const valid_namespaces = [
'foreign',
'html',
'mathml',
'svg',
'xlink',
'xml',
'xmlns',
foreign,
html,
mathml,
svg,
@ -20,4 +25,4 @@ export const valid_namespaces = [
xmlns
];
export const namespaces: Record<string, string> = { html, mathml, svg, xlink, xml, xmlns };
export const namespaces: Record<string, string> = { foreign, html, mathml, svg, xlink, xml, xmlns };

@ -0,0 +1,20 @@
// Test support for the `foreign` namespace preserving attribute case.
export default {
html: `
<page horizontalAlignment="center">
<button textWrap="true" text="button">
</page>
`,
options: {
hydrate: false // Hydration test will fail as case sensitivity is only handled for svg elements.
},
compileOptions: {
namespace: 'foreign'
},
test({ assert, target }) {
const attr = sel => target.querySelector(sel).attributes[0].name;
assert.equal(attr('page'), 'horizontalAlignment');
assert.equal(attr('button'), 'textWrap');
}
};

@ -0,0 +1,3 @@
<page horizontalAlignment="center">
<button textWrap="true" text="button">
</page>

@ -0,0 +1,18 @@
// Test support for the `foreign` namespace preserving attribute case.
export default {
html: `
<page horizontalAlignment="center">
<button textWrap="true" text="button">
</page>
`,
options: {
hydrate: false // Hydration test will fail as case sensitivity is only handled for svg elements.
},
test({ assert, target }) {
const attr = sel => target.querySelector(sel).attributes[0].name;
assert.equal(attr('page'), 'horizontalAlignment');
assert.equal(attr('button'), 'textWrap');
}
};

@ -0,0 +1,4 @@
<svelte:options namespace="foreign" />
<page horizontalAlignment="center">
<button textWrap="true" text="button">
</page>

@ -101,4 +101,22 @@ describe('validate', () => {
assert.deepEqual(warnings, []);
});
it('errors if namespace is provided but unrecognised', () => {
assert.throws(() => {
svelte.compile('<div></div>', {
name: 'test',
namespace: 'svefefe'
});
}, /Invalid namespace 'svefefe'/);
});
it('errors with a hint if namespace is provided but unrecognised but close', () => {
assert.throws(() => {
svelte.compile('<div></div>', {
name: 'test',
namespace: 'foriegn'
});
}, /Invalid namespace 'foriegn' \(did you mean 'foreign'\?\)/);
});
});

@ -0,0 +1,7 @@
<svelte:options namespace="foreign" />
<page>
<a>not actually a link</a>
<label>This isn't a html label</label>
<figure>This is maybe a QT figure</figure>
</page>

@ -0,0 +1,15 @@
[{
"code": "invalid-binding",
"message": "'value' is not a valid binding. Foreign elements only support bind:this",
"pos": 81,
"start": {
"line": 6,
"column": 7,
"character": 81
},
"end": {
"line": 6,
"column": 28,
"character": 102
}
}]

@ -0,0 +1,6 @@
<svelte:options namespace="foreign" />
<script>
let whatever;
</script>
<input bind:value={whatever} />
Loading…
Cancel
Save