breaking: warn on quotes single-expression attributes in runes mode (#12479)

* parse `foo={bar}` attribute as `value: { type: 'ExpressionTag', .. }` (i.e. don't wrap in an array)

* warn on quoted single-expression-attributes

* breaking: warn on quotes single-expression attributes in runes mode

In a future version, that will mean things are getting stringified, which is a departure from how things work today, therefore a warning first.
Related to #7925

* Update .changeset/plenty-items-build.md

* Apply suggestions from code review

* missed a spot

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/12478/head
Simon H 8 months ago committed by GitHub
parent b88e667b85
commit 32b55eaa93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
breaking: warn on quoted single-expression attributes in runes mode

@ -14,6 +14,10 @@
> '%wrong%' is not a valid HTML attribute. Did you mean '%right%'? > '%wrong%' is not a valid HTML attribute. Did you mean '%right%'?
## attribute_quoted
> Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes
## bind_invalid_each_rest ## bind_invalid_each_rest
> The rest operator (...) will create a new object and binding '%name%' with the original object will not work > The rest operator (...) will create a new object and binding '%name%' with the original object will not work

@ -411,6 +411,34 @@ export function convert(source, ast) {
) )
}; };
}, },
Attribute(node, { visit, next, path }) {
if (node.value !== true && !Array.isArray(node.value)) {
path.push(node);
const value = /** @type {Legacy.LegacyAttribute['value']} */ ([visit(node.value)]);
path.pop();
return {
...node,
value
};
} else {
return next();
}
},
StyleDirective(node, { visit, next, path }) {
if (node.value !== true && !Array.isArray(node.value)) {
path.push(node);
const value = /** @type {Legacy.LegacyStyleDirective['value']} */ ([visit(node.value)]);
path.pop();
return {
...node,
value
};
} else {
return next();
}
},
SpreadAttribute(node) { SpreadAttribute(node) {
return { ...node, type: 'Spread' }; return { ...node, type: 'Spread' };
}, },

@ -532,11 +532,13 @@ const template = {
if (attr.name === 'name') { if (attr.name === 'name') {
slot_name = /** @type {any} */ (attr.value)[0].data; slot_name = /** @type {any} */ (attr.value)[0].data;
} else { } else {
const attr_value =
attr.value === true || Array.isArray(attr.value) ? attr.value : [attr.value];
const value = const value =
attr.value !== true attr_value !== true
? state.str.original.substring( ? state.str.original.substring(
attr.value[0].start, attr_value[0].start,
attr.value[attr.value.length - 1].end attr_value[attr_value.length - 1].end
) )
: 'true'; : 'true';
slot_props += value === attr.name ? `${value}, ` : `${attr.name}: ${value}, `; slot_props += value === attr.name ? `${value}, ` : `${attr.name}: ${value}, `;

@ -41,8 +41,9 @@ export default function read_options(node) {
case 'customElement': { case 'customElement': {
/** @type {SvelteOptions['customElement']} */ /** @type {SvelteOptions['customElement']} */
const ce = { tag: '' }; const ce = { tag: '' };
const { value: v } = attribute;
const value = v === true || Array.isArray(v) ? v : [v];
const { value } = attribute;
if (value === true) { if (value === true) {
e.svelte_options_invalid_customelement(attribute); e.svelte_options_invalid_customelement(attribute);
} else if (value[0].type === 'Text') { } else if (value[0].type === 'Text') {
@ -199,7 +200,11 @@ export default function read_options(node) {
*/ */
function get_static_value(attribute) { function get_static_value(attribute) {
const { value } = attribute; const { value } = attribute;
const chunk = value[0];
if (value === true) return true;
const chunk = Array.isArray(value) ? value[0] : value;
if (!chunk) return true; if (!chunk) return true;
if (value.length > 1) { if (value.length > 1) {
return null; return null;
@ -208,6 +213,7 @@ function get_static_value(attribute) {
if (chunk.expression.type !== 'Literal') { if (chunk.expression.type !== 'Literal') {
return null; return null;
} }
return chunk.expression.value; return chunk.expression.value;
} }

@ -9,6 +9,7 @@ import * as e from '../../../errors.js';
import * as w from '../../../warnings.js'; import * as w from '../../../warnings.js';
import { create_fragment } from '../utils/create.js'; import { create_fragment } from '../utils/create.js';
import { create_attribute } from '../../nodes.js'; import { create_attribute } from '../../nodes.js';
import { get_attribute_expression, is_expression_attribute } from '../../../utils/ast.js';
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
const valid_tag_name = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/; const valid_tag_name = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/;
@ -241,15 +242,11 @@ export default function element(parser) {
} }
const definition = /** @type {Compiler.Attribute} */ (element.attributes.splice(index, 1)[0]); const definition = /** @type {Compiler.Attribute} */ (element.attributes.splice(index, 1)[0]);
if ( if (!is_expression_attribute(definition)) {
definition.value === true ||
definition.value.length !== 1 ||
definition.value[0].type === 'Text'
) {
e.svelte_component_invalid_this(definition.start); e.svelte_component_invalid_this(definition.start);
} }
element.expression = definition.value[0].expression; element.expression = get_attribute_expression(definition);
} }
if (element.type === 'SvelteElement') { if (element.type === 'SvelteElement') {
@ -267,15 +264,16 @@ export default function element(parser) {
e.svelte_element_missing_this(definition); e.svelte_element_missing_this(definition);
} }
const chunk = definition.value[0]; if (!is_expression_attribute(definition)) {
if (definition.value.length !== 1 || chunk.type !== 'ExpressionTag') {
w.svelte_element_invalid_this(definition); w.svelte_element_invalid_this(definition);
// note that this is wrong, in the case of e.g. `this="h{n}"` — it will result in `<h>`. // note that this is wrong, in the case of e.g. `this="h{n}"` — it will result in `<h>`.
// it would be much better to just error here, but we are preserving the existing buggy // it would be much better to just error here, but we are preserving the existing buggy
// Svelte 4 behaviour out of an overabundance of caution regarding breaking changes. // Svelte 4 behaviour out of an overabundance of caution regarding breaking changes.
// TODO in 6.0, error // TODO in 6.0, error
const chunk = /** @type {Array<Compiler.ExpressionTag | Compiler.Text>} */ (
definition.value
)[0];
element.tag = element.tag =
chunk.type === 'Text' chunk.type === 'Text'
? { ? {
@ -287,7 +285,7 @@ export default function element(parser) {
} }
: chunk.expression; : chunk.expression;
} else { } else {
element.tag = chunk.expression; element.tag = get_attribute_expression(definition);
} }
} }
@ -543,7 +541,7 @@ function read_attribute(parser) {
} }
}; };
return create_attribute(name, start, parser.index, [expression]); return create_attribute(name, start, parser.index, expression);
} }
} }
@ -557,7 +555,7 @@ function read_attribute(parser) {
const colon_index = name.indexOf(':'); const colon_index = name.indexOf(':');
const type = colon_index !== -1 && get_directive_type(name.slice(0, colon_index)); const type = colon_index !== -1 && get_directive_type(name.slice(0, colon_index));
/** @type {true | Array<Compiler.Text | Compiler.ExpressionTag>} */ /** @type {true | Compiler.ExpressionTag | Array<Compiler.Text | Compiler.ExpressionTag>} */
let value = true; let value = true;
if (parser.eat('=')) { if (parser.eat('=')) {
parser.allow_whitespace(); parser.allow_whitespace();
@ -589,7 +587,9 @@ function read_attribute(parser) {
}; };
} }
const first_value = value === true ? undefined : value[0]; const first_value = value === true ? undefined : Array.isArray(value) ? value[0] : value;
/** @type {import('estree').Expression | null} */
let expression = null; let expression = null;
if (first_value) { if (first_value) {
@ -598,6 +598,8 @@ function read_attribute(parser) {
if (attribute_contains_text) { if (attribute_contains_text) {
e.directive_invalid_value(/** @type {number} */ (first_value.start)); e.directive_invalid_value(/** @type {number} */ (first_value.start));
} else { } else {
// TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`,
// which means stringified value, which isn't allowed for some directives?
expression = first_value.expression; expression = first_value.expression;
} }
} }
@ -662,6 +664,7 @@ function get_directive_type(name) {
/** /**
* @param {Parser} parser * @param {Parser} parser
* @return {Compiler.ExpressionTag | Array<Compiler.ExpressionTag | Compiler.Text>}
*/ */
function read_attribute_value(parser) { function read_attribute_value(parser) {
const quote_mark = parser.eat("'") ? "'" : parser.eat('"') ? '"' : null; const quote_mark = parser.eat("'") ? "'" : parser.eat('"') ? '"' : null;
@ -678,6 +681,7 @@ function read_attribute_value(parser) {
]; ];
} }
/** @type {Array<Compiler.ExpressionTag | Compiler.Text>} */
let value; let value;
try { try {
value = read_sequence( value = read_sequence(
@ -708,7 +712,12 @@ function read_attribute_value(parser) {
} }
if (quote_mark) parser.index += 1; if (quote_mark) parser.index += 1;
return value;
if (quote_mark || value.length > 1 || value[0].type === 'Text') {
return value;
} else {
return value[0];
}
} }
/** /**

@ -3,6 +3,7 @@
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import { get_possible_values } from './utils.js'; import { get_possible_values } from './utils.js';
import { regex_ends_with_whitespace, regex_starts_with_whitespace } from '../../patterns.js'; import { regex_ends_with_whitespace, regex_starts_with_whitespace } from '../../patterns.js';
import { get_attribute_chunks, is_text_attribute } from '../../../utils/ast.js';
/** /**
* @typedef {{ * @typedef {{
@ -444,14 +445,11 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
if (attribute.value === true) return operator === null; if (attribute.value === true) return operator === null;
if (expected_value === null) return true; if (expected_value === null) return true;
const chunks = attribute.value; if (is_text_attribute(attribute)) {
if (chunks.length === 1) { return test_attribute(operator, expected_value, case_insensitive, attribute.value[0].data);
const value = chunks[0];
if (value.type === 'Text') {
return test_attribute(operator, expected_value, case_insensitive, value.data);
}
} }
const chunks = get_attribute_chunks(attribute.value);
const possible_values = new Set(); const possible_values = new Set();
/** @type {string[]} */ /** @type {string[]} */

@ -9,7 +9,9 @@ import {
is_event_attribute, is_event_attribute,
is_text_attribute, is_text_attribute,
object, object,
unwrap_optional unwrap_optional,
get_attribute_expression,
get_attribute_chunks
} from '../../utils/ast.js'; } from '../../utils/ast.js';
import * as b from '../../utils/builders.js'; import * as b from '../../utils/builders.js';
import { MathMLElements, ReservedKeywords, Runes, SVGElements } from '../constants.js'; import { MathMLElements, ReservedKeywords, Runes, SVGElements } from '../constants.js';
@ -597,19 +599,24 @@ export function analyze_component(root, source, options) {
} }
if (class_attribute && class_attribute.value !== true) { if (class_attribute && class_attribute.value !== true) {
const chunks = class_attribute.value; if (is_text_attribute(class_attribute)) {
class_attribute.value[0].data += ` ${analysis.css.hash}`;
if (chunks.length === 1 && chunks[0].type === 'Text') {
chunks[0].data += ` ${analysis.css.hash}`;
} else { } else {
chunks.push({ /** @type {import('#compiler').Text} */
const css_text = {
type: 'Text', type: 'Text',
data: ` ${analysis.css.hash}`, data: ` ${analysis.css.hash}`,
raw: ` ${analysis.css.hash}`, raw: ` ${analysis.css.hash}`,
start: -1, start: -1,
end: -1, end: -1,
parent: null parent: null
}); };
if (Array.isArray(class_attribute.value)) {
class_attribute.value.push(css_text);
} else {
class_attribute.value = [class_attribute.value, css_text];
}
} }
} else { } else {
element.attributes.push( element.attributes.push(
@ -1171,7 +1178,7 @@ const common_visitors = {
context.next(); context.next();
node.metadata.dynamic = node.value.some((chunk) => { node.metadata.dynamic = get_attribute_chunks(node.value).some((chunk) => {
if (chunk.type !== 'ExpressionTag') { if (chunk.type !== 'ExpressionTag') {
return false; return false;
} }
@ -1192,8 +1199,7 @@ const common_visitors = {
context.state.analysis.uses_event_attributes = true; context.state.analysis.uses_event_attributes = true;
} }
const expression = node.value[0].expression; const expression = get_attribute_expression(node);
const delegated_event = get_delegated_event(node.name.slice(2), expression, context); const delegated_event = get_delegated_event(node.name.slice(2), expression, context);
if (delegated_event !== null) { if (delegated_event !== null) {
@ -1228,7 +1234,7 @@ const common_visitors = {
} }
} else { } else {
context.next(); context.next();
node.metadata.dynamic = node.value.some( node.metadata.dynamic = get_attribute_chunks(node.value).some(
(node) => node.type === 'ExpressionTag' && node.metadata.dynamic (node) => node.type === 'ExpressionTag' && node.metadata.dynamic
); );
} }

@ -7,6 +7,7 @@ import {
import * as e from '../../errors.js'; import * as e from '../../errors.js';
import { import {
extract_identifiers, extract_identifiers,
get_attribute_expression,
get_parent, get_parent,
is_expression_attribute, is_expression_attribute,
is_text_attribute, is_text_attribute,
@ -33,9 +34,26 @@ import { Scope, get_rune } from '../scope.js';
import { merge } from '../visitors.js'; import { merge } from '../visitors.js';
import { a11y_validators } from './a11y.js'; import { a11y_validators } from './a11y.js';
/** @param {import('#compiler').Attribute} attribute */ /**
function validate_attribute(attribute) { * @param {import('#compiler').Attribute} attribute
if (attribute.value === true || attribute.value.length === 1) return; * @param {import('#compiler').ElementLike} parent
*/
function validate_attribute(attribute, parent) {
if (
Array.isArray(attribute.value) &&
attribute.value.length === 1 &&
attribute.value[0].type === 'ExpressionTag' &&
(parent.type === 'Component' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteSelf' ||
(parent.type === 'RegularElement' && is_custom_element_node(parent)))
) {
w.attribute_quoted(attribute);
}
if (attribute.value === true || !Array.isArray(attribute.value) || attribute.value.length === 1) {
return;
}
const is_quoted = attribute.value.at(-1)?.end !== attribute.end; const is_quoted = attribute.value.at(-1)?.end !== attribute.end;
@ -69,10 +87,10 @@ function validate_component(node, context) {
if (attribute.type === 'Attribute') { if (attribute.type === 'Attribute') {
if (context.state.analysis.runes) { if (context.state.analysis.runes) {
validate_attribute(attribute); validate_attribute(attribute, node);
if (is_expression_attribute(attribute)) { if (is_expression_attribute(attribute)) {
const expression = attribute.value[0].expression; const expression = get_attribute_expression(attribute);
if (expression.type === 'SequenceExpression') { if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start); let i = /** @type {number} */ (expression.start);
while (--i > 0) { while (--i > 0) {
@ -122,10 +140,10 @@ function validate_element(node, context) {
const is_expression = is_expression_attribute(attribute); const is_expression = is_expression_attribute(attribute);
if (context.state.analysis.runes) { if (context.state.analysis.runes) {
validate_attribute(attribute); validate_attribute(attribute, node);
if (is_expression) { if (is_expression) {
const expression = attribute.value[0].expression; const expression = get_attribute_expression(attribute);
if (expression.type === 'SequenceExpression') { if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start); let i = /** @type {number} */ (expression.start);
while (--i > 0) { while (--i > 0) {
@ -146,7 +164,7 @@ function validate_element(node, context) {
e.attribute_invalid_event_handler(attribute); e.attribute_invalid_event_handler(attribute);
} }
const value = attribute.value[0].expression; const value = get_attribute_expression(attribute);
if ( if (
value.type === 'Identifier' && value.type === 'Identifier' &&
value.name === attribute.name && value.name === attribute.name &&

@ -2,6 +2,8 @@
import { import {
extract_identifiers, extract_identifiers,
extract_paths, extract_paths,
get_attribute_chunks,
get_attribute_expression,
is_event_attribute, is_event_attribute,
is_text_attribute, is_text_attribute,
object, object,
@ -96,11 +98,9 @@ function serialize_style_directives(style_directives, element_id, context, is_at
) )
); );
const contains_call_expression = const contains_call_expression = get_attribute_chunks(directive.value).some(
Array.isArray(directive.value) && (v) => v.type === 'ExpressionTag' && v.metadata.contains_call_expression
directive.value.some( );
(v) => v.type === 'ExpressionTag' && v.metadata.contains_call_expression
);
if (!is_attributes_reactive && contains_call_expression) { if (!is_attributes_reactive && contains_call_expression) {
state.init.push(serialize_update(update)); state.init.push(serialize_update(update));
@ -286,8 +286,8 @@ function serialize_element_spread_attributes(
if ( if (
is_event_attribute(attribute) && is_event_attribute(attribute) &&
(attribute.value[0].expression.type === 'ArrowFunctionExpression' || (get_attribute_expression(attribute).type === 'ArrowFunctionExpression' ||
attribute.value[0].expression.type === 'FunctionExpression') get_attribute_expression(attribute).type === 'FunctionExpression')
) { ) {
// Give the event handler a stable ID so it isn't removed and readded on every update // Give the event handler a stable ID so it isn't removed and readded on every update
const id = context.state.scope.generate('event_handler'); const id = context.state.scope.generate('event_handler');
@ -385,8 +385,8 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) {
if ( if (
is_event_attribute(attribute) && is_event_attribute(attribute) &&
(attribute.value[0].expression.type === 'ArrowFunctionExpression' || (get_attribute_expression(attribute).type === 'ArrowFunctionExpression' ||
attribute.value[0].expression.type === 'FunctionExpression') get_attribute_expression(attribute).type === 'FunctionExpression')
) { ) {
// Give the event handler a stable ID so it isn't removed and readded on every update // Give the event handler a stable ID so it isn't removed and readded on every update
const id = context.state.scope.generate('event_handler'); const id = context.state.scope.generate('event_handler');
@ -757,15 +757,13 @@ function serialize_inline_component(node, component_name, context, anchor = cont
// When we have a non-simple computation, anything other than an Identifier or Member expression, // When we have a non-simple computation, anything other than an Identifier or Member expression,
// then there's a good chance it needs to be memoized to avoid over-firing when read within the // then there's a good chance it needs to be memoized to avoid over-firing when read within the
// child component. // child component.
const should_wrap_in_derived = const should_wrap_in_derived = get_attribute_chunks(attribute.value).some((n) => {
Array.isArray(attribute.value) && return (
attribute.value.some((n) => { n.type === 'ExpressionTag' &&
return ( n.expression.type !== 'Identifier' &&
n.type === 'ExpressionTag' && n.expression.type !== 'MemberExpression'
n.expression.type !== 'Identifier' && );
n.expression.type !== 'MemberExpression' });
);
});
if (should_wrap_in_derived) { if (should_wrap_in_derived) {
const id = b.id(context.state.scope.generate(attribute.name)); const id = b.id(context.state.scope.generate(attribute.name));
@ -1291,7 +1289,7 @@ function serialize_event(node, context) {
} }
/** /**
* @param {import('#compiler').Attribute & { value: [import('#compiler').ExpressionTag] }} node * @param {import('#compiler').Attribute & { value: import('#compiler').ExpressionTag | [import('#compiler').ExpressionTag] }} node
* @param {import('../types').ComponentContext} context * @param {import('../types').ComponentContext} context
*/ */
function serialize_event_attribute(node, context) { function serialize_event_attribute(node, context) {
@ -1307,7 +1305,7 @@ function serialize_event_attribute(node, context) {
serialize_event( serialize_event(
{ {
name: event_name, name: event_name,
expression: node.value[0].expression, expression: get_attribute_expression(node),
modifiers, modifiers,
delegated: node.metadata.delegated delegated: node.metadata.delegated
}, },
@ -1468,7 +1466,7 @@ function get_node_id(expression, state, name) {
} }
/** /**
* @param {true | Array<import('#compiler').Text | import('#compiler').ExpressionTag>} value * @param {import('#compiler').Attribute['value']} value
* @param {import('../types').ComponentContext} context * @param {import('../types').ComponentContext} context
* @returns {[contains_call_expression: boolean, Expression]} * @returns {[contains_call_expression: boolean, Expression]}
*/ */
@ -1477,8 +1475,8 @@ function serialize_attribute_value(value, context) {
return [false, b.literal(true)]; return [false, b.literal(true)];
} }
if (value.length === 1) { if (!Array.isArray(value) || value.length === 1) {
const chunk = value[0]; const chunk = Array.isArray(value) ? value[0] : value;
if (chunk.type === 'Text') { if (chunk.type === 'Text') {
return [false, b.literal(chunk.data)]; return [false, b.literal(chunk.data)];

@ -3,6 +3,7 @@ import { set_scope, get_rune } from '../../scope.js';
import { import {
extract_identifiers, extract_identifiers,
extract_paths, extract_paths,
get_attribute_chunks,
is_event_attribute, is_event_attribute,
is_expression_async, is_expression_async,
is_text_attribute, is_text_attribute,
@ -678,7 +679,7 @@ const javascript_visitors_runes = {
/** /**
* *
* @param {true | Array<import('#compiler').Text | import('#compiler').ExpressionTag>} value * @param {import('#compiler').Attribute['value']} value
* @param {import('./types').ComponentContext} context * @param {import('./types').ComponentContext} context
* @param {boolean} trim_whitespace * @param {boolean} trim_whitespace
* @param {boolean} is_component * @param {boolean} is_component
@ -689,8 +690,8 @@ function serialize_attribute_value(value, context, trim_whitespace = false, is_c
return b.true; return b.true;
} }
if (value.length === 1) { if (!Array.isArray(value) || value.length === 1) {
const chunk = value[0]; const chunk = Array.isArray(value) ? value[0] : value;
if (chunk.type === 'Text') { if (chunk.type === 'Text') {
const data = trim_whitespace const data = trim_whitespace
@ -1667,6 +1668,7 @@ function serialize_element_attributes(node, context) {
if (node.name === 'textarea') { if (node.name === 'textarea') {
if ( if (
attribute.value !== true && attribute.value !== true &&
Array.isArray(attribute.value) &&
attribute.value[0].type === 'Text' && attribute.value[0].type === 'Text' &&
regex_starts_with_newline.test(attribute.value[0].data) regex_starts_with_newline.test(attribute.value[0].data)
) { ) {
@ -1891,15 +1893,19 @@ function serialize_class_directives(class_directives, class_attribute) {
const expressions = class_directives.map((directive) => const expressions = class_directives.map((directive) =>
b.conditional(directive.expression, b.literal(directive.name), b.literal('')) b.conditional(directive.expression, b.literal(directive.name), b.literal(''))
); );
if (class_attribute === null) { if (class_attribute === null) {
class_attribute = create_attribute('class', -1, -1, []); class_attribute = create_attribute('class', -1, -1, []);
} }
const last = /** @type {any[]} */ (class_attribute.value).at(-1);
const chunks = get_attribute_chunks(class_attribute.value);
const last = chunks.at(-1);
if (last?.type === 'Text') { if (last?.type === 'Text') {
last.data += ' '; last.data += ' ';
last.raw += ' '; last.raw += ' ';
} else if (last) { } else if (last) {
/** @type {import('#compiler').Text[]} */ (class_attribute.value).push({ chunks.push({
type: 'Text', type: 'Text',
start: -1, start: -1,
end: -1, end: -1,
@ -1908,7 +1914,8 @@ function serialize_class_directives(class_directives, class_attribute) {
raw: ' ' raw: ' '
}); });
} }
/** @type {import('#compiler').ExpressionTag[]} */ (class_attribute.value).push({
chunks.push({
type: 'ExpressionTag', type: 'ExpressionTag',
start: -1, start: -1,
end: -1, end: -1,
@ -1922,6 +1929,8 @@ function serialize_class_directives(class_directives, class_attribute) {
), ),
metadata: { contains_call_expression: false, dynamic: false } metadata: { contains_call_expression: false, dynamic: false }
}); });
class_attribute.value = chunks;
return class_attribute; return class_attribute;
} }

@ -33,7 +33,7 @@ export function is_custom_element_node(node) {
* @param {string} name * @param {string} name
* @param {number} start * @param {number} start
* @param {number} end * @param {number} end
* @param {true | Array<Compiler.Text | Compiler.ExpressionTag>} value * @param {Compiler.Attribute['value']} value
* @returns {Compiler.Attribute} * @returns {Compiler.Attribute}
*/ */
export function create_attribute(name, start, end, value) { export function create_attribute(name, start, end, value) {

@ -1,4 +1,4 @@
import type { StyleDirective as LegacyStyleDirective, Text, Css } from '#compiler'; import type { Text, Css, ExpressionTag } from '#compiler';
import type { import type {
ArrayExpression, ArrayExpression,
AssignmentExpression, AssignmentExpression,
@ -194,6 +194,16 @@ export interface LegacyTransition extends BaseNode {
outro: boolean; outro: boolean;
} }
/** A `style:` directive */
export interface LegacyStyleDirective extends BaseNode {
type: 'StyleDirective';
/** The 'x' in `style:x` */
name: string;
/** The 'y' in `style:x={y}` */
value: true | Array<ExpressionTag | Text>;
modifiers: Array<'important'>;
}
export interface LegacyWindow extends BaseElement { export interface LegacyWindow extends BaseElement {
type: 'Window'; type: 'Window';
} }

@ -226,7 +226,7 @@ export interface StyleDirective extends BaseNode {
/** The 'x' in `style:x` */ /** The 'x' in `style:x` */
name: string; name: string;
/** The 'y' in `style:x={y}` */ /** The 'y' in `style:x={y}` */
value: true | Array<ExpressionTag | Text>; value: true | ExpressionTag | Array<ExpressionTag | Text>;
modifiers: Array<'important'>; modifiers: Array<'important'>;
metadata: { metadata: {
dynamic: boolean; dynamic: boolean;
@ -447,7 +447,7 @@ export type Block = EachBlock | IfBlock | AwaitBlock | KeyBlock | SnippetBlock;
export interface Attribute extends BaseNode { export interface Attribute extends BaseNode {
type: 'Attribute'; type: 'Attribute';
name: string; name: string;
value: true | Array<Text | ExpressionTag>; value: true | ExpressionTag | Array<Text | ExpressionTag>;
metadata: { metadata: {
dynamic: boolean; dynamic: boolean;
/** May be set if this is an event attribute */ /** May be set if this is an event attribute */

@ -27,27 +27,54 @@ export function object(expression) {
*/ */
export function is_text_attribute(attribute) { export function is_text_attribute(attribute) {
return ( return (
attribute.value !== true && attribute.value.length === 1 && attribute.value[0].type === 'Text' Array.isArray(attribute.value) &&
attribute.value.length === 1 &&
attribute.value[0].type === 'Text'
); );
} }
/** /**
* Returns true if the attribute contains a single expression node. * Returns true if the attribute contains a single expression node.
* In Svelte 5, this also includes a single expression node wrapped in an array.
* TODO change that in a future version
* @param {Attribute} attribute * @param {Attribute} attribute
* @returns {attribute is Attribute & { value: [ExpressionTag] }} * @returns {attribute is Attribute & { value: [ExpressionTag] | ExpressionTag }}
*/ */
export function is_expression_attribute(attribute) { export function is_expression_attribute(attribute) {
return ( return (
attribute.value !== true && (attribute.value !== true && !Array.isArray(attribute.value)) ||
attribute.value.length === 1 && (Array.isArray(attribute.value) &&
attribute.value[0].type === 'ExpressionTag' attribute.value.length === 1 &&
attribute.value[0].type === 'ExpressionTag')
); );
} }
/**
* Returns the single attribute expression node.
* In Svelte 5, this also includes a single expression node wrapped in an array.
* TODO change that in a future version
* @param { Attribute & { value: [ExpressionTag] | ExpressionTag }} attribute
* @returns {ESTree.Expression}
*/
export function get_attribute_expression(attribute) {
return Array.isArray(attribute.value)
? /** @type {ExpressionTag} */ (attribute.value[0]).expression
: attribute.value.expression;
}
/**
* Returns the expression chunks of an attribute value
* @param {Attribute['value']} value
* @returns {Array<Text | ExpressionTag>}
*/
export function get_attribute_chunks(value) {
return Array.isArray(value) ? value : typeof value === 'boolean' ? [] : [value];
}
/** /**
* Returns true if the attribute starts with `on` and contains a single expression node. * Returns true if the attribute starts with `on` and contains a single expression node.
* @param {Attribute} attribute * @param {Attribute} attribute
* @returns {attribute is Attribute & { value: [ExpressionTag] }} * @returns {attribute is Attribute & { value: [ExpressionTag] | ExpressionTag }}
*/ */
export function is_event_attribute(attribute) { export function is_event_attribute(attribute) {
return is_expression_attribute(attribute) && attribute.name.startsWith('on'); return is_expression_attribute(attribute) && attribute.name.startsWith('on');

@ -108,6 +108,7 @@ export const codes = [
"attribute_global_event_reference", "attribute_global_event_reference",
"attribute_illegal_colon", "attribute_illegal_colon",
"attribute_invalid_property_name", "attribute_invalid_property_name",
"attribute_quoted",
"bind_invalid_each_rest", "bind_invalid_each_rest",
"block_empty", "block_empty",
"component_name_lowercase", "component_name_lowercase",
@ -686,6 +687,14 @@ export function attribute_invalid_property_name(node, wrong, right) {
w(node, "attribute_invalid_property_name", `'${wrong}' is not a valid HTML attribute. Did you mean '${right}'?`); w(node, "attribute_invalid_property_name", `'${wrong}' is not a valid HTML attribute. Did you mean '${right}'?`);
} }
/**
* Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes
* @param {null | NodeLike} node
*/
export function attribute_quoted(node) {
w(node, "attribute_quoted", "Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes");
}
/** /**
* The rest operator (...) will create a new object and binding '%name%' with the original object will not work * The rest operator (...) will create a new object and binding '%name%' with the original object will not work
* @param {null | NodeLike} node * @param {null | NodeLike} node

@ -54,35 +54,33 @@
"start": 50, "start": 50,
"end": 62, "end": 62,
"name": "runes", "name": "runes",
"value": [ "value": {
{ "type": "ExpressionTag",
"type": "ExpressionTag", "start": 56,
"start": 56, "end": 62,
"end": 62, "expression": {
"expression": { "type": "Literal",
"type": "Literal", "start": 57,
"start": 57, "end": 61,
"end": 61, "loc": {
"loc": { "start": {
"start": { "line": 1,
"line": 1, "column": 57
"column": 57
},
"end": {
"line": 1,
"column": 61
}
}, },
"value": true, "end": {
"raw": "true" "line": 1,
"column": 61
}
}, },
"parent": null, "value": true,
"metadata": { "raw": "true"
"contains_call_expression": false, },
"dynamic": false "parent": null,
} "metadata": {
"contains_call_expression": false,
"dynamic": false
} }
], },
"parent": null, "parent": null,
"metadata": { "metadata": {
"dynamic": false, "dynamic": false,

@ -0,0 +1,21 @@
<svelte:options runes />
<!-- don't warn on these -->
<!-- prettier-ignore -->
<p class="{foo}"></p>
<!-- prettier-ignore -->
<svelte:element this={foo} class="{foo}"></svelte:element>
<!-- warn on these -->
<!-- prettier-ignore -->
<Component class="{foo}" />
<!-- prettier-ignore -->
<svelte:component this={foo} class="{foo}" />
<!-- prettier-ignore -->
{#if foo}
<svelte:self class="{foo}" />
{/if}
<!-- prettier-ignore -->
<custom-element class="{foo}"></custom-element>

@ -0,0 +1,50 @@
[
{
"code": "attribute_quoted",
"message": "Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes",
"start": {
"column": 11,
"line": 13
},
"end": {
"column": 24,
"line": 13
}
},
{
"code": "attribute_quoted",
"message": "Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes",
"start": {
"column": 29,
"line": 15
},
"end": {
"column": 42,
"line": 15
}
},
{
"code": "attribute_quoted",
"message": "Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes",
"start": {
"column": 14,
"line": 18
},
"end": {
"column": 27,
"line": 18
}
},
{
"code": "attribute_quoted",
"message": "Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes",
"start": {
"column": 16,
"line": 21
},
"end": {
"column": 29,
"line": 21
}
}
]

@ -1153,6 +1153,16 @@ declare module 'svelte/compiler' {
outro: boolean; outro: boolean;
} }
/** A `style:` directive */
interface LegacyStyleDirective extends BaseNode_1 {
type: 'StyleDirective';
/** The 'x' in `style:x` */
name: string;
/** The 'y' in `style:x={y}` */
value: true | Array<ExpressionTag | Text>;
modifiers: Array<'important'>;
}
interface LegacyWindow extends BaseElement_1 { interface LegacyWindow extends BaseElement_1 {
type: 'Window'; type: 'Window';
} }
@ -1171,7 +1181,7 @@ declare module 'svelte/compiler' {
| LegacyClass | LegacyClass
| LegacyLet | LegacyLet
| LegacyEventHandler | LegacyEventHandler
| StyleDirective | LegacyStyleDirective
| LegacyTransition | LegacyTransition
| LegacyAction; | LegacyAction;
@ -1634,7 +1644,7 @@ declare module 'svelte/compiler' {
/** The 'x' in `style:x` */ /** The 'x' in `style:x` */
name: string; name: string;
/** The 'y' in `style:x={y}` */ /** The 'y' in `style:x={y}` */
value: true | Array<ExpressionTag | Text>; value: true | ExpressionTag | Array<ExpressionTag | Text>;
modifiers: Array<'important'>; modifiers: Array<'important'>;
metadata: { metadata: {
dynamic: boolean; dynamic: boolean;
@ -1855,7 +1865,7 @@ declare module 'svelte/compiler' {
interface Attribute extends BaseNode { interface Attribute extends BaseNode {
type: 'Attribute'; type: 'Attribute';
name: string; name: string;
value: true | Array<Text | ExpressionTag>; value: true | ExpressionTag | Array<Text | ExpressionTag>;
metadata: { metadata: {
dynamic: boolean; dynamic: boolean;
/** May be set if this is an event attribute */ /** May be set if this is an event attribute */

Loading…
Cancel
Save