feat: add `<svelte:html>` element

closes #8663
svelte-html
Simon Holthausen 1 year ago
parent b145035a00
commit 81bd13173b

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `<svelte:html>` element

@ -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.

@ -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
```

@ -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?:

@ -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

@ -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

@ -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']
]);

@ -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';
@ -169,6 +170,7 @@ const visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
SvelteWindow,
TaggedTemplateExpression,

@ -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();
}

@ -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';
@ -123,6 +124,7 @@ const visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
SvelteWindow,
TitleElement,

@ -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);
}
}
}
}
}

@ -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 {

@ -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

@ -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';
@ -74,6 +75,7 @@ const template_visitors = {
SvelteElement,
SvelteFragment,
SvelteHead,
SvelteHTML,
SvelteSelf,
TitleElement
};

@ -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)))
);
}

@ -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' ||

@ -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';
@ -491,6 +496,7 @@ export type ElementLike =
| AST.TitleElement
| AST.SlotElement
| AST.RegularElement
| AST.SvelteHTML
| AST.SvelteBody
| AST.SvelteComponent
| AST.SvelteDocument

@ -0,0 +1,13 @@
/** @import { Payload } from '#server' */
import { escape } from '..';
/**
* @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));
}
}

@ -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,
@ -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 = [];
@ -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(' ')
};
}
@ -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';

@ -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;
@ -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;
}

@ -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

@ -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]
}
});

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
async test({ assert }) {
assert.deepEqual(document.documentElement.lang, 'de');
}
});

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
htmlAttributes: 'foo="bar"'
});

@ -0,0 +1,8 @@
<script>
import Nested from './Nested.svelte';
let ignored;
</script>
<svelte:html foo="foo" onevent={ignored}></svelte:html>
<Nested/>

@ -16,6 +16,7 @@ interface SSRTest extends BaseTest {
compileOptions?: Partial<CompileOptions>;
props?: Record<string, any>;
withoutNormalizeHtml?: boolean;
htmlAttributes?: string;
errors?: string[];
}
@ -34,7 +35,7 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default;
const expected_html = try_read_file(`${test_dir}/_expected.html`);
const rendered = render(Component, { props: config.props || {} });
const { body, head } = rendered;
const { body, head, htmlAttributes } = rendered;
fs.writeFileSync(`${test_dir}/_output/rendered.html`, body);
@ -75,6 +76,10 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
}
}
if (config.htmlAttributes) {
assert.deepEqual(htmlAttributes, config.htmlAttributes);
}
if (errors.length > 0) {
assert.deepEqual(config.errors, errors);
}

@ -1136,6 +1136,11 @@ declare module 'svelte/compiler' {
type: 'RegularElement';
}
export interface SvelteHTML extends BaseElement {
type: 'SvelteHTML';
name: 'svelte:html';
}
export interface SvelteBody extends BaseElement {
type: 'SvelteBody';
name: 'svelte:body';
@ -1272,6 +1277,7 @@ declare module 'svelte/compiler' {
| AST.TitleElement
| AST.SlotElement
| AST.RegularElement
| AST.SvelteHTML
| AST.SvelteBody
| AST.SvelteComponent
| AST.SvelteDocument
@ -1744,6 +1750,8 @@ declare module 'svelte/server' {
html: string;
/** HTML that goes somewhere into the `<body>` */
body: string;
/** Attributes that go onto the `<html>` */
htmlAttributes: string;
}
export {};

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" %htmlAttributes%>
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.png" />

@ -23,9 +23,10 @@ polka()
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
const transformed_template = await vite.transformIndexHtml(req.url, template);
const { default: App } = await vite.ssrLoadModule('/src/main.svelte');
const { head, body } = render(App);
const { head, body, htmlAttributes } = render(App);
const html = transformed_template
.replace('%htmlAttributes%', htmlAttributes)
.replace(`<!--ssr-head-->`, head)
.replace(`<!--ssr-body-->`, body)
// check that Safari doesn't break hydration

@ -4,10 +4,11 @@ import polka from 'polka';
import { render } from 'svelte/server';
import App from './src/main.svelte';
const { head, body } = render(App);
const { head, body, htmlAttributes } = render(App);
const rendered = fs
.readFileSync(path.resolve('./dist/client/index.html'), 'utf-8')
.replace('%htmlAttributes%', htmlAttributes)
.replace(`<!--ssr-body-->`, body)
.replace(`<!--ssr-head-->`, head);

Loading…
Cancel
Save