diff --git a/.changeset/lemon-paws-work.md b/.changeset/lemon-paws-work.md new file mode 100644 index 000000000000..0cba4622673b --- /dev/null +++ b/.changeset/lemon-paws-work.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `` element diff --git a/documentation/docs/05-special-elements/04-svelte-html.md b/documentation/docs/05-special-elements/04-svelte-html.md new file mode 100644 index 000000000000..86e76f2a77b2 --- /dev/null +++ b/documentation/docs/05-special-elements/04-svelte-html.md @@ -0,0 +1,11 @@ +--- +title: +--- + +```svelte + +``` + +Similarly to ``, 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 ``, `` and ``, this element may only appear the top level of your component and must never be inside a block or element. diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index d726d25fa188..f49db29eb7ad 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -852,6 +852,12 @@ Invalid component definition — must be an `{expression}` `` cannot have attributes nor directives ``` +### svelte_html_illegal_attribute + +``` +`` can only have regular attributes +``` + ### svelte_meta_duplicate ``` diff --git a/documentation/docs/98-reference/.generated/shared-warnings.md b/documentation/docs/98-reference/.generated/shared-warnings.md index f449a4031ebf..06e813258769 100644 --- a/documentation/docs/98-reference/.generated/shared-warnings.md +++ b/documentation/docs/98-reference/.generated/shared-warnings.md @@ -26,3 +26,11 @@ The following properties cannot be cloned with `$state.snapshot` — the return const object = $state({ property: 'this is cloneable', window }) const snapshot = $state.snapshot(object); ``` + +### svelte_html_duplicate_attribute + +``` +Duplicate attribute '%name%' across multiple `` blocks, the latest value will be used. +``` + +This warning appears when you have multiple `` blocks across several files, and they set the same attribute. In that case, the latest value wins. On the server and on the client for static attributes, that's the last occurence of the attribute. On the client for dynamic attributes that's the value which was updated last across all `` blocks. diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 8800b65172dc..2c055a80c958 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -2024,6 +2024,7 @@ export interface SvelteHTMLElements { 'svelte:window': SvelteWindowAttributes; 'svelte:document': SvelteDocumentAttributes; 'svelte:body': HTMLAttributes; + 'svelte:html': HTMLAttributes; 'svelte:fragment': { slot?: string }; 'svelte:options': { customElement?: diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 02961b61fccc..a28a33fc4eec 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -322,6 +322,10 @@ HTML restricts where certain elements can appear. In case of a violation the bro > `` cannot have attributes nor directives +## svelte_html_illegal_attribute + +> `` can only have regular attributes + ## svelte_meta_duplicate > A component can only have one `<%name%>` element diff --git a/packages/svelte/messages/shared-warnings/warnings.md b/packages/svelte/messages/shared-warnings/warnings.md index fb27867f9bf3..e801f8cfa959 100644 --- a/packages/svelte/messages/shared-warnings/warnings.md +++ b/packages/svelte/messages/shared-warnings/warnings.md @@ -18,3 +18,9 @@ Elements such as `` cannot have content, any children passed to these ele const object = $state({ property: 'this is cloneable', window }) const snapshot = $state.snapshot(object); ``` + +## svelte_html_duplicate_attribute + +> Duplicate attribute '%name%' across multiple `` blocks, the latest value will be used. + +This warning appears when you have multiple `` blocks across several files, and they set the same attribute. In that case, the latest value wins. On the server and on the client for static attributes, that's the last occurence of the attribute. On the client for dynamic attributes that's the value which was updated last across all `` blocks. diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index fd509eb3ab75..5c5cec277e2e 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1337,6 +1337,15 @@ export function svelte_head_illegal_attribute(node) { e(node, "svelte_head_illegal_attribute", `\`\` cannot have attributes nor directives\nhttps://svelte.dev/e/svelte_head_illegal_attribute`); } +/** + * `` 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", "`` can only have regular attributes"); +} + /** * A component can only have one `<%name%>` element * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 45350bb1aec5..5df5fcf170cc 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -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'] ]); diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 9e29813ee336..73ae0c22ac9b 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -59,6 +59,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 { SvelteBoundary } from './visitors/SvelteBoundary.js'; @@ -172,6 +173,7 @@ const visitors = { SvelteElement, SvelteFragment, SvelteHead, + SvelteHTML, SvelteSelf, SvelteWindow, SvelteBoundary, diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js new file mode 100644 index 000000000000..195528491e89 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteHTML.js @@ -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(); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 3edbc49f1699..b038c0695246 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -50,6 +50,7 @@ import { SvelteElement } from './visitors/SvelteElement.js'; import { SvelteFragment } from './visitors/SvelteFragment.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.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'; @@ -125,6 +126,7 @@ const visitors = { SvelteFragment, SvelteBoundary, SvelteHead, + SvelteHTML, SvelteSelf, SvelteWindow, TitleElement, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js new file mode 100644 index 000000000000..f6e4f50c1af1 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteHTML.js @@ -0,0 +1,40 @@ +/** @import { ExpressionStatement, Property } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types' */ +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/element.js'; +import { visit_event_attribute } from './shared/events.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') { + if (is_event_attribute(attribute)) { + visit_event_attribute(attribute, context); + } else { + const name = normalize_attribute(attribute.name); + const { value } = build_attribute_value(attribute.value, context); + + attributes.push(b.init(name, value)); + + if (context.state.options.dev) { + context.state.init.push( + b.stmt(b.call('$.validate_svelte_html_attribute', b.literal(name))) + ); + } + } + } + } + + if (attributes.length > 0) { + context.state.init.push(b.stmt(b.call('$.svelte_html', b.arrow([], b.object(attributes))))); + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js index 92f99c4a8b11..ae6318d623a6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js @@ -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 { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js index 558bc4fee7b4..eff5bf4869b4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/special_element.js @@ -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 diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index ffde76fab7af..54bdfcbc0f55 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -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'; @@ -75,6 +76,7 @@ const template_visitors = { SvelteElement, SvelteFragment, SvelteHead, + SvelteHTML, SvelteSelf, TitleElement, SvelteBoundary diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js new file mode 100644 index 000000000000..78750712d80a --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHTML.js @@ -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))) + ); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js index 14fd3aa2e849..aa9f3e0f14dc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/utils.js @@ -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' || diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index b8724f28dc94..28b85f2236d1 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -314,6 +314,11 @@ export namespace AST { }; } + export interface SvelteHTML extends BaseElement { + type: 'SvelteHTML'; + name: 'svelte:html'; + } + export interface SvelteBody extends BaseElement { type: 'SvelteBody'; name: 'svelte:body'; @@ -520,6 +525,7 @@ export type ElementLike = | AST.TitleElement | AST.SlotElement | AST.RegularElement + | AST.SvelteHTML | AST.SvelteBody | AST.SvelteComponent | AST.SvelteDocument diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-html.js b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js new file mode 100644 index 000000000000..a1bd8306b51c --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-html.js @@ -0,0 +1,62 @@ +import { render_effect, teardown } from '../../reactivity/effects.js'; +import { set_attribute } from '../elements/attributes.js'; +import { set_class } from '../elements/class.js'; +import { hydrating } from '../hydration.js'; + +/** + * @param {() => Record} get_attributes + * @returns {void} + */ +export function svelte_html(get_attributes) { + const node = document.documentElement; + const own = {}; + + /** @type {Record>} to check who set the last value of each attribute */ + // @ts-expect-error + const current_setters = (node.__attributes_setters ??= {}); + + /** @type {Record} */ + let attributes; + + render_effect(() => { + attributes = get_attributes(); + + for (const name in attributes) { + const current = (current_setters[name] ??= []); + const idx = current.findIndex(([owner]) => owner === own); + const old = idx === -1 ? null : current.splice(idx, 1)[0][1]; + + let value = attributes[name]; + current.push([own, value]); + + // Do nothing on initial render during hydration: If there are attribute duplicates, the last value + // wins, which could result in needless hydration repairs from earlier values. + if (hydrating) continue; + + if (name === 'class') { + // Avoid unrelated attribute changes from triggering class changes + if (old !== value) { + set_class(node, current_setters[name].map(([_, text]) => text).join(' ')); + } + } else { + set_attribute(node, name, value); + } + } + }); + + teardown(() => { + for (const name in attributes) { + const old = current_setters[name]; + current_setters[name] = old.filter(([owner]) => owner !== own); + const current = current_setters[name]; + + if (name === 'class') { + set_class(node, current.map(([_, text]) => text).join(' ')); + + // If this was the last one setting this attribute, revert to the previous value + } else if (old[old.length - 1][0] === own) { + set_attribute(node, name, current[current.length - 1]?.[1]); + } + } + }); +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index b706e52a5378..f38deeddb826 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -24,6 +24,7 @@ export { snippet, wrap_snippet } from './dom/blocks/snippet.js'; export { component } from './dom/blocks/svelte-component.js'; export { element } from './dom/blocks/svelte-element.js'; export { head } from './dom/blocks/svelte-head.js'; +export { svelte_html } from './dom/blocks/svelte-html.js'; export { append_styles } from './dom/css.js'; export { action } from './dom/elements/actions.js'; export { @@ -149,7 +150,12 @@ export { setContext, hasContext } from './runtime.js'; -export { validate_binding, validate_each_keys, validate_prop_bindings } from './validate.js'; +export { + validate_binding, + validate_each_keys, + validate_prop_bindings, + validate_svelte_html_attribute +} from './validate.js'; export { raf } from './timing.js'; export { proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 951feee33bdf..88b6e2abb8c8 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -2,9 +2,10 @@ import { dev_current_component_function } from './runtime.js'; import { get_descriptor, is_array } from '../shared/utils.js'; import * as e from './errors.js'; import { FILENAME } from '../../constants.js'; -import { render_effect } from './reactivity/effects.js'; +import { render_effect, teardown } from './reactivity/effects.js'; import * as w from './warnings.js'; import { capture_store_binding } from './reactivity/store.js'; +import { svelte_html_duplicate_attribute } from '../shared/warnings.js'; /** * @param {() => any} collection @@ -104,3 +105,27 @@ export function validate_binding(binding, get_object, get_property, line, column } }); } + +let svelte_html_attributes = new Map(); + +/** + * @param {string} name + */ +export function validate_svelte_html_attribute(name) { + const count = svelte_html_attributes.get(name) || 0; + + if (count > 0) { + svelte_html_duplicate_attribute(name); + } + + svelte_html_attributes.set(name, count + 1); + + teardown(() => { + const count = svelte_html_attributes.get(name) || 1; + if (count === 1) { + svelte_html_attributes.delete(name); + } else { + svelte_html_attributes.set(name, count - 1); + } + }); +} diff --git a/packages/svelte/src/internal/server/blocks/svelte-html.js b/packages/svelte/src/internal/server/blocks/svelte-html.js new file mode 100644 index 000000000000..adda28321fba --- /dev/null +++ b/packages/svelte/src/internal/server/blocks/svelte-html.js @@ -0,0 +1,25 @@ +/** @import { Payload } from '#server' */ + +import { escape_html } from '../../../escaping.js'; +import { svelte_html_duplicate_attribute } from '../../shared/warnings.js'; + +/** + * @param {Payload} payload + * @param {Record} attributes + */ +export function svelte_html(payload, attributes) { + for (const name in attributes) { + let value = attributes[name]; + + if (payload.htmlAttributes.has(name)) { + if (name === 'class') { + // Don't bother deduplicating class names, the browser handles it just fine + value = `${payload.htmlAttributes.get(name)} ${value}`; + } else { + svelte_html_duplicate_attribute(name); + } + } + + payload.htmlAttributes.set(name, escape_html(value, true)); + } +} diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index b944c602b884..c8075119900d 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -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(' ') }; } @@ -529,6 +538,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'; diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index e6c235147b5f..c17b6f2d80e7 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -13,6 +13,7 @@ export interface Component { export interface Payload { out: string; + htmlAttributes: Map; 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: string; + /** Attributes that go onto the `` */ + htmlAttributes: string; } diff --git a/packages/svelte/src/internal/shared/warnings.js b/packages/svelte/src/internal/shared/warnings.js index 83c58c9b14a2..c948b6d525b7 100644 --- a/packages/svelte/src/internal/shared/warnings.js +++ b/packages/svelte/src/internal/shared/warnings.js @@ -33,4 +33,17 @@ ${properties}` } else { console.warn(`https://svelte.dev/e/state_snapshot_uncloneable`); } +} + +/** + * Duplicate attribute '%name%' across multiple `` blocks, the latest value will be used. + * @param {string} name + */ +export function svelte_html_duplicate_attribute(name) { + if (DEV) { + console.warn(`%c[svelte] svelte_html_duplicate_attribute\n%cDuplicate attribute '${name}' across multiple \`\` blocks, the latest value will be used.`, bold, normal); + } else { + // TODO print a link to the documentation + console.warn("svelte_html_duplicate_attribute"); + } } \ No newline at end of file diff --git a/packages/svelte/svelte-html.d.ts b/packages/svelte/svelte-html.d.ts index 5042eaa4b849..d97ef5831593 100644 --- a/packages/svelte/svelte-html.d.ts +++ b/packages/svelte/svelte-html.d.ts @@ -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 }; 'svelte:boundary': { diff --git a/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js b/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js index de5ee3bb6909..63c464c2562c 100644 --- a/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/svelte-selfdestructive/_config.js @@ -4,7 +4,7 @@ export default test({ error: { code: 'svelte_meta_invalid_tag', message: - 'Valid `` tag names are svelte:head, svelte:options, svelte:window, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self, svelte:fragment or svelte:boundary', + 'Valid `` tag names are svelte:head, svelte:options, svelte:window, svelte:html, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self, svelte:fragment or svelte:boundary', position: [10, 32] } }); diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index e6dc0f385bf9..fb6bc714c193 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -283,12 +283,21 @@ async function run_test_variant( config.before_test?.(); // ssr into target const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default; - const { html, head } = render(SsrSvelteComponent, { + const { head, body, htmlAttributes } = render(SsrSvelteComponent, { props: config.server_props ?? config.props ?? {} }); - fs.writeFileSync(`${cwd}/_output/rendered.html`, html); - target.innerHTML = html; + if (htmlAttributes) { + for (const [key, value] of htmlAttributes.split('" ').map((attr) => attr.split('='))) { + window.document.documentElement.setAttribute( + key, + value.slice(1, value.endsWith('"') ? -1 : undefined) + ); + } + } + + fs.writeFileSync(`${cwd}/_output/rendered.html`, body); + target.innerHTML = body; if (head) { fs.writeFileSync(`${cwd}/_output/rendered_head.html`, head); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js new file mode 100644 index 000000000000..0c60240475f4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/_config.js @@ -0,0 +1,45 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + async test({ assert, warnings }) { + assert.include(warnings[0], "Duplicate attribute 'foo' across multiple `` blocks"); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'bar'); + assert.deepEqual(document.documentElement.getAttribute('class'), 'foo bar foo baz'); + + const [btn1, btn2] = document.querySelectorAll('button'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'foo'); + assert.deepEqual(document.documentElement.getAttribute('class'), 'foo bar'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'bar'); + assert.deepEqual(document.documentElement.getAttribute('class'), 'foo bar foo baz'); + + btn2.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'top0'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'top0'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'bar'); + + document.querySelectorAll('button')[2].click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'nested0'); + + btn1.click(); + flushSync(); + assert.deepEqual(document.documentElement.getAttribute('foo'), 'top0'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte new file mode 100644 index 000000000000..f3942db7ed8b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/child.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte new file mode 100644 index 000000000000..b5830a714001 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html-nested/main.svelte @@ -0,0 +1,15 @@ + + + + + + + +{#if show} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js new file mode 100644 index 000000000000..cea9bba2aa5a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert }) { + assert.deepEqual(document.documentElement.lang, 'de'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte new file mode 100644 index 000000000000..cd4ed850b41a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-html/main.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte b/packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte new file mode 100644 index 000000000000..8a2047d45915 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/svelte-html/Nested.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js b/packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js new file mode 100644 index 000000000000..280ecb349307 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/svelte-html/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + htmlAttributes: 'foo="bar"' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/_expected.html b/packages/svelte/tests/server-side-rendering/samples/svelte-html/_expected.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte b/packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte new file mode 100644 index 000000000000..e9211092ae54 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/svelte-html/main.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index f76c5b539f24..63708f1e9b5f 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -16,6 +16,7 @@ interface SSRTest extends BaseTest { compileOptions?: Partial; props?: Record; withoutNormalizeHtml?: boolean; + htmlAttributes?: string; errors?: string[]; } @@ -34,7 +35,7 @@ const { test, run } = suite(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(async (config, test_dir) => { } } + if (config.htmlAttributes) { + assert.deepEqual(htmlAttributes, config.htmlAttributes); + } + if (errors.length > 0) { assert.deepEqual(config.errors, errors); } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 435476d7033a..135c8d196e3b 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -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'; @@ -1278,6 +1283,7 @@ declare module 'svelte/compiler' { | AST.TitleElement | AST.SlotElement | AST.RegularElement + | AST.SvelteHTML | AST.SvelteBody | AST.SvelteComponent | AST.SvelteDocument @@ -2054,6 +2060,8 @@ declare module 'svelte/server' { html: string; /** HTML that goes somewhere into the `` */ body: string; + /** Attributes that go onto the `` */ + htmlAttributes: string; } export {}; diff --git a/playgrounds/sandbox/index.html b/playgrounds/sandbox/index.html index 512b5426a932..d80ee5ab753e 100644 --- a/playgrounds/sandbox/index.html +++ b/playgrounds/sandbox/index.html @@ -1,5 +1,5 @@ - + diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 65390b70cac5..aac382d47d9c 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -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(``, head) .replace(``, body) // check that Safari doesn't break hydration diff --git a/playgrounds/sandbox/ssr-prod.js b/playgrounds/sandbox/ssr-prod.js index c268aac7902a..7aa38f1c2906 100644 --- a/playgrounds/sandbox/ssr-prod.js +++ b/playgrounds/sandbox/ssr-prod.js @@ -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(``, body) .replace(``, head);