Skip to content

fix: allow attributes on the title element #15983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selfish-wasps-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

Allow attributes on the title element.
6 changes: 0 additions & 6 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -1082,12 +1082,6 @@ Expected a valid element or component name. Components must have a valid variabl
A `<textarea>` can have either a value attribute or (equivalently) child content, but not both
```

### title_illegal_attribute

```
`<title>` cannot have attributes nor directives
```

### title_invalid_content

```
Expand Down
4 changes: 0 additions & 4 deletions packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,6 @@ See https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-ele

> A `<textarea>` can have either a value attribute or (equivalently) child content, but not both

## title_illegal_attribute

> `<title>` cannot have attributes nor directives

## title_invalid_content

> `<title>` can only contain text and {tags}
Expand Down
11 changes: 1 addition & 10 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1573,15 +1573,6 @@ export function textarea_invalid_content(node) {
e(node, 'textarea_invalid_content', `A \`<textarea>\` can have either a value attribute or (equivalently) child content, but not both\nhttps://svelte.dev/e/textarea_invalid_content`);
}

/**
* `<title>` cannot have attributes nor directives
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function title_illegal_attribute(node) {
e(node, 'title_illegal_attribute', `\`<title>\` cannot have attributes nor directives\nhttps://svelte.dev/e/title_illegal_attribute`);
}

/**
* `<title>` can only contain text and {tags}
* @param {null | number | NodeLike} node
Expand Down Expand Up @@ -1647,4 +1638,4 @@ export function unterminated_string_constant(node) {
*/
export function void_element_invalid_content(node) {
e(node, 'void_element_invalid_content', `Void elements cannot have children or closing tags\nhttps://svelte.dev/e/void_element_invalid_content`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ import * as e from '../../../errors.js';
* @param {Context} context
*/
export function TitleElement(node, context) {
for (const attribute of node.attributes) {
e.title_illegal_attribute(attribute);
}

for (const child of node.fragment.nodes) {
if (child.type !== 'Text' && child.type !== 'ExpressionTag') {
e.title_invalid_content(child);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { build_template_chunk } from './shared/utils.js';
import {build_template_chunk, get_expression_id} from './shared/utils.js';
import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js';
import {build_attribute_value} from "./shared/element.js";
import { visit_event_attribute } from './shared/events.js';
import {normalize_attribute} from "../../../../../utils.js";
import {is_ignored} from "../../../../state.js";

/**
* @param {AST.TitleElement} node
Expand All @@ -15,10 +20,46 @@ export function TitleElement(node, context) {
);

const statement = b.stmt(b.assignment('=', b.id('$.document.title'), value));

if (has_state) {
context.state.update.push(statement);
} else {
context.state.init.push(statement);
}

// TODO: is this the right approach?

/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
const attributes = [];
for (const attribute of node.attributes) {
switch (attribute.type) {
case 'Attribute':
attributes.push(attribute);
break;
}
}

const node_id = {"type": "Identifier", "name": "title"}

for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (is_event_attribute(attribute)) {
visit_event_attribute(attribute, context);
continue;
}

const name = normalize_attribute(attribute.name);
const { value, has_state } = build_attribute_value(
attribute.value,
context,
(value, metadata) => (metadata.has_call ? get_expression_id(context.state, value) : value)
);

const update = b.call(
'$.set_attribute',
false,
b.literal(name),
value,
is_ignored(node, 'hydration_attribute_changed') && b.true
);
(has_state ? context.state.update : context.state.init).push(b.stmt(update));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
import { process_children, build_template } from './shared/utils.js';
import { build_element_attributes } from "./shared/element.js";

/**
* @param {AST.TitleElement} node
* @param {ComponentContext} context
*/
export function TitleElement(node, context) {
// title is guaranteed to contain only text/expression tag children
const template = [b.literal('<title>')];
const template = [b.literal('<title')];
build_element_attributes(node, { ...context, state: { ...context.state, template } });
template.push(b.literal('>'));
process_children(node.fragment.nodes, { ...context, state: { ...context.state, template } });
template.push(b.literal('</title>'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import { escape_html } from '../../../../../../escaping.js';
const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style'];

/**
* Writes the output to the template output. Some elements may have attributes on them that require the
* Writes the output to the template output. Some elements may have attributes on them that require
* their output to be the child content instead. In this case, an object is returned.
* @param {AST.RegularElement | AST.SvelteElement} node
* @param {AST.RegularElement | AST.SvelteElement | AST.TitleElement} node
* @param {import('zimmerframe').Context<AST.SvelteNode, ComponentServerTransformState>} context
*/
export function build_element_attributes(node, context) {
Expand Down Expand Up @@ -203,7 +203,7 @@ export function build_element_attributes(node, context) {
if (has_spread) {
build_element_spread_attributes(node, attributes, style_directives, class_directives, context);
} else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;
const css_hash = node.type !== 'TitleElement' && node.metadata.scoped ? context.state.analysis.css.hash : null;

for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
const name = get_attribute_name(node, attribute);
Expand Down Expand Up @@ -273,12 +273,12 @@ export function build_element_attributes(node, context) {
}

/**
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {AST.RegularElement | AST.SvelteElement | AST.TitleElement} element
* @param {AST.Attribute} attribute
*/
function get_attribute_name(element, attribute) {
let name = attribute.name;
if (!element.metadata.svg && !element.metadata.mathml) {
if (element.type !== 'TitleElement' && !element.metadata.svg && !element.metadata.mathml) {
name = name.toLowerCase();
// don't lookup boolean aliases here, the server runtime function does only
// check for the lowercase variants of boolean attributes
Expand All @@ -288,7 +288,7 @@ function get_attribute_name(element, attribute) {

/**
*
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {AST.RegularElement | AST.SvelteElement | AST.TitleElement} element
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
* @param {AST.StyleDirective[]} style_directives
* @param {AST.ClassDirective[]} class_directives
Expand Down Expand Up @@ -330,10 +330,12 @@ function build_element_spread_attributes(
styles = b.object(properties);
}

if (element.metadata.svg || element.metadata.mathml) {
flags |= ELEMENT_IS_NAMESPACED | ELEMENT_PRESERVE_ATTRIBUTE_CASE;
} else if (is_custom_element_node(element)) {
flags |= ELEMENT_PRESERVE_ATTRIBUTE_CASE;
if (element.type !== 'TitleElement') {
if (element.metadata.svg || element.metadata.mathml) {
flags |= ELEMENT_IS_NAMESPACED | ELEMENT_PRESERVE_ATTRIBUTE_CASE;
} else if (is_custom_element_node(element)) {
flags |= ELEMENT_PRESERVE_ATTRIBUTE_CASE;
}
}

const object = b.object(
Expand All @@ -353,7 +355,7 @@ function build_element_spread_attributes(
);

const css_hash =
element.metadata.scoped && context.state.analysis.css.hash
element.type !== 'TitleElement' && element.metadata.scoped && context.state.analysis.css.hash
? b.literal(context.state.analysis.css.hash)
: b.null;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { test } from '../../test';

export default test({
test({ assert, component, window }) {
assert.equal(window.document.title, 'Foo');

const elems = window.document.getElementsByTagName('title');
assert.equal(elems.length, 1);
const attrValue = elems[0].getAttribute('aria-live');
assert.equal(attrValue, 'assertive');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<svelte:head>
<title aria-live="assertive">Foo</title>
</svelte:head>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<title aria-live="assertive">Foo</title>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<svelte:head>
<title aria-live="assertive">Foo</title>
</svelte:head>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<title value="bar" form="qux" list="quu">Foo</title>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script>
let props = {
value: 'bar',
form: 'qux',
list: 'quu',
};
</script>

<svelte:head>
<title {...props}>Foo</title>
</svelte:head>

This file was deleted.