diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index f734c2b625c9..aaa230dba27a 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -131,7 +131,7 @@ export default class Element extends Node { this.namespace = get_namespace(parent, this, component.namespace); - if (this.name === 'textarea') { + if (this.name === 'textarea' && !this.is_native()) { if (info.children.length > 0) { const value_attribute = info.attributes.find(node => node.name === 'value'); if (value_attribute) { @@ -153,7 +153,7 @@ export default class Element extends Node { } } - if (this.name === 'option') { + if (this.name === 'option' && !this.is_native()) { // Special case — treat these the same way: // // @@ -248,51 +248,53 @@ 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` - }); - } + if (!this.is_native()) { + if (a11y_distracting_elements.has(this.name)) { + // no-distracting-elements + this.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; + 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; + while (parent) { + if ((parent as Element).name === 'figure') { + is_figure_parent = true; + break; + } + if (parent.type === 'Element') { + break; + } + parent = parent.parent; } - if (parent.type === 'Element') { - break; + + if (!is_figure_parent) { + this.component.warn(this, { + code: 'a11y-structure', + message: 'A11y:
must be an immediate child of
' + }); } - parent = parent.parent; } - if (!is_figure_parent) { - this.component.warn(this, { - code: 'a11y-structure', - message: 'A11y:
must be an immediate child of
' + 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 (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'); + const index = children.findIndex(child => (child as Element).name === 'figcaption'); - if (index !== -1 && (index !== 0 && index !== children.length - 1)) { - this.component.warn(children[index], { - code: 'a11y-structure', - message: 'A11y:
must be first or last child of
' - }); + if (index !== -1 && (index !== 0 && index !== children.length - 1)) { + this.component.warn(children[index], { + code: 'a11y-structure', + message: 'A11y:
must be first or last child of
' + }); + } } } @@ -306,13 +308,50 @@ export default class Element extends Node { validate_attributes() { const { component, parent } = this; - const attribute_map = new Map(); - 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 (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' + }); + } + } + + // The rest of the warnings don't apply to native elements + if (this.is_native()) return; + + // Warnings + // aria-props if (name.startsWith('aria-')) { if (invisible_elements.has(this.name)) { @@ -404,52 +443,20 @@ 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' }); } - - attribute_map.set(attribute.name, attribute); }); } validate_special_cases() { const { component, attributes, handlers } = this; + + if (this.is_native()) return; + const attribute_map = new Map(); const handlers_map = new Map(); @@ -595,6 +602,16 @@ export default class Element extends Node { return value; }; + if (this.is_native()) { + this.bindings.forEach(binding => { + component.error(binding, { + code: 'invalid-binding', + message: `'${binding.name}' is not a valid binding. Native elements only support bind:this` + }); + }); + return; + } + this.bindings.forEach(binding => { const { name } = binding; @@ -754,7 +771,7 @@ export default class Element extends Node { } validate_content() { - if (!a11y_required_content.has(this.name)) return; + if (!a11y_required_content.has(this.name) || this.is_native()) return; if ( this.bindings .some((binding) => ['textContent', 'innerHTML'].includes(binding.name)) @@ -831,6 +848,10 @@ export default class Element extends Node { return this.name === 'audio' || this.name === 'video'; } + is_native() { + return this.namespace == namespaces['native']; + } + add_css_class() { if (this.attributes.some(attr => attr.is_spread)) { this.needs_manual_style_scoping = true; diff --git a/src/compiler/compile/render_dom/wrappers/Element/Attribute.ts b/src/compiler/compile/render_dom/wrappers/Element/Attribute.ts index 2969a7d82c9e..6de64313bba4 100644 --- a/src/compiler/compile/render_dom/wrappers/Element/Attribute.ts +++ b/src/compiler/compile/render_dom/wrappers/Element/Attribute.ts @@ -67,15 +67,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.is_native()) { + // leave attribute case alone for elements in the "native" 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); } diff --git a/src/compiler/utils/namespaces.ts b/src/compiler/utils/namespaces.ts index 3db7bc3fae0b..d1d0642cfd49 100644 --- a/src/compiler/utils/namespaces.ts +++ b/src/compiler/utils/namespaces.ts @@ -1,23 +1,27 @@ export const html = 'http://www.w3.org/1999/xhtml'; export const mathml = 'http://www.w3.org/1998/Math/MathML'; +export const native = 'http://svelte-native.technology/2019/native'; export const svg = 'http://www.w3.org/2000/svg'; export const xlink = 'http://www.w3.org/1999/xlink'; export const xml = 'http://www.w3.org/XML/1998/namespace'; export const xmlns = 'http://www.w3.org/2000/xmlns'; + export const valid_namespaces = [ 'html', 'mathml', + 'native', 'svg', 'xlink', 'xml', 'xmlns', html, mathml, + native, svg, xlink, xml, xmlns ]; -export const namespaces: Record = { html, mathml, svg, xlink, xml, xmlns }; +export const namespaces: Record = { html, mathml, native, svg, xlink, xml, xmlns }; diff --git a/test/runtime/samples/attribute-casing-native-namespace/_config.js b/test/runtime/samples/attribute-casing-native-namespace/_config.js new file mode 100644 index 000000000000..6c61d3c8b306 --- /dev/null +++ b/test/runtime/samples/attribute-casing-native-namespace/_config.js @@ -0,0 +1,18 @@ +// Test support for the native namespaces preserving attribute case (eg svelte-native, svelte-nodegui). + +export default { + html: ` + +