Skip to content
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

feat: add <svelte:html> element #14397

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
5 changes: 5 additions & 0 deletions .changeset/lemon-paws-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `<svelte:html>` element
11 changes: 11 additions & 0 deletions documentation/docs/05-special-elements/04-svelte-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: <svelte:html>
---

```svelte
<svelte:html attribute={value} onevent={handler} />
```

Similarly to `<svelte:body>`, this element allows you to add properties and listeners to events on `document.documentElement`. This is useful for attributes such as `lang` which influence how the browser interprets the content.

As with `<svelte:window>`, `<svelte:document>` and `<svelte:body>`, this element may only appear the top level of your component and must never be inside a block or element.
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,12 @@ Invalid component definition — must be an `{expression}`
`<svelte:head>` cannot have attributes nor directives
```

### svelte_html_illegal_attribute

```
`<svelte:html>` can only have regular attributes
```

### svelte_meta_duplicate

```
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,7 @@ export interface SvelteHTMLElements {
'svelte:window': SvelteWindowAttributes;
'svelte:document': SvelteDocumentAttributes;
'svelte:body': HTMLAttributes<HTMLElement>;
'svelte:html': HTMLAttributes<HTMLElement>;
'svelte:fragment': { slot?: string };
'svelte:options': {
customElement?:
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro

> `<svelte:head>` cannot have attributes nor directives

## svelte_html_illegal_attribute

> `<svelte:html>` can only have regular attributes

## svelte_meta_duplicate

> A component can only have one `<%name%>` element
Expand Down
9 changes: 9 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,15 @@ export function svelte_head_illegal_attribute(node) {
e(node, "svelte_head_illegal_attribute", "`<svelte:head>` cannot have attributes nor directives");
}

/**
* `<svelte:html>` can only have regular attributes
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function svelte_html_illegal_attribute(node) {
e(node, "svelte_html_illegal_attribute", "`<svelte:html>` can only have regular attributes");
}

/**
* A component can only have one `<%name%>` element
* @param {null | number | NodeLike} node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const root_only_meta_tags = new Map([
['svelte:head', 'SvelteHead'],
['svelte:options', 'SvelteOptions'],
['svelte:window', 'SvelteWindow'],
['svelte:html', 'SvelteHTML'],
['svelte:document', 'SvelteDocument'],
['svelte:body', 'SvelteBody']
]);
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteHTML } from './visitors/SvelteHTML.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js';
Expand Down Expand Up @@ -169,6 +170,7 @@ const visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
SvelteWindow,
TaggedTemplateExpression,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';

/**
* @param {AST.SvelteHTML} node
* @param {Context} context
*/
export function SvelteHTML(node, context) {
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') {
e.svelte_html_illegal_attribute(attribute);
}
}

if (node.fragment.nodes.length > 0) {
e.svelte_meta_invalid_content(node, node.name);
}

context.next();
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteHTML } from './visitors/SvelteHTML.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { TitleElement } from './visitors/TitleElement.js';
Expand Down Expand Up @@ -123,6 +124,7 @@ const visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
SvelteWindow,
TitleElement,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** @import { ExpressionStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { is_dom_property, normalize_attribute } from '../../../../../utils.js';
import { is_ignored } from '../../../../state.js';
import { is_event_attribute } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js';
import { build_attribute_value } from './shared/element.js';
import { visit_event_attribute } from './shared/events.js';

/**
* @param {AST.SvelteHTML} element
* @param {ComponentContext} context
*/
export function SvelteHTML(element, context) {
const node_id = b.id('$.document.documentElement');

for (const attribute of element.attributes) {
if (attribute.type === 'Attribute') {
if (is_event_attribute(attribute)) {
visit_event_attribute(attribute, context);
} else {
const name = normalize_attribute(attribute.name);
const { value, has_state } = build_attribute_value(attribute.value, context);

/** @type {ExpressionStatement} */
let update;

if (name === 'class') {
update = b.stmt(b.call('$.set_class', node_id, value));
} else if (is_dom_property(name)) {
update = b.stmt(b.assignment('=', b.member(node_id, name), value));
} else {
update = b.stmt(
b.call(
'$.set_attribute',
node_id,
b.literal(name),
value,
is_ignored(element, 'hydration_attribute_changed') && b.true
)
);
}

if (has_state) {
context.state.update.push(update);
} else {
context.state.init.push(update);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export function visit_event_attribute(node, context) {

const type = /** @type {SvelteNode} */ (context.path.at(-1)).type;

if (type === 'SvelteDocument' || type === 'SvelteWindow' || type === 'SvelteBody') {
if (
type === 'SvelteDocument' ||
type === 'SvelteWindow' ||
type === 'SvelteBody' ||
type === 'SvelteHTML'
) {
// These nodes are above the component tree, and its events should run parent first
context.state.init.push(statement);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as b from '../../../../../utils/builders.js';

/**
*
* Puts all event listeners onto the given element
* @param {AST.SvelteBody | AST.SvelteDocument | AST.SvelteWindow} node
* @param {string} id
* @param {ComponentContext} context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { SvelteComponent } from './visitors/SvelteComponent.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteHTML } from './visitors/SvelteHTML.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { TitleElement } from './visitors/TitleElement.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
Expand Down Expand Up @@ -74,6 +75,7 @@ const template_visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
TitleElement
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** @import { Property } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import { normalize_attribute } from '../../../../../utils.js';
import { is_event_attribute } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js';
import { build_attribute_value } from './shared/utils.js';

/**
* @param {AST.SvelteHTML} element
* @param {ComponentContext} context
*/
export function SvelteHTML(element, context) {
/** @type {Property[]} */
const attributes = [];

for (const attribute of element.attributes) {
if (attribute.type === 'Attribute' && !is_event_attribute(attribute)) {
const name = normalize_attribute(attribute.name);
const value = build_attribute_value(attribute.value, context);
attributes.push(b.init(name, value));
}
}

context.state.template.push(
b.stmt(b.call('$.svelte_html', b.id('$$payload'), b.object(attributes)))
);
}
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/3-transform/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export function clean_nodes(
node.type === 'ConstTag' ||
node.type === 'DebugTag' ||
node.type === 'SvelteBody' ||
node.type === 'SvelteHTML' ||
node.type === 'SvelteWindow' ||
node.type === 'SvelteDocument' ||
node.type === 'SvelteHead' ||
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte/src/compiler/types/template.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ export namespace AST {
};
}

export interface SvelteHTML extends BaseElement {
type: 'SvelteHTML';
name: 'svelte:html';
}

export interface SvelteBody extends BaseElement {
type: 'SvelteBody';
name: 'svelte:body';
Expand Down Expand Up @@ -491,6 +496,7 @@ export type ElementLike =
| AST.TitleElement
| AST.SlotElement
| AST.RegularElement
| AST.SvelteHTML
| AST.SvelteBody
| AST.SvelteComponent
| AST.SvelteDocument
Expand Down
13 changes: 13 additions & 0 deletions packages/svelte/src/internal/server/blocks/svelte-html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @import { Payload } from '#server' */

import { escape } from '..';
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

/**
* @param {Payload} payload
* @param {Record<string, string>} attributes
*/
export function svelte_html(payload, attributes) {
for (const name in attributes) {
payload.htmlAttributes.set(name, escape(attributes[name], true));
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
}
}
17 changes: 14 additions & 3 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ const RAW_TEXT_ELEMENTS = ['textarea', 'script', 'style', 'title'];
* @param {Payload} to_copy
* @returns {Payload}
*/
export function copy_payload({ out, css, head }) {
export function copy_payload({ out, htmlAttributes, css, head }) {
return {
out,
htmlAttributes: new Map(htmlAttributes),
css: new Set(css),
head: {
title: head.title,
Expand Down Expand Up @@ -96,7 +97,12 @@ export let on_destroy = [];
*/
export function render(component, options = {}) {
/** @type {Payload} */
const payload = { out: '', css: new Set(), head: { title: '', out: '' } };
const payload = {
out: '',
htmlAttributes: new Map(),
css: new Set(),
head: { title: '', out: '' }
};

const prev_on_destroy = on_destroy;
on_destroy = [];
Expand Down Expand Up @@ -138,7 +144,10 @@ export function render(component, options = {}) {
return {
head,
html: payload.out,
body: payload.out
body: payload.out,
htmlAttributes: [...payload.htmlAttributes]
.map(([name, value]) => `${name}="${value}"`)
.join(' ')
Comment on lines +148 to +150
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Someone might have an existing classname in their HTML template, in which case giving them a string would make it awkward to combine stuff. Should we return an object instead of a string, so that they have more flexibility?

Copy link
Member Author

@dummdidumm dummdidumm Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I briefly thought about that but it seemed unnecessary - in which case would you have existing attributes on a html tag but in such a way that you know which ones to then merge them in some way? Even if, the regex for adjusting the html attributes string would be straightforward. So I opted for making the simple case more ergonomic.

It is an interesting question for SvelteKit specifically though, which currently sets lang="en" in app.html by default. What would we do here? (regardless of whether we return a string or an object). The easiest would be to have lang="en" after the string and rely on browser being forgiving about it (they ignore duplicate attributes) / the user removing it in case they set it themselves in <svelte:html>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it only really matters for new projects, I think, since we can't retroactively add %htmlAttributes% anyway. I think we just replace lang="en" with %htmlAttributes% in the template project's app.html, and add this to the root layout:

<svelte:html lang="en" />

};
}

Expand Down Expand Up @@ -527,6 +536,8 @@ export { attr };

export { html } from './blocks/html.js';

export { svelte_html } from './blocks/svelte-html.js';

export { push, pop } from './context.js';

export { push_element, pop_element } from './dev.js';
Expand Down
3 changes: 3 additions & 0 deletions packages/svelte/src/internal/server/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Component {

export interface Payload {
out: string;
htmlAttributes: Map<string, string>;
css: Set<{ hash: string; code: string }>;
head: {
title: string;
Expand All @@ -27,4 +28,6 @@ export interface RenderOutput {
html: string;
/** HTML that goes somewhere into the `<body>` */
body: string;
/** Attributes that go onto the `<html>` */
htmlAttributes: string;
}
1 change: 1 addition & 0 deletions packages/svelte/svelte-html.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ declare global {
'svelte:window': HTMLProps<'svelte:window', HTMLAttributes>;
'svelte:body': HTMLProps<'svelte:body', HTMLAttributes>;
'svelte:document': HTMLProps<'svelte:document', HTMLAttributes>;
'svelte:html': HTMLProps<'svelte:html', HTMLAttributes>;
'svelte:fragment': { slot?: string };
'svelte:head': { [name: string]: any };
// don't type svelte:options, it would override the types in svelte/elements and it isn't extendable anyway
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default test({
error: {
code: 'svelte_meta_invalid_tag',
message:
'Valid `<svelte:...>` tag names are svelte:head, svelte:options, svelte:window, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self or svelte:fragment',
'Valid `<svelte:...>` tag names are svelte:head, svelte:options, svelte:window, svelte:html, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self or svelte:fragment',
position: [10, 32]
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { test } from '../../test';

export default test({
async test({ assert }) {
assert.deepEqual(document.documentElement.lang, 'de');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<svelte:html lang="de"></svelte:html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<svelte:html foo="bar"></svelte:html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test } from '../../test';

export default test({
htmlAttributes: 'foo="bar"'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
import Nested from './Nested.svelte';
let ignored;
</script>

<svelte:html foo="foo" onevent={ignored}></svelte:html>

<Nested/>
Loading
Loading