@rohal12/spindle 0.52.8 → 0.52.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3767,6 +3767,7 @@ var RawTextContext = createContext(false);
3767
3767
  * no live form properties, so they are set as written (see splitAttributes).
3768
3768
  */
3769
3769
  var SvgContext = createContext(false);
3770
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
3770
3771
  /** Elements whose content is literal text, not markdown. */
3771
3772
  var PREFORMATTED_ELEMENTS = new Set(["pre", "textarea"]);
3772
3773
  var WidgetChildrenContext = createContext(null);
@@ -3811,20 +3812,42 @@ function expandPlaceholderText(text, ph) {
3811
3812
  return splitPlaceholderText(text, ph).map((part) => typeof part === "number" ? ph.components[part] : part);
3812
3813
  }
3813
3814
  /**
3814
- * An element whose attributes contain placeholders (a variable in image alt
3815
- * text or a link title). Each such attribute is evaluated as text against
3816
- * the store and locals, so it follows its variables as the component would.
3815
+ * An element of markdown output whose attributes contain placeholders (a
3816
+ * variable in image alt text or a link title). Each such attribute is
3817
+ * evaluated as text against the store and locals, so it follows its
3818
+ * variables as the component would.
3817
3819
  */
3818
- function PlaceholderAttributes({ tag, props, attributes, children }) {
3820
+ function PlaceholderAttributes({ tag, attributes, placeholders, svg, children }) {
3819
3821
  const scope = useTextScope();
3820
- const resolved = { ...props };
3821
3822
  const errors = [];
3822
- for (const [name, nodes] of Object.entries(attributes)) {
3823
+ const { props, direct } = splitAttributes(attributes.map(([name, value, written]) => {
3824
+ const nodes = placeholders[name];
3825
+ if (!nodes) return [
3826
+ name,
3827
+ value,
3828
+ written
3829
+ ];
3823
3830
  const result = renderText(nodes, scope);
3824
- resolved[name] = result.text;
3825
3831
  for (const error of result.errors) errors.push([name, error]);
3826
- }
3827
- return withAttributeErrors(errors, h(tag, resolved, ...children));
3832
+ return [
3833
+ name,
3834
+ result.text,
3835
+ written
3836
+ ];
3837
+ }), svg);
3838
+ const ref = useDirectAttributes(direct);
3839
+ return withAttributeErrors(errors, h(tag, ref ? {
3840
+ ...props,
3841
+ ref
3842
+ } : props, ...children));
3843
+ }
3844
+ /** An element of markdown output with attributes to set directly. */
3845
+ function DirectAttributes({ tag, props, direct, children }) {
3846
+ const ref = useDirectAttributes(direct);
3847
+ return h(tag, {
3848
+ ...props,
3849
+ ref
3850
+ }, ...children);
3828
3851
  }
3829
3852
  /**
3830
3853
  * Show the errors met in an element's attributes in front of it, the way a
@@ -3846,16 +3869,29 @@ function placeholderIndex(node, ph) {
3846
3869
  return parseInt(value.slice(prefix.length), 10);
3847
3870
  }
3848
3871
  /**
3872
+ * Parse HTML into nodes without side effects. The content of a `<template>`
3873
+ * belongs to a document with no browsing context, so parsing it fetches no
3874
+ * images and media, runs no inline event handlers (`<img onerror>`) and
3875
+ * constructs no custom elements. A detached `<div>` belongs to the page's
3876
+ * document, which does all of these while parsing.
3877
+ */
3878
+ function parseHtmlInert(html) {
3879
+ const template = document.createElement("template");
3880
+ template.innerHTML = html;
3881
+ return template.content;
3882
+ }
3883
+ /**
3849
3884
  * Convert an HTML string (from micromark) to Preact VNodes,
3850
3885
  * replacing placeholder elements with pre-rendered components.
3851
3886
  * With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
3852
3887
  * replaced by their children.
3853
3888
  */
3854
3889
  function htmlToPreact(html, ph, unwrapParagraphs = false) {
3855
- const temp = document.createElement("div");
3856
- temp.innerHTML = html.trim();
3857
- if (unwrapParagraphs) for (const p of Array.from(temp.querySelectorAll(":scope > p"))) p.replaceWith(...Array.from(p.childNodes));
3858
- return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(temp.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
3890
+ const content = parseHtmlInert(html.trim());
3891
+ if (unwrapParagraphs) {
3892
+ for (const child of Array.from(content.childNodes)) if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === "P") child.replaceWith(...Array.from(child.childNodes));
3893
+ }
3894
+ return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(content.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
3859
3895
  }
3860
3896
  function convertDomNode(node, key, ph) {
3861
3897
  if (node.nodeType === Node.TEXT_NODE) {
@@ -3869,25 +3905,36 @@ function convertDomNode(node, key, ph) {
3869
3905
  const tag = el.localName;
3870
3906
  const idx = placeholderIndex(el, ph);
3871
3907
  if (idx !== -1) return ph.components[idx];
3872
- const props = {};
3873
- let withPlaceholders;
3874
- for (const attr of Array.from(el.attributes)) {
3875
- const parts = splitPlaceholderText(attr.value, ph);
3876
- if (parts.every((part) => typeof part === "string")) {
3877
- props[attr.name] = attr.value;
3878
- continue;
3879
- }
3880
- withPlaceholders ??= {};
3881
- withPlaceholders[attr.name] = parts.map((part) => typeof part === "string" ? {
3908
+ const svg = el.namespaceURI === SVG_NAMESPACE;
3909
+ const attributes = [];
3910
+ let placeholders;
3911
+ for (const { name, value } of Array.from(el.attributes)) {
3912
+ attributes.push([
3913
+ name,
3914
+ value,
3915
+ value
3916
+ ]);
3917
+ const parts = splitPlaceholderText(value, ph);
3918
+ if (parts.every((part) => typeof part === "string")) continue;
3919
+ placeholders ??= {};
3920
+ placeholders[name] = parts.map((part) => typeof part === "string" ? {
3882
3921
  type: "text",
3883
3922
  value: part
3884
3923
  } : ph.nodes[part]);
3885
3924
  }
3886
3925
  const children = Array.from(el.childNodes).map((child, i) => convertDomNode(child, i, ph));
3887
- if (withPlaceholders) return /* @__PURE__ */ jsx(PlaceholderAttributes, {
3926
+ if (placeholders) return /* @__PURE__ */ jsx(PlaceholderAttributes, {
3927
+ tag,
3928
+ attributes,
3929
+ placeholders,
3930
+ svg,
3931
+ children
3932
+ }, key);
3933
+ const { props, direct } = splitAttributes(attributes, svg);
3934
+ if (direct.length > 0) return /* @__PURE__ */ jsx(DirectAttributes, {
3888
3935
  tag,
3889
3936
  props,
3890
- attributes: withPlaceholders,
3937
+ direct,
3891
3938
  children
3892
3939
  }, key);
3893
3940
  return h(tag, {
@@ -4002,9 +4049,7 @@ function setRawAttribute(el, name, value) {
4002
4049
  el.setAttribute(name, value);
4003
4050
  return;
4004
4051
  }
4005
- const template = document.createElement("template");
4006
- template.innerHTML = `<i ${name}=""></i>`;
4007
- const parsed = template.content.firstChild.attributes[0];
4052
+ const parsed = parseHtmlInert(`<i ${name}=""></i>`).firstChild.attributes[0];
4008
4053
  if (!parsed) return;
4009
4054
  const attr = parsed.cloneNode();
4010
4055
  attr.value = value;
@@ -4042,6 +4087,23 @@ function splitAttributes(attributes, svg) {
4042
4087
  direct
4043
4088
  };
4044
4089
  }
4090
+ /**
4091
+ * Set the attributes `splitAttributes` leaves to set directly on the element
4092
+ * given the returned ref (none if there are none), and remove them again
4093
+ * when they change.
4094
+ */
4095
+ function useDirectAttributes(direct) {
4096
+ const elementRef = useRef(null);
4097
+ useLayoutEffect(() => {
4098
+ const el = elementRef.current;
4099
+ if (!el || direct.length === 0) return;
4100
+ for (const [name, value] of direct) setRawAttribute(el, name, value);
4101
+ return () => {
4102
+ for (const [name] of direct) el.removeAttribute(name);
4103
+ };
4104
+ }, [JSON.stringify(direct)]);
4105
+ return direct.length > 0 ? elementRef : void 0;
4106
+ }
4045
4107
  var decodedAttributeText = /* @__PURE__ */ new Map();
4046
4108
  /**
4047
4109
  * Decode character references (`&amp;`, `&#123;`) in attribute text the way
@@ -4051,9 +4113,7 @@ function decodeAttributeText(text) {
4051
4113
  if (!text.includes("&")) return text;
4052
4114
  let decoded = decodedAttributeText.get(text);
4053
4115
  if (decoded === void 0) {
4054
- const template = document.createElement("template");
4055
- template.innerHTML = `<i title="${text.replace(/"/g, "&quot;")}"></i>`;
4056
- decoded = template.content.firstChild.getAttribute("title") ?? text;
4116
+ decoded = parseHtmlInert(`<i title="${text.replace(/"/g, "&quot;")}"></i>`).firstChild.getAttribute("title") ?? text;
4057
4117
  decodedAttributeText.set(text, decoded);
4058
4118
  }
4059
4119
  return decoded;
@@ -4118,16 +4178,8 @@ function HtmlNodeRenderer({ node }) {
4118
4178
  resolveAttributeValue(k, v, scope, errors),
4119
4179
  v
4120
4180
  ]), inSvg || isSvgRoot);
4121
- const elementRef = useRef(null);
4122
- useLayoutEffect(() => {
4123
- const el = elementRef.current;
4124
- if (!el || direct.length === 0) return;
4125
- for (const [name, value] of direct) setRawAttribute(el, name, value);
4126
- return () => {
4127
- for (const [name] of direct) el.removeAttribute(name);
4128
- };
4129
- }, [JSON.stringify(direct)]);
4130
- if (direct.length > 0) props.ref = elementRef;
4181
+ const ref = useDirectAttributes(direct);
4182
+ if (ref) props.ref = ref;
4131
4183
  const isInline = INLINE_ELEMENTS.has(tag);
4132
4184
  let children = void 0;
4133
4185
  if (node.children.length > 0) if (inRaw || isRawRoot) children = renderInlineNodes(node.children);
@@ -7335,19 +7387,92 @@ function App() {
7335
7387
  }
7336
7388
  //#endregion
7337
7389
  //#region src/parser.ts
7390
+ var HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
7391
+ /** HTML elements written without content or end tag, as innerHTML does. */
7392
+ var VOID_ELEMENTS = new Set([
7393
+ "area",
7394
+ "base",
7395
+ "basefont",
7396
+ "bgsound",
7397
+ "br",
7398
+ "col",
7399
+ "embed",
7400
+ "frame",
7401
+ "hr",
7402
+ "img",
7403
+ "input",
7404
+ "keygen",
7405
+ "link",
7406
+ "meta",
7407
+ "param",
7408
+ "source",
7409
+ "track",
7410
+ "wbr"
7411
+ ]);
7412
+ /**
7413
+ * The passage markup in the nodes of passage data.
7414
+ *
7415
+ * A compiler that HTML-escapes passage text (Twine, Tweego) leaves a text
7416
+ * node whose text is the markup; a no-break space in it is U+00A0, which
7417
+ * innerHTML writes as `&nbsp;`. One that doesn't escape it leaves the
7418
+ * browser's parse of it, whose elements are written back as markup here.
7419
+ * innerHTML did that too, but its escaping can't be undone: a `"` in an
7420
+ * attribute value is written `&quot;`, and decoding that ends the value
7421
+ * early, while text it leaves unescaped (in `<style>`, comments) would be
7422
+ * decoded wrongly.
7423
+ */
7424
+ function passageMarkup(parent) {
7425
+ let markup = "";
7426
+ for (const node of Array.from(parent.childNodes)) if (node.nodeType === Node.TEXT_NODE) markup += node.data;
7427
+ else if (node.nodeType === Node.COMMENT_NODE) markup += `<!--${node.data}-->`;
7428
+ else if (node.nodeType === Node.ELEMENT_NODE) markup += elementMarkup(node);
7429
+ return markup;
7430
+ }
7431
+ /** An element of passage data as markup (see passageMarkup). */
7432
+ function elementMarkup(el) {
7433
+ const name = el.localName;
7434
+ let markup = `<${name}`;
7435
+ for (const attr of Array.from(el.attributes)) markup += ` ${attr.name}=${quoteAttribute(attr.name, attr.value)}`;
7436
+ markup += ">";
7437
+ if (el.namespaceURI === HTML_NAMESPACE && VOID_ELEMENTS.has(name)) return markup;
7438
+ const content = name === "template" ? el.content : el;
7439
+ return `${markup}${passageMarkup(content)}</${name}>`;
7440
+ }
7441
+ /**
7442
+ * An attribute value quoted so that the passage tokenizer reads it back:
7443
+ * in double quotes, else single ones, else with the double quotes in its
7444
+ * text written as `&quot;`, which rendering decodes. Quotes inside markup in
7445
+ * the value (`{print "a"}`) are code, and don't end it.
7446
+ */
7447
+ function quoteAttribute(name, value) {
7448
+ const code = isCodeAttribute(name);
7449
+ const probe = code ? "on" : "a";
7450
+ const readsBack = (quoted) => {
7451
+ const tokens = tokenize(`<i ${probe}=${quoted}>`);
7452
+ const tag = tokens[0];
7453
+ return tokens.length === 1 && tag?.type === "html" && tag.attributes[probe] === quoted.slice(1, -1);
7454
+ };
7455
+ return [`"${value}"`, `'${value}'`].find(readsBack) ?? `"${escapeTextQuotes(value, code)}"`;
7456
+ }
7338
7457
  /**
7339
- * Decode the HTML entities that browsers use when serializing innerHTML.
7340
- * &amp; is decoded last to avoid double-decoding (e.g. &amp;lt; → &lt;, not <).
7458
+ * `value` with the double quotes in its text written as `&quot;`, but not
7459
+ * those in markup (`{print "a"}`) or, in a code attribute, in sigil
7460
+ * references (see splitSigilTemplate), which are code.
7341
7461
  */
7342
- function decodeHtmlEntities(html) {
7343
- return html.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&amp;/g, "&");
7462
+ function escapeTextQuotes(value, code) {
7463
+ const escape = (text) => text.replace(/"/g, "&quot;");
7464
+ if (code) return splitSigilTemplate(value).map((part) => "text" in part ? escape(part.text) : "expr" in part ? `{${part.expr}}` : part.verbatim).join("");
7465
+ return tokenize(value, { text: true }).map((token) => {
7466
+ const source = value.slice(token.start, token.end);
7467
+ return token.type === "text" ? escape(source) : source;
7468
+ }).join("");
7344
7469
  }
7345
7470
  /**
7346
7471
  * Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
7347
7472
  *
7348
- * Uses innerHTML + entity decoding instead of textContent so that HTML
7349
- * tags inside passage markup (e.g. <div> inside {button}) are preserved
7350
- * regardless of whether the compiler HTML-encoded them.
7473
+ * Passage content is read with passageMarkup instead of textContent, so
7474
+ * that HTML tags inside passage markup (e.g. <div> inside {button}) are
7475
+ * preserved whether or not the compiler HTML-encoded them.
7351
7476
  */
7352
7477
  function parseStoryData() {
7353
7478
  const storyEl = document.querySelector("tw-storydata");
@@ -7365,7 +7490,7 @@ function parseStoryData() {
7365
7490
  const pid = parseInt(el.getAttribute("pid") || "0", 10);
7366
7491
  const passageName = el.getAttribute("name") || "";
7367
7492
  const tags = (el.getAttribute("tags") || "").split(/\s+/).filter((t) => t.length > 0);
7368
- const content = decodeHtmlEntities(el.innerHTML);
7493
+ const content = passageMarkup(el);
7369
7494
  const metadata = {};
7370
7495
  const skipAttrs = new Set([
7371
7496
  "pid",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.52.8",
3
+ "version": "0.52.9",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,4 +1,4 @@
1
- import { createContext } from 'preact';
1
+ import { createContext, type RefObject } from 'preact';
2
2
  import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
3
3
  import { VarDisplay } from '../components/macros/VarDisplay';
4
4
  import { ExprDisplay } from '../components/macros/ExprDisplay';
@@ -63,6 +63,8 @@ export const RawTextContext = createContext(false);
63
63
  */
64
64
  const SvgContext = createContext(false);
65
65
 
66
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
67
+
66
68
  /** Elements whose content is literal text, not markdown. */
67
69
  const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
68
70
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
@@ -139,31 +141,59 @@ function expandPlaceholderText(
139
141
  );
140
142
  }
141
143
 
144
+ /** An attribute: its name, value and value as written (see splitAttributes). */
145
+ type Attribute = [name: string, value: string, written: string];
146
+
142
147
  /**
143
- * An element whose attributes contain placeholders (a variable in image alt
144
- * text or a link title). Each such attribute is evaluated as text against
145
- * the store and locals, so it follows its variables as the component would.
148
+ * An element of markdown output whose attributes contain placeholders (a
149
+ * variable in image alt text or a link title). Each such attribute is
150
+ * evaluated as text against the store and locals, so it follows its
151
+ * variables as the component would.
146
152
  */
147
153
  function PlaceholderAttributes({
148
154
  tag,
149
- props,
150
155
  attributes,
156
+ placeholders,
157
+ svg,
151
158
  children,
152
159
  }: {
153
160
  tag: string;
154
- props: Record<string, string>;
155
- attributes: Record<string, ASTNode[]>;
161
+ attributes: Attribute[];
162
+ placeholders: Record<string, ASTNode[]>;
163
+ svg: boolean;
156
164
  children: preact.ComponentChildren[];
157
165
  }) {
158
166
  const scope = useTextScope();
159
- const resolved: Record<string, string> = { ...props };
160
167
  const errors: AttributeError[] = [];
161
- for (const [name, nodes] of Object.entries(attributes)) {
168
+ const resolved = attributes.map(([name, value, written]): Attribute => {
169
+ const nodes = placeholders[name];
170
+ if (!nodes) return [name, value, written];
162
171
  const result = renderText(nodes, scope);
163
- resolved[name] = result.text;
164
172
  for (const error of result.errors) errors.push([name, error]);
165
- }
166
- return withAttributeErrors(errors, h(tag, resolved, ...children));
173
+ return [name, result.text, written];
174
+ });
175
+ const { props, direct } = splitAttributes(resolved, svg);
176
+ const ref = useDirectAttributes(direct);
177
+ return withAttributeErrors(
178
+ errors,
179
+ h(tag, ref ? { ...props, ref } : props, ...children),
180
+ );
181
+ }
182
+
183
+ /** An element of markdown output with attributes to set directly. */
184
+ function DirectAttributes({
185
+ tag,
186
+ props,
187
+ direct,
188
+ children,
189
+ }: {
190
+ tag: string;
191
+ props: Record<string, unknown>;
192
+ direct: [string, string][];
193
+ children: preact.ComponentChildren[];
194
+ }) {
195
+ const ref = useDirectAttributes(direct);
196
+ return h(tag, { ...props, ref }, ...children);
167
197
  }
168
198
 
169
199
  /** An error met while evaluating the named attribute. */
@@ -201,6 +231,19 @@ function placeholderIndex(node: Node | null, ph: Placeholders): number {
201
231
  return parseInt(value.slice(prefix.length), 10);
202
232
  }
203
233
 
234
+ /**
235
+ * Parse HTML into nodes without side effects. The content of a `<template>`
236
+ * belongs to a document with no browsing context, so parsing it fetches no
237
+ * images and media, runs no inline event handlers (`<img onerror>`) and
238
+ * constructs no custom elements. A detached `<div>` belongs to the page's
239
+ * document, which does all of these while parsing.
240
+ */
241
+ export function parseHtmlInert(html: string): DocumentFragment {
242
+ const template = document.createElement('template');
243
+ template.innerHTML = html;
244
+ return template.content;
245
+ }
246
+
204
247
  /**
205
248
  * Convert an HTML string (from micromark) to Preact VNodes,
206
249
  * replacing placeholder elements with pre-rendered components.
@@ -212,14 +255,15 @@ function htmlToPreact(
212
255
  ph: Placeholders,
213
256
  unwrapParagraphs = false,
214
257
  ): preact.ComponentChildren {
215
- const temp = document.createElement('div');
216
- temp.innerHTML = html.trim();
258
+ const content = parseHtmlInert(html.trim());
217
259
  if (unwrapParagraphs) {
218
- for (const p of Array.from(temp.querySelectorAll(':scope > p'))) {
219
- p.replaceWith(...Array.from(p.childNodes));
260
+ for (const child of Array.from(content.childNodes)) {
261
+ if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === 'P') {
262
+ (child as Element).replaceWith(...Array.from(child.childNodes));
263
+ }
220
264
  }
221
265
  }
222
- const children = Array.from(temp.childNodes).map((child, i) =>
266
+ const children = Array.from(content.childNodes).map((child, i) =>
223
267
  convertDomNode(child, i, ph),
224
268
  );
225
269
  return <>{children}</>;
@@ -252,17 +296,17 @@ function convertDomNode(
252
296
  return ph.components[idx];
253
297
  }
254
298
 
255
- // Convert attributes
256
- const props: Record<string, string> = {};
257
- let withPlaceholders: Record<string, ASTNode[]> | undefined;
258
- for (const attr of Array.from(el.attributes)) {
259
- const parts = splitPlaceholderText(attr.value, ph);
260
- if (parts.every((part) => typeof part === 'string')) {
261
- props[attr.name] = attr.value;
262
- continue;
263
- }
264
- withPlaceholders ??= {};
265
- withPlaceholders[attr.name] = parts.map(
299
+ // Convert attributes, as author HTML's (see splitAttributes): markdown
300
+ // output holds raw HTML the passage tokenizer didn't take as a tag
301
+ const svg = el.namespaceURI === SVG_NAMESPACE;
302
+ const attributes: Attribute[] = [];
303
+ let placeholders: Record<string, ASTNode[]> | undefined;
304
+ for (const { name, value } of Array.from(el.attributes)) {
305
+ attributes.push([name, value, value]);
306
+ const parts = splitPlaceholderText(value, ph);
307
+ if (parts.every((part) => typeof part === 'string')) continue;
308
+ placeholders ??= {};
309
+ placeholders[name] = parts.map(
266
310
  (part): ASTNode =>
267
311
  typeof part === 'string'
268
312
  ? { type: 'text', value: part }
@@ -275,13 +319,26 @@ function convertDomNode(
275
319
  convertDomNode(child, i, ph),
276
320
  );
277
321
 
278
- if (withPlaceholders) {
322
+ if (placeholders) {
279
323
  return (
280
324
  <PlaceholderAttributes
325
+ key={key}
326
+ tag={tag}
327
+ attributes={attributes}
328
+ placeholders={placeholders}
329
+ svg={svg}
330
+ children={children}
331
+ />
332
+ );
333
+ }
334
+ const { props, direct } = splitAttributes(attributes, svg);
335
+ if (direct.length > 0) {
336
+ return (
337
+ <DirectAttributes
281
338
  key={key}
282
339
  tag={tag}
283
340
  props={props}
284
- attributes={withPlaceholders}
341
+ direct={direct}
285
342
  children={children}
286
343
  />
287
344
  );
@@ -395,9 +452,8 @@ function setRawAttribute(el: Element, name: string, value: string) {
395
452
  el.setAttribute(name, value);
396
453
  return;
397
454
  }
398
- const template = document.createElement('template');
399
- template.innerHTML = `<i ${name}=""></i>`;
400
- const parsed = (template.content.firstChild as Element).attributes[0];
455
+ const parsed = (parseHtmlInert(`<i ${name}=""></i>`).firstChild as Element)
456
+ .attributes[0];
401
457
  if (!parsed) return;
402
458
  const attr = parsed.cloneNode() as Attr;
403
459
  attr.value = value;
@@ -422,7 +478,7 @@ function setRawAttribute(el: Element, name: string, value: string) {
422
478
  * `className` into `class`) are set directly instead.
423
479
  */
424
480
  function splitAttributes(
425
- attributes: [name: string, value: string, written: string][],
481
+ attributes: Attribute[],
426
482
  svg: boolean,
427
483
  ): { props: Record<string, unknown>; direct: [string, string][] } {
428
484
  const props: Record<string, unknown> = {};
@@ -447,6 +503,27 @@ function splitAttributes(
447
503
  return { props, direct };
448
504
  }
449
505
 
506
+ /**
507
+ * Set the attributes `splitAttributes` leaves to set directly on the element
508
+ * given the returned ref (none if there are none), and remove them again
509
+ * when they change.
510
+ */
511
+ function useDirectAttributes(
512
+ direct: [string, string][],
513
+ ): RefObject<Element> | undefined {
514
+ const elementRef = useRef<Element>(null);
515
+ const directKey = JSON.stringify(direct);
516
+ useLayoutEffect(() => {
517
+ const el = elementRef.current;
518
+ if (!el || direct.length === 0) return;
519
+ for (const [name, value] of direct) setRawAttribute(el, name, value);
520
+ return () => {
521
+ for (const [name] of direct) el.removeAttribute(name);
522
+ };
523
+ }, [directKey]);
524
+ return direct.length > 0 ? elementRef : undefined;
525
+ }
526
+
450
527
  const decodedAttributeText = new Map<string, string>();
451
528
 
452
529
  /**
@@ -457,10 +534,10 @@ function decodeAttributeText(text: string): string {
457
534
  if (!text.includes('&')) return text;
458
535
  let decoded = decodedAttributeText.get(text);
459
536
  if (decoded === undefined) {
460
- const template = document.createElement('template');
461
- template.innerHTML = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
537
+ const html = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
462
538
  decoded =
463
- (template.content.firstChild as Element).getAttribute('title') ?? text;
539
+ (parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
540
+ text;
464
541
  decodedAttributeText.set(text, decoded);
465
542
  }
466
543
  return decoded;
@@ -532,24 +609,11 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
532
609
  const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
533
610
  const errors: AttributeError[] = [];
534
611
  const resolved = Object.entries(node.attributes).map(
535
- ([k, v]): [string, string, string] => [
536
- k,
537
- resolveAttributeValue(k, v, scope, errors),
538
- v,
539
- ],
612
+ ([k, v]): Attribute => [k, resolveAttributeValue(k, v, scope, errors), v],
540
613
  );
541
614
  const { props, direct } = splitAttributes(resolved, inSvg || isSvgRoot);
542
- const elementRef = useRef<Element>(null);
543
- const directKey = JSON.stringify(direct);
544
- useLayoutEffect(() => {
545
- const el = elementRef.current;
546
- if (!el || direct.length === 0) return;
547
- for (const [name, value] of direct) setRawAttribute(el, name, value);
548
- return () => {
549
- for (const [name] of direct) el.removeAttribute(name);
550
- };
551
- }, [directKey]);
552
- if (direct.length > 0) props.ref = elementRef;
615
+ const ref = useDirectAttributes(direct);
616
+ if (ref) props.ref = ref;
553
617
  const isInline = INLINE_ELEMENTS.has(tag);
554
618
  // Inside SVG and preformatted elements, skip markdown processing entirely
555
619
  // (see RawTextContext).