fix: always lowercase HTML elements, for XHTML compliance (#17664)

Another XHTML thing, per
https://github.com/sveltejs/svelte/pull/17418#issuecomment-3863029273
(that PR doesn't address this issue)

### Before submitting the PR, please make sure you do the following

- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).

### Tests and linting

- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
pull/17683/head
Rich Harris 8 months ago committed by GitHub
parent 015e744962
commit bcd170ceb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: always lowercase HTML elements, for XHTML compliance

@ -30,13 +30,15 @@ export class Template {
/** /**
* @param {string} name * @param {string} name
* @param {number} start * @param {number} start
* @param {boolean} is_html
*/ */
push_element(name, start) { push_element(name, start, is_html) {
this.#element = { this.#element = {
type: 'element', type: 'element',
name, name,
attributes: {}, attributes: {},
children: [], children: [],
is_html,
start start
}; };
@ -100,7 +102,7 @@ function stringify(item) {
for (const key in item.attributes) { for (const key in item.attributes) {
const value = item.attributes[key]; const value = item.attributes[key];
str += ` ${key}`; str += ` ${item.is_html ? key.toLowerCase() : key}`;
if (value !== undefined) str += `="${escape_html(value, true)}"`; if (value !== undefined) str += `="${escape_html(value, true)}"`;
} }

@ -5,6 +5,7 @@ export interface Element {
name: string; name: string;
attributes: Record<string, string | undefined>; attributes: Record<string, string | undefined>;
children: Node[]; children: Node[];
is_html: boolean;
/** used for populating __svelte_meta */ /** used for populating __svelte_meta */
start: number; start: number;
} }

@ -38,9 +38,11 @@ import { TEMPLATE_FRAGMENT } from '../../../../../constants.js';
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function RegularElement(node, context) { export function RegularElement(node, context) {
context.state.template.push_element(node.name, node.start); const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg';
const name = is_html ? node.name.toLowerCase() : node.name;
context.state.template.push_element(name, node.start, is_html);
if (node.name === 'noscript') { if (name === 'noscript') {
context.state.template.pop_element(); context.state.template.pop_element();
return; return;
} }
@ -53,9 +55,9 @@ export function RegularElement(node, context) {
// Therefore we need to use importNode instead, which doesn't have this caveat. // Therefore we need to use importNode instead, which doesn't have this caveat.
// Additionally, Webkit browsers need importNode for video elements for autoplay // Additionally, Webkit browsers need importNode for video elements for autoplay
// to work correctly. // to work correctly.
context.state.template.needs_import_node ||= node.name === 'video' || is_custom_element; context.state.template.needs_import_node ||= name === 'video' || is_custom_element;
context.state.template.contains_script_tag ||= node.name === 'script'; context.state.template.contains_script_tag ||= name === 'script';
/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */ /** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
const attributes = []; const attributes = [];
@ -161,7 +163,7 @@ export function RegularElement(node, context) {
} }
} }
if (node.name === 'input') { if (name === 'input') {
const has_value_attribute = attributes.some( const has_value_attribute = attributes.some(
(attribute) => (attribute) =>
attribute.type === 'Attribute' && attribute.type === 'Attribute' &&
@ -190,7 +192,7 @@ export function RegularElement(node, context) {
} }
} }
if (node.name === 'textarea') { if (name === 'textarea') {
const attribute = lookup.get('value') ?? lookup.get('checked'); const attribute = lookup.get('value') ?? lookup.get('checked');
const needs_content_reset = attribute && !is_text_attribute(attribute); const needs_content_reset = attribute && !is_text_attribute(attribute);
@ -206,10 +208,7 @@ export function RegularElement(node, context) {
/** If true, needs `__value` for inputs */ /** If true, needs `__value` for inputs */
const needs_special_value_handling = const needs_special_value_handling =
node.name === 'option' || name === 'option' || name === 'select' || bindings.has('group') || bindings.has('checked');
node.name === 'select' ||
bindings.has('group') ||
bindings.has('checked');
if (has_spread) { if (has_spread) {
build_attribute_effect( build_attribute_effect(
@ -258,7 +257,6 @@ export function RegularElement(node, context) {
let { value } = build_attribute_value(attribute.value, context); let { value } = build_attribute_value(attribute.value, context);
context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value))); context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value)));
} else if (name === 'class') { } else if (name === 'class') {
const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg';
build_set_class(node, node_id, attribute, class_directives, context, is_html); build_set_class(node, node_id, attribute, class_directives, context, is_html);
} else if (name === 'style') { } else if (name === 'style') {
build_set_style(node_id, attribute, style_directives, context); build_set_style(node_id, attribute, style_directives, context);
@ -279,7 +277,7 @@ export function RegularElement(node, context) {
} }
if ( if (
is_load_error_element(node.name) && is_load_error_element(name) &&
(has_spread || has_use || lookup.has('onload') || lookup.has('onerror')) (has_spread || has_use || lookup.has('onload') || lookup.has('onerror'))
) { ) {
context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id))); context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id)));
@ -307,8 +305,7 @@ export function RegularElement(node, context) {
...context.state, ...context.state,
metadata, metadata,
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)),
preserve_whitespace: preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea'
context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea'
}; };
const { hoisted, trimmed } = clean_nodes( const { hoisted, trimmed } = clean_nodes(
@ -317,7 +314,7 @@ export function RegularElement(node, context) {
context.path, context.path,
state.metadata.namespace, state.metadata.namespace,
state, state,
node.name === 'script' || state.preserve_whitespace, name === 'script' || state.preserve_whitespace,
state.options.preserveComments state.options.preserveComments
); );
@ -363,7 +360,7 @@ export function RegularElement(node, context) {
context.state.template.push_comment(); context.state.template.push_comment();
// Create a separate template for the rich content // Create a separate template for the rich content
const template_name = context.state.scope.root.unique(`${node.name}_content`); const template_name = context.state.scope.root.unique(`${name}_content`);
const fragment_id = b.id(context.state.scope.generate('fragment')); const fragment_id = b.id(context.state.scope.generate('fragment'));
const anchor_id = b.id(context.state.scope.generate('anchor')); const anchor_id = b.id(context.state.scope.generate('anchor'));
@ -414,7 +411,7 @@ export function RegularElement(node, context) {
// The same applies if it's a `<template>` element, since we need to // The same applies if it's a `<template>` element, since we need to
// set the value of `hydrate_node` to `node.content` // set the value of `hydrate_node` to `node.content`
if (node.name === 'template') { if (name === 'template') {
needs_reset = true; needs_reset = true;
child_state.init.push(b.stmt(b.call('$.hydrate_template', arg))); child_state.init.push(b.stmt(b.call('$.hydrate_template', arg)));
arg = b.member(arg, 'content'); arg = b.member(arg, 'content');
@ -451,7 +448,7 @@ export function RegularElement(node, context) {
context.state.after_update.push(...element_state.after_update); context.state.after_update.push(...element_state.after_update);
} }
if (node.name === 'selectedcontent') { if (name === 'selectedcontent') {
context.state.init.push( context.state.init.push(
b.stmt( b.stmt(
b.call( b.call(
@ -483,11 +480,11 @@ export function RegularElement(node, context) {
// this node is an `option` that didn't have a `value` attribute, but had // this node is an `option` that didn't have a `value` attribute, but had
// a single-expression child, so we treat the value of that expression as // a single-expression child, so we treat the value of that expression as
// the value of the option // the value of the option
build_element_special_value_attribute(node.name, node_id, synthetic_attribute, context, true); build_element_special_value_attribute(name, node_id, synthetic_attribute, context, true);
} else { } else {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) { for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (attribute.name === 'value') { if (attribute.name === 'value') {
build_element_special_value_attribute(node.name, node_id, attribute, context); build_element_special_value_attribute(name, node_id, attribute, context);
break; break;
} }
} }

@ -488,10 +488,10 @@ export function build_component(node, component_name, loc, context) {
if (Object.keys(custom_css_props).length > 0) { if (Object.keys(custom_css_props).length > 0) {
if (context.state.metadata.namespace === 'svg') { if (context.state.metadata.namespace === 'svg') {
// this boils down to <g><!></g> // this boils down to <g><!></g>
context.state.template.push_element('g', node.start); context.state.template.push_element('g', node.start, false);
} else { } else {
// this boils down to <svelte-css-wrapper style='display: contents'><!></svelte-css-wrapper> // this boils down to <svelte-css-wrapper style='display: contents'><!></svelte-css-wrapper>
context.state.template.push_element('svelte-css-wrapper', node.start); context.state.template.push_element('svelte-css-wrapper', node.start, false);
context.state.template.set_prop('style', 'display: contents'); context.state.template.set_prop('style', 'display: contents');
} }

@ -15,6 +15,7 @@ import { is_customizable_select_element } from '../../../nodes.js';
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function RegularElement(node, context) { export function RegularElement(node, context) {
const name = context.state.namespace === 'html' ? node.name.toLowerCase() : node.name;
const namespace = determine_namespace_for_children(node, context.state.namespace); const namespace = determine_namespace_for_children(node, context.state.namespace);
/** @type {ComponentServerTransformState} */ /** @type {ComponentServerTransformState} */
@ -27,7 +28,7 @@ export function RegularElement(node, context) {
template: [] template: []
}; };
const node_is_void = is_void(node.name); const node_is_void = is_void(name);
const optimiser = new PromiseOptimiser(); const optimiser = new PromiseOptimiser();
@ -35,28 +36,28 @@ export function RegularElement(node, context) {
// avoid calling build_element_attributes here to prevent evaluating/awaiting // avoid calling build_element_attributes here to prevent evaluating/awaiting
// attribute expressions twice. We'll handle attributes in the special branch. // attribute expressions twice. We'll handle attributes in the special branch.
const is_select_special = const is_select_special =
node.name === 'select' && name === 'select' &&
node.attributes.some( node.attributes.some(
(attribute) => (attribute) =>
((attribute.type === 'Attribute' || attribute.type === 'BindDirective') && ((attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value') || attribute.name === 'value') ||
attribute.type === 'SpreadAttribute' attribute.type === 'SpreadAttribute'
); );
const is_option_special = node.name === 'option'; const is_option_special = name === 'option';
const is_special = is_select_special || is_option_special; const is_special = is_select_special || is_option_special;
let body = /** @type {Expression | null} */ (null); let body = /** @type {Expression | null} */ (null);
if (!is_special) { if (!is_special) {
// only open the tag in the non-special path // only open the tag in the non-special path
state.template.push(b.literal(`<${node.name}`)); state.template.push(b.literal(`<${name}`));
body = build_element_attributes(node, { ...context, state }, optimiser.transform); body = build_element_attributes(node, { ...context, state }, optimiser.transform);
state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance
} }
if ((node.name === 'script' || node.name === 'style') && node.fragment.nodes.length === 1) { if ((name === 'script' || name === 'style') && node.fragment.nodes.length === 1) {
state.template.push( state.template.push(
b.literal(/** @type {AST.Text} */ (node.fragment.nodes[0]).data), b.literal(/** @type {AST.Text} */ (node.fragment.nodes[0]).data),
b.literal(`</${node.name}>`) b.literal(`</${name}>`)
); );
context.state.template.push( context.state.template.push(
@ -90,7 +91,7 @@ export function RegularElement(node, context) {
b.call( b.call(
'$.push_element', '$.push_element',
b.id('$$renderer'), b.id('$$renderer'),
b.literal(node.name), b.literal(name),
b.literal(location.line), b.literal(location.line),
b.literal(location.column) b.literal(location.column)
) )
@ -142,7 +143,7 @@ export function RegularElement(node, context) {
b.call( b.call(
'$.push_element', '$.push_element',
b.id('$$renderer'), b.id('$$renderer'),
b.literal(node.name), b.literal(name),
b.literal(location.line), b.literal(location.line),
b.literal(location.column) b.literal(location.column)
) )
@ -191,16 +192,13 @@ export function RegularElement(node, context) {
} else { } else {
// For optgroup or select with rich content, add hydration marker at the start // For optgroup or select with rich content, add hydration marker at the start
process_children(trimmed, { ...context, state }); process_children(trimmed, { ...context, state });
if ( if ((name === 'optgroup' || name === 'select') && is_customizable_select_element(node)) {
(node.name === 'optgroup' || node.name === 'select') &&
is_customizable_select_element(node)
) {
state.template.push(b.literal('<!>')); state.template.push(b.literal('<!>'));
} }
} }
if (!node_is_void) { if (!node_is_void) {
state.template.push(b.literal(`</${node.name}>`)); state.template.push(b.literal(`</${name}>`));
} }
if (dev) { if (dev) {

@ -235,7 +235,7 @@ export function build_element_attributes(node, context, transform) {
if (name !== 'class' || literal_value) { if (name !== 'class' || literal_value) {
context.state.template.push( context.state.template.push(
b.literal(` ${attribute.name}="${literal_value === true ? '' : String(literal_value)}"`) b.literal(` ${name}="${literal_value === true ? '' : String(literal_value)}"`)
); );
} }

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
test({ assert, target }) {
const [input] = target.querySelectorAll('input');
assert.equal(input.disabled, true);
}
});
Loading…
Cancel
Save