Skip to content
Merged
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/trusted-html-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: support TrustedHTML in `{@html}` expressions
35 changes: 21 additions & 14 deletions packages/svelte/src/internal/client/dom/blocks/html.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
/** @import { Effect, TemplateNode } from '#client' */
import { FILENAME, HYDRATION_ERROR } from '../../../../constants.js';
/** @import {} from 'trusted-types' */
import {
FILENAME,
HYDRATION_ERROR,
NAMESPACE_SVG,
NAMESPACE_MATHML
} from '../../../../constants.js';
import { remove_effect_dom, template_effect } from '../../reactivity/effects.js';
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from '../hydration.js';
import { create_fragment_from_html } from '../reconciler.js';

import { assign_nodes } from '../template.js';
import * as w from '../../warnings.js';
import { hash, sanitize_location } from '../../../../utils.js';
import { DEV } from 'esm-env';
import { dev_current_component_function } from '../../context.js';
import { get_first_child, get_next_sibling } from '../operations.js';
import { create_element, get_first_child, get_next_sibling } from '../operations.js';
import { active_effect } from '../../runtime.js';
import { COMMENT_NODE } from '#client/constants';

/**
* @param {Element} element
* @param {string | null} server_hash
* @param {string} value
* @param {string | TrustedHTML} value
*/
function check_hash(element, server_hash, value) {
if (!server_hash || server_hash === hash(String(value ?? ''))) return;
Expand All @@ -35,7 +41,7 @@ function check_hash(element, server_hash, value) {

/**
* @param {Element | Text | Comment} node
* @param {() => string} get_value
* @param {() => string | TrustedHTML} get_value
* @param {boolean} [svg]
* @param {boolean} [mathml]
* @param {boolean} [skip_warning]
Expand All @@ -44,6 +50,7 @@ function check_hash(element, server_hash, value) {
export function html(node, get_value, svg = false, mathml = false, skip_warning = false) {
var anchor = node;

/** @type {string | TrustedHTML} */
var value = '';

template_effect(() => {
Expand Down Expand Up @@ -92,18 +99,18 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning
return;
}

var html = value + '';
if (svg) html = `<svg>${html}</svg>`;
else if (mathml) html = `<math>${html}</math>`;

// Don't use create_fragment_with_script_from_html here because that would mean script tags are executed.
// @html is basically `.innerHTML = ...` and that doesn't execute scripts either due to security reasons.
/** @type {DocumentFragment | Element} */
var node = create_fragment_from_html(html);
// Use a <template>, <svg>, or <math> wrapper depending on context. If value is a TrustedHTML object,
// it will be assigned directly to innerHTML without coercion — this allows {@html policy.createHTML(...)} to work.
var ns = svg ? NAMESPACE_SVG : mathml ? NAMESPACE_MATHML : undefined;
var wrapper = /** @type {HTMLTemplateElement | SVGElement | MathMLElement} */ (
create_element(svg ? 'svg' : mathml ? 'math' : 'template', ns)
);
wrapper.innerHTML = /** @type {any} */ (value);

if (svg || mathml) {
node = /** @type {Element} */ (get_first_child(node));
}
/** @type {DocumentFragment | Element} */
var node = svg || mathml ? wrapper : /** @type {HTMLTemplateElement} */ (wrapper).content;

assign_nodes(
/** @type {TemplateNode} */ (get_first_child(node)),
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dom/blocks/snippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function createRawSnippet(fn) {
hydrate_next();
} else {
var html = snippet.render().trim();
var fragment = create_fragment_from_html(html, true);
var fragment = create_fragment_from_html(html);
element = /** @type {Element} */ (get_first_child(fragment));

if (DEV && (get_next_sibling(element) !== null || element.nodeType !== ELEMENT_NODE)) {
Expand Down
8 changes: 2 additions & 6 deletions packages/svelte/src/internal/client/dom/reconciler.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/** @import {} from 'trusted-types' */

import { create_element } from './operations.js';

const policy = /* @__PURE__ */ globalThis?.window?.trustedTypes?.createPolicy(
Expand All @@ -19,11 +17,9 @@ function create_trusted_html(html) {

/**
* @param {string} html
* @param {boolean} trusted
*/
export function create_fragment_from_html(html, trusted = false) {
export function create_fragment_from_html(html) {
var elem = create_element('template');
html = html.replaceAll('<!>', '<!---->'); // XHTML compliance
elem.innerHTML = trusted ? create_trusted_html(html) : html;
elem.innerHTML = create_trusted_html(html.replaceAll('<!>', '<!---->')); // XHTML compliance
return elem.content;
}
4 changes: 2 additions & 2 deletions packages/svelte/src/internal/client/dom/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function from_html(content, flags) {
}

if (node === undefined) {
node = create_fragment_from_html(has_start ? content : '<!>' + content, true);
node = create_fragment_from_html(has_start ? content : '<!>' + content);
if (!is_fragment) node = /** @type {TemplateNode} */ (get_first_child(node));
}

Expand Down Expand Up @@ -118,7 +118,7 @@ function from_namespace(content, flags, ns = 'svg') {
}

if (!node) {
var fragment = /** @type {DocumentFragment} */ (create_fragment_from_html(wrapped, true));
var fragment = /** @type {DocumentFragment} */ (create_fragment_from_html(wrapped));
var root = /** @type {Element} */ (get_first_child(fragment));

if (is_fragment) {
Expand Down
Loading