@texuli/core 0.4.0 → 0.6.0

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.
Files changed (53) hide show
  1. package/README.md +30 -6
  2. package/dist/editor/document.js +8 -1
  3. package/dist/editor/extensions.d.ts +64 -0
  4. package/dist/editor/extensions.js +116 -0
  5. package/dist/editor/inline.d.ts +5 -3
  6. package/dist/editor/inline.js +24 -12
  7. package/dist/editor/load.d.ts +10 -0
  8. package/dist/editor/load.js +16 -0
  9. package/dist/editor/markdown.d.ts +10 -1
  10. package/dist/editor/markdown.js +340 -100
  11. package/dist/editor/model.d.ts +3 -1
  12. package/dist/editor/model.js +3 -1
  13. package/dist/editor/transforms.d.ts +1 -1
  14. package/dist/editor/tree.d.ts +11 -1
  15. package/dist/editor/tree.js +11 -2
  16. package/dist/editor/types.d.ts +10 -0
  17. package/dist/editor/types.js +1 -0
  18. package/dist/focus-scope/focus-scope.d.ts +7 -0
  19. package/dist/focus-scope/focus-scope.js +24 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/inert/inert.d.ts +11 -0
  23. package/dist/inert/inert.js +13 -4
  24. package/dist/markup/index.d.ts +5 -0
  25. package/dist/markup/index.js +5 -0
  26. package/dist/markup/markup.d.ts +72 -0
  27. package/dist/markup/markup.js +233 -0
  28. package/dist/motion/animate.d.ts +109 -0
  29. package/dist/motion/animate.js +225 -0
  30. package/dist/motion/flip.d.ts +58 -0
  31. package/dist/motion/flip.js +167 -0
  32. package/dist/motion/index.d.ts +15 -0
  33. package/dist/motion/index.js +15 -0
  34. package/dist/motion/preferences.d.ts +68 -0
  35. package/dist/motion/preferences.js +111 -0
  36. package/dist/motion/presence.d.ts +61 -0
  37. package/dist/motion/presence.js +231 -0
  38. package/dist/motion/sequence.d.ts +70 -0
  39. package/dist/motion/sequence.js +159 -0
  40. package/dist/motion/shared.d.ts +45 -0
  41. package/dist/motion/shared.js +144 -0
  42. package/dist/motion/spring.d.ts +109 -0
  43. package/dist/motion/spring.js +166 -0
  44. package/dist/overlay/core.js +16 -3
  45. package/dist/url-policy/filter-image-srcset.d.ts +11 -0
  46. package/dist/url-policy/filter-image-srcset.js +60 -0
  47. package/dist/url-policy/index.d.ts +1 -0
  48. package/dist/url-policy/index.js +1 -0
  49. package/dist/zip/index.d.ts +5 -0
  50. package/dist/zip/index.js +5 -0
  51. package/dist/zip/zip.d.ts +35 -0
  52. package/dist/zip/zip.js +190 -0
  53. package/package.json +6 -2
@@ -2,9 +2,11 @@
2
2
  * `@texuli/core/editor-model`: the rich text editor document model (ADR-003).
3
3
  * Types and caps, the closed vocabularies, constructors, normalization,
4
4
  * validation, the untrusted loader, selection helpers, grapheme and word
5
- * boundaries, and the nested inline interchange shape. Framework-free.
5
+ * boundaries, the nested inline interchange shape, and host inline
6
+ * extensions (mentions, references). Framework-free.
6
7
  */
7
8
  export { blockLength, blockText, createEditorIds, type EditorAncestor, type EditorIdSource, type EditorLocation, editorBlockCount, editorCharCount, editorLeaves, editorPlainText, emptyEditorDocument, isTextBlock, isVoidBlock, locateBlock, nextEditorId, normalizeDocument, paragraph, removeBlocks, replaceBlock, replaceItem, validateEditorDocument, } from './document.js';
9
+ export { acceptsExtensionId, createInlineExtensions, EDITOR_EXTENSION_LIMITS, EDITOR_EXTENSION_SIGILS, type EditorExtensionSigil, type EditorInlineExtension, type EditorInlineExtensions, extensionHref, toInlineExtensions, } from './extensions.js';
8
10
  export { graphemeBoundaries, nextGrapheme, nextWord, previousGrapheme, previousWord, snapToGrapheme, } from './graphemes.js';
9
11
  export { cleanText, hasMark, inheritedMarks, inlineLength, inlineText, markKey, normalizeInline, sliceInline, sortMarks, textToInline, } from './inline.js';
10
12
  export { type EditorLoadOptions, type EditorLoadResult, loadEditorDocument } from './load.js';
@@ -2,9 +2,11 @@
2
2
  * `@texuli/core/editor-model`: the rich text editor document model (ADR-003).
3
3
  * Types and caps, the closed vocabularies, constructors, normalization,
4
4
  * validation, the untrusted loader, selection helpers, grapheme and word
5
- * boundaries, and the nested inline interchange shape. Framework-free.
5
+ * boundaries, the nested inline interchange shape, and host inline
6
+ * extensions (mentions, references). Framework-free.
6
7
  */
7
8
  export { blockLength, blockText, createEditorIds, editorBlockCount, editorCharCount, editorLeaves, editorPlainText, emptyEditorDocument, isTextBlock, isVoidBlock, locateBlock, nextEditorId, normalizeDocument, paragraph, removeBlocks, replaceBlock, replaceItem, validateEditorDocument, } from "./document.js";
9
+ export { acceptsExtensionId, createInlineExtensions, EDITOR_EXTENSION_LIMITS, EDITOR_EXTENSION_SIGILS, extensionHref, toInlineExtensions, } from "./extensions.js";
8
10
  export { graphemeBoundaries, nextGrapheme, nextWord, previousGrapheme, previousWord, snapToGrapheme, } from "./graphemes.js";
9
11
  export { cleanText, hasMark, inheritedMarks, inlineLength, inlineText, markKey, normalizeInline, sliceInline, sortMarks, textToInline, } from "./inline.js";
10
12
  export { loadEditorDocument } from "./load.js";
@@ -50,7 +50,7 @@ export declare function toggleQuote(state: EditorState, options?: EditorTransfor
50
50
  * it). At a caret it toggles the stored marks for the next typed text. Code
51
51
  * text takes only `link` besides `code`.
52
52
  */
53
- export declare function toggleMark(state: EditorState, type: Exclude<EditorMarkType, 'link'>, options?: EditorTransformOptions): EditorTransformResult;
53
+ export declare function toggleMark(state: EditorState, type: Exclude<EditorMarkType, 'link' | 'extension'>, options?: EditorTransformOptions): EditorTransformResult;
54
54
  export interface SetLinkOptions extends EditorTransformOptions {
55
55
  /** URL policy tier. Default `untrusted`; pasted and typed URLs stay untrusted. */
56
56
  trust?: HrefTrust;
@@ -20,8 +20,18 @@ export type EditorInlineNode = {
20
20
  type: 'link';
21
21
  href: string;
22
22
  children: EditorInlineNode[];
23
+ }
24
+ /** A host inline extension entity (`name`, `id`) around its label. */
25
+ | {
26
+ type: 'extension';
27
+ name: string;
28
+ id: string;
29
+ children: EditorInlineNode[];
23
30
  };
24
- /** Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`, `strike`). */
31
+ /**
32
+ * Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`,
33
+ * `strike`, then `extension` innermost around its label).
34
+ */
25
35
  export declare function inlineToTree(content: readonly EditorInline[]): EditorInlineNode[];
26
36
  /**
27
37
  * Flatten a nested inline tree (this shape, the catalog's or the blocks
@@ -1,6 +1,9 @@
1
1
  import { markKey, normalizeInline, sortMarks } from "./inline.js";
2
2
  import { LoadBudget } from "./load.js";
3
- /** Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`, `strike`). */
3
+ /**
4
+ * Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`,
5
+ * `strike`, then `extension` innermost around its label).
6
+ */
4
7
  export function inlineToTree(content) {
5
8
  const root = [];
6
9
  const stack = [];
@@ -22,7 +25,9 @@ export function inlineToTree(content) {
22
25
  for (const mark of wanted.slice(common)) {
23
26
  const node = mark.type === 'link'
24
27
  ? { type: 'link', href: mark.href, children: [] }
25
- : { type: mark.type, children: [] };
28
+ : mark.type === 'extension'
29
+ ? { type: 'extension', name: mark.name, id: mark.id, children: [] }
30
+ : { type: mark.type, children: [] };
26
31
  children().push(node);
27
32
  stack.push({ mark, node });
28
33
  }
@@ -90,6 +95,10 @@ export function inlineFromTree(nodes, options = {}) {
90
95
  const href = budget.href(node.href);
91
96
  visit(node.children, href ? sortMarks([...marks, { type: 'link', href }]) : marks, depth + 1);
92
97
  }
98
+ else if (type === 'extension') {
99
+ const extension = budget.extension(node.name, node.id);
100
+ visit(node.children, extension ? sortMarks([...marks, extension]) : marks, depth + 1);
101
+ }
93
102
  else {
94
103
  budget.report('unknown-node', 'Unknown inline nodes kept only their text.');
95
104
  if (text !== undefined)
@@ -18,6 +18,16 @@ export type EditorMark = {
18
18
  } | {
19
19
  type: 'link';
20
20
  href: string;
21
+ }
22
+ /**
23
+ * A host inline extension entity (a mention, a reference) over its label
24
+ * text: `name` is a registered extension, `id` the entity (G48). Excludes
25
+ * `link` and `code` on the same run.
26
+ */
27
+ | {
28
+ type: 'extension';
29
+ name: string;
30
+ id: string;
21
31
  };
22
32
  export type EditorMarkType = EditorMark['type'];
23
33
  export type EditorInline = {
@@ -27,6 +27,7 @@ export const EDITOR_MARK_ORDER = [
27
27
  'em',
28
28
  'strike',
29
29
  'code',
30
+ 'extension',
30
31
  ];
31
32
  export const EDITOR_BLOCK_TYPES = [
32
33
  'paragraph',
@@ -20,6 +20,13 @@ export interface FocusScopeOptions {
20
20
  cycleTabNavigation?: boolean;
21
21
  /** Called before focus returns to the opener; call `preventDefault()` to cancel. */
22
22
  onBeforeRestoreFocus?: (event: FocusRestoreEvent) => void;
23
+ /**
24
+ * Focus-exempt regions outside the container (an undo toaster, the live
25
+ * announcer). While focus is inside one, Tab and Shift+Tab move natively
26
+ * between the region's own stops; past its last (or before its first) stop,
27
+ * focus wraps back into the scope. Queried on every Tab.
28
+ */
29
+ exemptRegions?: () => readonly Element[];
23
30
  }
24
31
  /** An activated, framework-free keyboard focus scope. */
25
32
  export interface FocusScope {
@@ -44,11 +44,35 @@ export function createFocusScope(container, options = {}) {
44
44
  return;
45
45
  focus(resolveInitialTarget(container, options), true);
46
46
  };
47
+ /** The exempt region holding `node`, when it sits outside the container. */
48
+ const exemptRegionOf = (node) => {
49
+ if (!node || container.contains(node))
50
+ return null;
51
+ for (const region of options.exemptRegions?.() ?? []) {
52
+ if (region.contains(node))
53
+ return region;
54
+ }
55
+ return null;
56
+ };
47
57
  const onKeyDown = (event) => {
48
58
  if (!active || !isTopScope(scope, document) || event.key !== 'Tab')
49
59
  return;
50
60
  const tabbable = getTabbableElements(container);
51
61
  const current = document.activeElement;
62
+ const region = exemptRegionOf(current);
63
+ if (region) {
64
+ // Inside a focus-exempt region: native order between its own stops,
65
+ // then back into the scope at either end.
66
+ const stops = getTabbableElements(region);
67
+ const at = stops.indexOf(current);
68
+ const leaving = event.shiftKey ? at <= 0 : at < 0 || at === stops.length - 1;
69
+ if (!leaving)
70
+ return;
71
+ event.preventDefault();
72
+ const target = event.shiftKey ? tabbable[tabbable.length - 1] : tabbable[0];
73
+ focus(target ?? container, !target);
74
+ return;
75
+ }
52
76
  const index = tabbable.indexOf(current);
53
77
  const cycle = options.cycleTabNavigation === true;
54
78
  if (!cycle && !(event.shiftKey ? index <= 0 : index < 0 || index === tabbable.length - 1)) {
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export { lockScroll } from './scroll-lock/scroll-lock.js';
19
19
  export type { TabbableOptions } from './tabbable/index.js';
20
20
  export { getFocusableElements, getTabbableElements, isFocusable, isTabbable, } from './tabbable/index.js';
21
21
  export { type HrefTrust, safeHref } from './url/safe-href.js';
22
+ export { filterImageSrcset } from './url-policy/filter-image-srcset.js';
22
23
  export { safeImageSrc } from './url-policy/safe-image-src.js';
23
24
  export { safeImageSrcset } from './url-policy/safe-image-srcset.js';
24
25
  export { type SafeMediaSrcOptions, safeMediaSrc } from './url-policy/safe-media-src.js';
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { createRovingFocus, createTypeahead, indexOfSelected, isItemDisabled, se
18
18
  export { lockScroll } from "./scroll-lock/scroll-lock.js";
19
19
  export { getFocusableElements, getTabbableElements, isFocusable, isTabbable, } from "./tabbable/index.js";
20
20
  export { safeHref } from "./url/safe-href.js";
21
+ export { filterImageSrcset } from "./url-policy/filter-image-srcset.js";
21
22
  export { safeImageSrc } from "./url-policy/safe-image-src.js";
22
23
  export { safeImageSrcset } from "./url-policy/safe-image-srcset.js";
23
24
  export { safeMediaSrc } from "./url-policy/safe-media-src.js";
@@ -34,10 +34,21 @@ export interface InertExemptionInput {
34
34
  element: Element;
35
35
  /** Optional debug label (`toast-region`, `announcer`, …). */
36
36
  label?: string;
37
+ /**
38
+ * Whether the region stays above overlays opened after it (default `true`):
39
+ * when it is open as a popover, the overlay engine re-shows it in the top
40
+ * layer each time another overlay opens, so the toast region keeps painting
41
+ * on top. Pass `false` for exemptions that belong under later overlays: a
42
+ * dialog backdrop, or a menu or listbox opened inside a modal, which must
43
+ * never cover the overlay opened from it.
44
+ */
45
+ raise?: boolean;
37
46
  }
38
47
  export interface InertExemption {
39
48
  readonly element: Element;
40
49
  readonly label?: string;
50
+ /** `false` when the region must not be re-raised above later overlays (omitted means `true`). */
51
+ readonly raise?: false;
41
52
  }
42
53
  export interface InertExemptionHandle {
43
54
  /** Stop exempting this element (idempotent). Open scopes recompute. */
@@ -30,8 +30,15 @@ const SKIPPED_TAGS = new Set([
30
30
  'TEMPLATE',
31
31
  'TITLE',
32
32
  ]);
33
+ // Realm-independent: an element from another window, or one whose timer runs
34
+ // after the global Element binding is gone, is still an element.
33
35
  function isElement(value) {
34
- return typeof Element !== 'undefined' && value instanceof Element;
36
+ if (typeof value !== 'object' || value === null)
37
+ return false;
38
+ const node = value;
39
+ return (node.nodeType === 1 &&
40
+ typeof node.setAttribute === 'function' &&
41
+ typeof node.contains === 'function');
35
42
  }
36
43
  /**
37
44
  * The elements to make inert so that only `keep` (and their descendants) stay
@@ -135,9 +142,11 @@ export function createInertRegistry() {
135
142
  if (!isElement(input.element)) {
136
143
  throw new TypeError('InertRegistry.exempt: element must be a DOM Element');
137
144
  }
138
- const entry = input.label === undefined
139
- ? { element: input.element }
140
- : { element: input.element, label: input.label };
145
+ const entry = {
146
+ element: input.element,
147
+ ...(input.label === undefined ? {} : { label: input.label }),
148
+ ...(input.raise === false ? { raise: false } : {}),
149
+ };
141
150
  exemptions.push(entry);
142
151
  apply();
143
152
  let released = false;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @texuli/core/markup: escaped XML and HTML builders (ADR-016). Typed nodes
3
+ * only, no raw-markup API; URL attributes go through the URL policy.
4
+ */
5
+ export { el, escapeMarkupAttribute, escapeMarkupText, HTML_ELEMENTS, HTML_URL_ATTRIBUTES, type HtmlSerializeOptions, imageUrl, isMarkupUrl, linkUrl, type MarkupAttribute, type MarkupAttributeValue, type MarkupChild, type MarkupElement, type MarkupNode, type MarkupText, type MarkupUrl, sanitizeMarkupChars, serializeHtml, serializeXml, text, type XmlSerializeOptions, } from './markup.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @texuli/core/markup: escaped XML and HTML builders (ADR-016). Typed nodes
3
+ * only, no raw-markup API; URL attributes go through the URL policy.
4
+ */
5
+ export { el, escapeMarkupAttribute, escapeMarkupText, HTML_ELEMENTS, HTML_URL_ATTRIBUTES, imageUrl, isMarkupUrl, linkUrl, sanitizeMarkupChars, serializeHtml, serializeXml, text, } from "./markup.js";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Escaped markup builders (ADR-016): typed element and text nodes that
3
+ * serialize to XML or HTML. Content only enters through these nodes, so
4
+ * there is no raw-markup API: every text and attribute value is escaped,
5
+ * element and attribute names are checked, and URL-valued HTML attributes
6
+ * accept only a `MarkupUrl` made by `linkUrl` or `imageUrl`, which call the
7
+ * URL policy with an explicit tier. Serialization is iterative and linear in
8
+ * the size of the tree.
9
+ */
10
+ import { type HrefTrust } from '../url/safe-href.js';
11
+ export interface MarkupText {
12
+ readonly kind: 'text';
13
+ readonly value: string;
14
+ }
15
+ /** A URL that passed the URL policy. Only `linkUrl` and `imageUrl` make one the serializers accept. */
16
+ export interface MarkupUrl {
17
+ readonly kind: 'url';
18
+ readonly value: string;
19
+ }
20
+ export type MarkupAttributeValue = string | number | boolean | MarkupUrl | null | undefined;
21
+ export type MarkupAttribute = readonly [string, string | boolean | MarkupUrl];
22
+ export interface MarkupElement {
23
+ readonly kind: 'element';
24
+ readonly name: string;
25
+ readonly attributes: readonly MarkupAttribute[];
26
+ readonly children: readonly MarkupNode[];
27
+ }
28
+ export type MarkupNode = MarkupElement | MarkupText;
29
+ /** A child: a node, a string or number (escaped text), a nested list, or a skipped `null`, `undefined` or `false`. */
30
+ export type MarkupChild = MarkupNode | string | number | null | undefined | false | readonly MarkupChild[];
31
+ export interface XmlSerializeOptions {
32
+ /** Prefix the UTF-8 declaration (`standalone="yes"`, as Office packages use). Default true. */
33
+ declaration?: boolean;
34
+ /** An allowlist for element and attribute names, on top of the XML name grammar. */
35
+ allow?: (name: string, what: 'element' | 'attribute') => boolean;
36
+ }
37
+ export interface HtmlSerializeOptions {
38
+ /** Prefix `<!doctype html>`. Default false. */
39
+ doctype?: boolean;
40
+ }
41
+ /**
42
+ * The HTML elements `serializeHtml` writes: document structure, text,
43
+ * lists, tables, links and images. No scripts, styles, frames, objects,
44
+ * forms, templates, SVG or MathML.
45
+ */
46
+ export declare const HTML_ELEMENTS: ReadonlySet<string>;
47
+ /** HTML attributes that hold a URL: only a `MarkupUrl` value is written. */
48
+ export declare const HTML_URL_ATTRIBUTES: ReadonlySet<string>;
49
+ /** A link URL through `safeHref` at `trust`, or `null` when the policy refuses it. */
50
+ export declare function linkUrl(value: string, trust: HrefTrust): MarkupUrl | null;
51
+ /** An image URL through `safeImageSrc` at `trust`, or `null` when the policy refuses it. */
52
+ export declare function imageUrl(value: string, trust: HrefTrust): MarkupUrl | null;
53
+ /** True only for a URL `linkUrl` or `imageUrl` made (a hand-built object is not one). */
54
+ export declare function isMarkupUrl(value: unknown): value is MarkupUrl;
55
+ /**
56
+ * Drop characters XML 1.0 cannot carry: C0 controls other than tab,
57
+ * newline and carriage return, U+FFFE and U+FFFF, and unpaired surrogates.
58
+ */
59
+ export declare function sanitizeMarkupChars(value: string): string;
60
+ export declare function escapeMarkupText(value: string): string;
61
+ export declare function escapeMarkupAttribute(value: string): string;
62
+ export declare function text(value: string | number): MarkupText;
63
+ /** Build an element. String and number children become escaped text; nullish and `false` children are skipped. */
64
+ export declare function el(name: string, attributes?: Readonly<Record<string, MarkupAttributeValue>>, ...children: MarkupChild[]): MarkupElement;
65
+ /** Serialize to XML: booleans become `1` and `0` (the Office convention); childless elements self-close. */
66
+ export declare function serializeXml(root: MarkupElement, options?: XmlSerializeOptions): string;
67
+ /**
68
+ * Serialize to HTML: only `HTML_ELEMENTS`, no event handler or `srcdoc`
69
+ * attributes, URL attributes only from `linkUrl` or `imageUrl`, booleans as
70
+ * bare attributes, and void elements without an end tag.
71
+ */
72
+ export declare function serializeHtml(root: MarkupElement, options?: HtmlSerializeOptions): string;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Escaped markup builders (ADR-016): typed element and text nodes that
3
+ * serialize to XML or HTML. Content only enters through these nodes, so
4
+ * there is no raw-markup API: every text and attribute value is escaped,
5
+ * element and attribute names are checked, and URL-valued HTML attributes
6
+ * accept only a `MarkupUrl` made by `linkUrl` or `imageUrl`, which call the
7
+ * URL policy with an explicit tier. Serialization is iterative and linear in
8
+ * the size of the tree.
9
+ */
10
+ import { safeHref } from "../url/safe-href.js";
11
+ import { safeImageSrc } from "../url-policy/safe-image-src.js";
12
+ const XML_NAME = /^[A-Za-z_][A-Za-z0-9._-]*(?::[A-Za-z_][A-Za-z0-9._-]*)?$/;
13
+ const HTML_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
14
+ const HTML_ATTRIBUTE = /^[a-z][a-z0-9]*(?:[-:][a-z0-9]+)*$/;
15
+ /**
16
+ * The HTML elements `serializeHtml` writes: document structure, text,
17
+ * lists, tables, links and images. No scripts, styles, frames, objects,
18
+ * forms, templates, SVG or MathML.
19
+ */
20
+ export const HTML_ELEMENTS = new Set(('html head body title meta main article section header footer nav aside div span p br hr ' +
21
+ 'h1 h2 h3 h4 h5 h6 strong b em i u s del ins code pre kbd samp small sub sup mark blockquote ' +
22
+ 'q cite abbr time ul ol li dl dt dd a img figure figcaption table caption thead tbody tfoot ' +
23
+ 'tr th td colgroup col center font address details summary wbr').split(' '));
24
+ /** HTML elements written without children or an end tag. */
25
+ const HTML_VOID = new Set(['br', 'hr', 'img', 'meta', 'col', 'wbr']);
26
+ /** HTML attributes that hold a URL: only a `MarkupUrl` value is written. */
27
+ export const HTML_URL_ATTRIBUTES = new Set(('href src srcset action formaction poster cite data background xlink:href ping manifest ' +
28
+ 'codebase longdesc usemap lowsrc dynsrc').split(' '));
29
+ /** HTML attributes no output carries: event handlers are refused by prefix, these by name. */
30
+ const HTML_DENIED_ATTRIBUTES = new Set(['srcdoc', 'http-equiv', 'is', 'nonce']);
31
+ const urls = new WeakSet();
32
+ function makeUrl(value) {
33
+ if (value === undefined)
34
+ return null;
35
+ const url = Object.freeze({ kind: 'url', value });
36
+ urls.add(url);
37
+ return url;
38
+ }
39
+ /** A link URL through `safeHref` at `trust`, or `null` when the policy refuses it. */
40
+ export function linkUrl(value, trust) {
41
+ return makeUrl(safeHref(value, trust));
42
+ }
43
+ /** An image URL through `safeImageSrc` at `trust`, or `null` when the policy refuses it. */
44
+ export function imageUrl(value, trust) {
45
+ return makeUrl(safeImageSrc(value, trust));
46
+ }
47
+ /** True only for a URL `linkUrl` or `imageUrl` made (a hand-built object is not one). */
48
+ export function isMarkupUrl(value) {
49
+ return typeof value === 'object' && value !== null && urls.has(value);
50
+ }
51
+ /**
52
+ * Drop characters XML 1.0 cannot carry: C0 controls other than tab,
53
+ * newline and carriage return, U+FFFE and U+FFFF, and unpaired surrogates.
54
+ */
55
+ export function sanitizeMarkupChars(value) {
56
+ // Fast path: most text has nothing to drop.
57
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching the controls is the point.
58
+ if (!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff￾￿]/.test(value))
59
+ return value;
60
+ let out = '';
61
+ for (const character of value) {
62
+ const code = character.codePointAt(0) ?? 0;
63
+ if (code === 0x09 || code === 0x0a || code === 0x0d)
64
+ out += character;
65
+ else if (code < 0x20 || code === 0xfffe || code === 0xffff)
66
+ continue;
67
+ else if (code >= 0xd800 && code <= 0xdfff)
68
+ continue;
69
+ else
70
+ out += character;
71
+ }
72
+ return out;
73
+ }
74
+ export function escapeMarkupText(value) {
75
+ return sanitizeMarkupChars(value)
76
+ .replace(/&/g, '&amp;')
77
+ .replace(/</g, '&lt;')
78
+ .replace(/>/g, '&gt;');
79
+ }
80
+ export function escapeMarkupAttribute(value) {
81
+ return escapeMarkupText(value)
82
+ .replace(/"/g, '&quot;')
83
+ .replace(/'/g, '&apos;')
84
+ .replace(/\t/g, '&#9;')
85
+ .replace(/\n/g, '&#10;')
86
+ .replace(/\r/g, '&#13;');
87
+ }
88
+ export function text(value) {
89
+ return { kind: 'text', value: String(value) };
90
+ }
91
+ function pushChildren(out, children) {
92
+ // Iterative flattening, so a deeply nested child list cannot overflow the stack.
93
+ const stack = [children, 0];
94
+ while (stack.length > 0) {
95
+ const index = stack.pop();
96
+ const list = stack.pop();
97
+ if (index >= list.length)
98
+ continue;
99
+ stack.push(list, index + 1);
100
+ const child = list[index];
101
+ if (child === null || child === undefined || child === false)
102
+ continue;
103
+ if (Array.isArray(child))
104
+ stack.push(child, 0);
105
+ else if (typeof child === 'string' || typeof child === 'number')
106
+ out.push(text(child));
107
+ else
108
+ out.push(child);
109
+ }
110
+ }
111
+ function assertXmlName(name, what) {
112
+ if (typeof name !== 'string' || !XML_NAME.test(name)) {
113
+ throw new TypeError(`Invalid markup ${what} name: ${JSON.stringify(name)}`);
114
+ }
115
+ }
116
+ /** Build an element. String and number children become escaped text; nullish and `false` children are skipped. */
117
+ export function el(name, attributes = {}, ...children) {
118
+ assertXmlName(name, 'element');
119
+ const attrs = [];
120
+ for (const key of Object.keys(attributes)) {
121
+ const value = attributes[key];
122
+ if (value === undefined || value === null)
123
+ continue;
124
+ assertXmlName(key, 'attribute');
125
+ if (typeof value === 'number') {
126
+ if (!Number.isFinite(value))
127
+ throw new TypeError(`Attribute ${key} must be a finite number.`);
128
+ attrs.push([key, String(value)]);
129
+ }
130
+ else if (typeof value === 'object') {
131
+ if (!isMarkupUrl(value))
132
+ throw new TypeError(`Attribute ${key} holds an unchecked URL object.`);
133
+ attrs.push([key, value]);
134
+ }
135
+ else
136
+ attrs.push([key, value]);
137
+ }
138
+ const nodes = [];
139
+ pushChildren(nodes, children);
140
+ return { kind: 'element', name, attributes: attrs, children: nodes };
141
+ }
142
+ function walk(root, open, close, leaf) {
143
+ if (root.kind === 'text')
144
+ return leaf(root);
145
+ const parts = [open(root)];
146
+ const stack = [{ node: root, index: 0 }];
147
+ while (stack.length > 0) {
148
+ const frame = stack[stack.length - 1];
149
+ const child = frame.node.children[frame.index];
150
+ if (child === undefined) {
151
+ parts.push(close(frame.node));
152
+ stack.pop();
153
+ continue;
154
+ }
155
+ frame.index += 1;
156
+ if (child.kind === 'text')
157
+ parts.push(leaf(child));
158
+ else if (child.kind === 'element') {
159
+ parts.push(open(child));
160
+ stack.push({ node: child, index: 0 });
161
+ }
162
+ else
163
+ throw new TypeError('Unknown markup node.');
164
+ }
165
+ return parts.join('');
166
+ }
167
+ /** Serialize to XML: booleans become `1` and `0` (the Office convention); childless elements self-close. */
168
+ export function serializeXml(root, options = {}) {
169
+ const allow = options.allow;
170
+ const check = (name, what) => {
171
+ assertXmlName(name, what);
172
+ if (allow && !allow(name, what)) {
173
+ throw new TypeError(`Markup ${what} not allowed: ${JSON.stringify(name)}`);
174
+ }
175
+ };
176
+ const body = walk(root, (node) => {
177
+ // Re-check names, so a hand-built node object cannot smuggle markup.
178
+ check(node.name, 'element');
179
+ let out = `<${node.name}`;
180
+ for (const [key, value] of node.attributes) {
181
+ check(key, 'attribute');
182
+ const raw = typeof value === 'boolean' ? (value ? '1' : '0') : xmlValue(value);
183
+ out += ` ${key}="${escapeMarkupAttribute(raw)}"`;
184
+ }
185
+ return node.children.length === 0 ? `${out}/>` : `${out}>`;
186
+ }, (node) => (node.children.length === 0 ? '' : `</${node.name}>`), (node) => escapeMarkupText(String(node.value)));
187
+ return options.declaration === false
188
+ ? body
189
+ : `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${body}`;
190
+ }
191
+ function xmlValue(value) {
192
+ if (typeof value === 'string')
193
+ return value;
194
+ if (!isMarkupUrl(value))
195
+ throw new TypeError('Unchecked URL object in markup.');
196
+ return value.value;
197
+ }
198
+ function htmlAttribute(name, value) {
199
+ if (!HTML_ATTRIBUTE.test(name) || name.startsWith('on') || HTML_DENIED_ATTRIBUTES.has(name)) {
200
+ throw new TypeError(`HTML attribute not allowed: ${JSON.stringify(name)}`);
201
+ }
202
+ if (HTML_URL_ATTRIBUTES.has(name)) {
203
+ if (!isMarkupUrl(value)) {
204
+ throw new TypeError(`HTML attribute ${name} takes a URL from linkUrl or imageUrl.`);
205
+ }
206
+ return ` ${name}="${escapeMarkupAttribute(value.value)}"`;
207
+ }
208
+ if (typeof value === 'boolean')
209
+ return value ? ` ${name}` : '';
210
+ if (typeof value !== 'string')
211
+ throw new TypeError(`HTML attribute ${name} is not text.`);
212
+ return ` ${name}="${escapeMarkupAttribute(value)}"`;
213
+ }
214
+ /**
215
+ * Serialize to HTML: only `HTML_ELEMENTS`, no event handler or `srcdoc`
216
+ * attributes, URL attributes only from `linkUrl` or `imageUrl`, booleans as
217
+ * bare attributes, and void elements without an end tag.
218
+ */
219
+ export function serializeHtml(root, options = {}) {
220
+ const body = walk(root, (node) => {
221
+ if (!HTML_NAME.test(node.name) || !HTML_ELEMENTS.has(node.name)) {
222
+ throw new TypeError(`HTML element not allowed: ${JSON.stringify(node.name)}`);
223
+ }
224
+ if (HTML_VOID.has(node.name) && node.children.length > 0) {
225
+ throw new TypeError(`HTML element ${node.name} takes no children.`);
226
+ }
227
+ let out = `<${node.name}`;
228
+ for (const [key, value] of node.attributes)
229
+ out += htmlAttribute(key, value);
230
+ return `${out}>`;
231
+ }, (node) => (HTML_VOID.has(node.name) ? '' : `</${node.name}>`), (node) => escapeMarkupText(String(node.value)));
232
+ return options.doctype ? `<!doctype html>${body}` : body;
233
+ }