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/17306/merge
Rich Harris 2 days 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 {number} start
* @param {boolean} is_html
*/
push_element(name, start) {
push_element(name, start, is_html) {
this.#element = {
type: 'element',
name,
attributes: {},
children: [],
is_html,
start
};
@ -100,7 +102,7 @@ function stringify(item) {
for (const key in item.attributes) {
const value = item.attributes[key];
str += ` ${key}`;
str += ` ${item.is_html ? key.toLowerCase() : key}`;
if (value !== undefined) str += `="${escape_html(value, true)}"`;
}

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

@ -38,9 +38,11 @@ import { TEMPLATE_FRAGMENT } from '../../../../../constants.js';
* @param {ComponentContext} 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();
return;
}
@ -53,9 +55,9 @@ export function RegularElement(node, context) {
// Therefore we need to use importNode instead, which doesn't have this caveat.
// Additionally, Webkit browsers need importNode for video elements for autoplay
// 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>} */
const attributes = [];
@ -161,7 +163,7 @@ export function RegularElement(node, context) {
}
}
if (node.name === 'input') {
if (name === 'input') {
const has_value_attribute = attributes.some(
(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 needs_content_reset = attribute && !is_text_attribute(attribute);
@ -206,10 +208,7 @@ export function RegularElement(node, context) {
/** If true, needs `__value` for inputs */
const needs_special_value_handling =
node.name === 'option' ||
node.name === 'select' ||
bindings.has('group') ||
bindings.has('checked');
name === 'option' || name === 'select' || bindings.has('group') || bindings.has('checked');
if (has_spread) {
build_attribute_effect(
@ -258,7 +257,6 @@ export function RegularElement(node, context) {
let { value } = build_attribute_value(attribute.value, context);
context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value)));
} 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);
} else if (name === 'style') {
build_set_style(node_id, attribute, style_directives, context);
@ -279,7 +277,7 @@ export function RegularElement(node, context) {
}
if (
is_load_error_element(node.name) &&
is_load_error_element(name) &&
(has_spread || has_use || lookup.has('onload') || lookup.has('onerror'))
) {
context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id)));
@ -307,8 +305,7 @@ export function RegularElement(node, context) {
...context.state,
metadata,
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)),
preserve_whitespace:
context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea'
preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea'
};
const { hoisted, trimmed } = clean_nodes(
@ -317,7 +314,7 @@ export function RegularElement(node, context) {
context.path,
state.metadata.namespace,
state,
node.name === 'script' || state.preserve_whitespace,
name === 'script' || state.preserve_whitespace,
state.options.preserveComments
);
@ -363,7 +360,7 @@ export function RegularElement(node, context) {
context.state.template.push_comment();
// 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 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
// set the value of `hydrate_node` to `node.content`
if (node.name === 'template') {
if (name === 'template') {
needs_reset = true;
child_state.init.push(b.stmt(b.call('$.hydrate_template', arg)));
arg = b.member(arg, 'content');
@ -451,7 +448,7 @@ export function RegularElement(node, context) {
context.state.after_update.push(...element_state.after_update);
}
if (node.name === 'selectedcontent') {
if (name === 'selectedcontent') {
context.state.init.push(
b.stmt(
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
// a single-expression child, so we treat the value of that expression as
// 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 {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
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;
}
}

@ -488,10 +488,10 @@ export function build_component(node, component_name, loc, context) {
if (Object.keys(custom_css_props).length > 0) {
if (context.state.metadata.namespace === 'svg') {
// this boils down to <g><!></g>
context.state.template.push_element('g', node.start);
context.state.template.push_element('g', node.start, false);
} else {
// 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');
}

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

@ -235,7 +235,7 @@ export function build_element_attributes(node, context, transform) {
if (name !== 'class' || literal_value) {
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