@craft-ts/component 0.7.0-beta.13 → 0.7.0-beta.16

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 (195) hide show
  1. package/package.json +7 -2
  2. package/src/index.d.ts +40 -40
  3. package/src/index.js +35 -35
  4. package/src/lib/a11y-control.d.ts +0 -1
  5. package/src/lib/a11y-control.js +0 -1
  6. package/src/lib/a11y.d.ts +4 -5
  7. package/src/lib/a11y.js +3 -4
  8. package/src/lib/ai/ai-context-menu.d.ts +24 -25
  9. package/src/lib/ai/ai-context-menu.js +2 -3
  10. package/src/lib/ai/ai-send-dialog.d.ts +277 -278
  11. package/src/lib/ai/ai-send-dialog.js +4 -5
  12. package/src/lib/ai/send-context-to-ai.d.ts +1 -2
  13. package/src/lib/ai/send-context-to-ai.js +4 -5
  14. package/src/lib/assert-defined-input.d.ts +1 -2
  15. package/src/lib/assert-defined-input.js +0 -1
  16. package/src/lib/bootstrap.d.ts +4 -3
  17. package/src/lib/bootstrap.js +6 -8
  18. package/src/lib/bridge.d.ts +9 -10
  19. package/src/lib/bridge.js +3 -4
  20. package/src/lib/catch-node.d.ts +35 -0
  21. package/src/lib/{block.js → catch-node.js} +9 -10
  22. package/src/lib/component.d.ts +23 -10
  23. package/src/lib/component.js +5 -6
  24. package/src/lib/composition.d.ts +1 -2
  25. package/src/lib/composition.js +2 -3
  26. package/src/lib/craft-defaults.d.ts +0 -1
  27. package/src/lib/craft-defaults.js +4 -5
  28. package/src/lib/craft-host-tokens.d.ts +0 -1
  29. package/src/lib/craft-host-tokens.js +0 -1
  30. package/src/lib/craft-router-outlet.d.ts +1 -2
  31. package/src/lib/craft-router-outlet.js +1 -2
  32. package/src/lib/css-vars.d.ts +0 -1
  33. package/src/lib/css-vars.js +0 -1
  34. package/src/lib/css-vars.type.d.ts +1 -2
  35. package/src/lib/css-vars.type.js +0 -1
  36. package/src/lib/{defer.d.ts → defer-node.d.ts} +4 -5
  37. package/src/lib/{defer.js → defer-node.js} +1 -2
  38. package/src/lib/directive.d.ts +1 -2
  39. package/src/lib/directive.js +1 -2
  40. package/src/lib/factory-runtime.d.ts +1 -2
  41. package/src/lib/factory-runtime.js +0 -1
  42. package/src/lib/{field-exception-block.d.ts → field-error-node.d.ts} +22 -23
  43. package/src/lib/{field-exception-block.js → field-error-node.js} +8 -9
  44. package/src/lib/for-node.d.ts +28 -0
  45. package/src/lib/{each.js → for-node.js} +4 -5
  46. package/src/lib/for-scheduling.d.ts +58 -0
  47. package/src/lib/{each-scheduling.js → for-scheduling.js} +14 -15
  48. package/src/lib/host-runtime.d.ts +0 -1
  49. package/src/lib/host-runtime.js +0 -1
  50. package/src/lib/hydrate.d.ts +2 -3
  51. package/src/lib/hydrate.js +57 -18
  52. package/src/lib/hyperscript.d.ts +1 -2
  53. package/src/lib/hyperscript.js +1 -2
  54. package/src/lib/if-node.d.ts +7 -0
  55. package/src/lib/{if-block.js → if-node.js} +2 -3
  56. package/src/lib/locator.d.ts +5 -6
  57. package/src/lib/locator.js +1 -2
  58. package/src/lib/{match-block.d.ts → match-node.d.ts} +8 -9
  59. package/src/lib/{match-block.js → match-node.js} +3 -4
  60. package/src/lib/{pending-block.d.ts → pending-node.d.ts} +28 -29
  61. package/src/lib/{pending-block.js → pending-node.js} +12 -13
  62. package/src/lib/project.d.ts +2 -3
  63. package/src/lib/project.js +2 -3
  64. package/src/lib/render/hydration-metadata.d.ts +0 -1
  65. package/src/lib/render/hydration-metadata.js +0 -1
  66. package/src/lib/render/hydration.d.ts +0 -1
  67. package/src/lib/render/hydration.js +0 -1
  68. package/src/lib/render/interpreter.d.ts +9 -8
  69. package/src/lib/render/interpreter.js +156 -81
  70. package/src/lib/render/server-style-collector.d.ts +0 -1
  71. package/src/lib/render/server-style-collector.js +0 -1
  72. package/src/lib/render/ssr-coordinator.d.ts +4 -2
  73. package/src/lib/render/ssr-coordinator.js +14 -3
  74. package/src/lib/render/string-dom.d.ts +0 -1
  75. package/src/lib/render/string-dom.js +48 -7
  76. package/src/lib/render/style-registry.d.ts +3 -2
  77. package/src/lib/render/style-registry.js +3 -2
  78. package/src/lib/render/style-scope.d.ts +0 -1
  79. package/src/lib/render/style-scope.js +0 -1
  80. package/src/lib/render/vnode.d.ts +117 -54
  81. package/src/lib/render/vnode.js +26 -27
  82. package/src/lib/security.d.ts +54 -0
  83. package/src/lib/security.js +502 -0
  84. package/src/lib/server-render.d.ts +12 -3
  85. package/src/lib/server-render.js +76 -17
  86. package/src/lib/start.d.ts +2 -3
  87. package/src/lib/start.js +3 -3
  88. package/src/lib/template-contract.d.ts +22 -23
  89. package/src/lib/template-contract.js +0 -1
  90. package/src/lib/template.d.ts +2 -3
  91. package/src/lib/template.js +2 -3
  92. package/src/lib/testing.d.ts +3 -4
  93. package/src/lib/testing.js +5 -6
  94. package/src/lib/types.d.ts +40 -28
  95. package/src/lib/types.js +3 -4
  96. package/testing/public-api.d.ts +0 -1
  97. package/testing/public-api.js +0 -1
  98. package/src/index.d.ts.map +0 -1
  99. package/src/index.js.map +0 -1
  100. package/src/lib/a11y-control.d.ts.map +0 -1
  101. package/src/lib/a11y-control.js.map +0 -1
  102. package/src/lib/a11y.d.ts.map +0 -1
  103. package/src/lib/a11y.js.map +0 -1
  104. package/src/lib/ai/ai-context-menu.d.ts.map +0 -1
  105. package/src/lib/ai/ai-context-menu.js.map +0 -1
  106. package/src/lib/ai/ai-send-dialog.d.ts.map +0 -1
  107. package/src/lib/ai/ai-send-dialog.js.map +0 -1
  108. package/src/lib/ai/send-context-to-ai.d.ts.map +0 -1
  109. package/src/lib/ai/send-context-to-ai.js.map +0 -1
  110. package/src/lib/assert-defined-input.d.ts.map +0 -1
  111. package/src/lib/assert-defined-input.js.map +0 -1
  112. package/src/lib/block.d.ts +0 -36
  113. package/src/lib/block.d.ts.map +0 -1
  114. package/src/lib/block.js.map +0 -1
  115. package/src/lib/bootstrap.d.ts.map +0 -1
  116. package/src/lib/bootstrap.js.map +0 -1
  117. package/src/lib/bridge.d.ts.map +0 -1
  118. package/src/lib/bridge.js.map +0 -1
  119. package/src/lib/component.d.ts.map +0 -1
  120. package/src/lib/component.js.map +0 -1
  121. package/src/lib/composition.d.ts.map +0 -1
  122. package/src/lib/composition.js.map +0 -1
  123. package/src/lib/craft-defaults.d.ts.map +0 -1
  124. package/src/lib/craft-defaults.js.map +0 -1
  125. package/src/lib/craft-host-tokens.d.ts.map +0 -1
  126. package/src/lib/craft-host-tokens.js.map +0 -1
  127. package/src/lib/craft-router-outlet.d.ts.map +0 -1
  128. package/src/lib/craft-router-outlet.js.map +0 -1
  129. package/src/lib/css-vars.d.ts.map +0 -1
  130. package/src/lib/css-vars.js.map +0 -1
  131. package/src/lib/css-vars.type.d.ts.map +0 -1
  132. package/src/lib/css-vars.type.js.map +0 -1
  133. package/src/lib/defer.d.ts.map +0 -1
  134. package/src/lib/defer.js.map +0 -1
  135. package/src/lib/directive.d.ts.map +0 -1
  136. package/src/lib/directive.js.map +0 -1
  137. package/src/lib/each-scheduling.d.ts +0 -59
  138. package/src/lib/each-scheduling.d.ts.map +0 -1
  139. package/src/lib/each-scheduling.js.map +0 -1
  140. package/src/lib/each.d.ts +0 -29
  141. package/src/lib/each.d.ts.map +0 -1
  142. package/src/lib/each.js.map +0 -1
  143. package/src/lib/factory-runtime.d.ts.map +0 -1
  144. package/src/lib/factory-runtime.js.map +0 -1
  145. package/src/lib/field-exception-block.d.ts.map +0 -1
  146. package/src/lib/field-exception-block.js.map +0 -1
  147. package/src/lib/host-runtime.d.ts.map +0 -1
  148. package/src/lib/host-runtime.js.map +0 -1
  149. package/src/lib/hydrate.d.ts.map +0 -1
  150. package/src/lib/hydrate.js.map +0 -1
  151. package/src/lib/hyperscript.d.ts.map +0 -1
  152. package/src/lib/hyperscript.js.map +0 -1
  153. package/src/lib/if-block.d.ts +0 -8
  154. package/src/lib/if-block.d.ts.map +0 -1
  155. package/src/lib/if-block.js.map +0 -1
  156. package/src/lib/locator.d.ts.map +0 -1
  157. package/src/lib/locator.js.map +0 -1
  158. package/src/lib/match-block.d.ts.map +0 -1
  159. package/src/lib/match-block.js.map +0 -1
  160. package/src/lib/pending-block.d.ts.map +0 -1
  161. package/src/lib/pending-block.js.map +0 -1
  162. package/src/lib/project.d.ts.map +0 -1
  163. package/src/lib/project.js.map +0 -1
  164. package/src/lib/render/hydration-metadata.d.ts.map +0 -1
  165. package/src/lib/render/hydration-metadata.js.map +0 -1
  166. package/src/lib/render/hydration.d.ts.map +0 -1
  167. package/src/lib/render/hydration.js.map +0 -1
  168. package/src/lib/render/interpreter.d.ts.map +0 -1
  169. package/src/lib/render/interpreter.js.map +0 -1
  170. package/src/lib/render/server-style-collector.d.ts.map +0 -1
  171. package/src/lib/render/server-style-collector.js.map +0 -1
  172. package/src/lib/render/ssr-coordinator.d.ts.map +0 -1
  173. package/src/lib/render/ssr-coordinator.js.map +0 -1
  174. package/src/lib/render/string-dom.d.ts.map +0 -1
  175. package/src/lib/render/string-dom.js.map +0 -1
  176. package/src/lib/render/style-registry.d.ts.map +0 -1
  177. package/src/lib/render/style-registry.js.map +0 -1
  178. package/src/lib/render/style-scope.d.ts.map +0 -1
  179. package/src/lib/render/style-scope.js.map +0 -1
  180. package/src/lib/render/vnode.d.ts.map +0 -1
  181. package/src/lib/render/vnode.js.map +0 -1
  182. package/src/lib/server-render.d.ts.map +0 -1
  183. package/src/lib/server-render.js.map +0 -1
  184. package/src/lib/start.d.ts.map +0 -1
  185. package/src/lib/start.js.map +0 -1
  186. package/src/lib/template-contract.d.ts.map +0 -1
  187. package/src/lib/template-contract.js.map +0 -1
  188. package/src/lib/template.d.ts.map +0 -1
  189. package/src/lib/template.js.map +0 -1
  190. package/src/lib/testing.d.ts.map +0 -1
  191. package/src/lib/testing.js.map +0 -1
  192. package/src/lib/types.d.ts.map +0 -1
  193. package/src/lib/types.js.map +0 -1
  194. package/testing/public-api.d.ts.map +0 -1
  195. package/testing/public-api.js.map +0 -1
@@ -1,9 +1,9 @@
1
1
  import { CRAFT_NODE_DIRECTIVE, isCraftNodeDirective } from '@craft-ts/core';
2
- import { isCraftDirective } from '../types';
3
- import { CATCH_BLOCK_DIRECTIVE, } from '../block';
4
- import { PENDING_BLOCK_DIRECTIVE, } from '../pending-block';
5
- import { FIELD_EXCEPTION_BLOCK_DIRECTIVE, } from '../field-exception-block';
6
- import { SCHEDULE_EACH_DIRECTIVE, isScheduleEachDirective, } from '../each-scheduling';
2
+ import { isCraftDirective } from '../types.js';
3
+ import { CATCH_NODE_DIRECTIVE, } from '../catch-node.js';
4
+ import { PENDING_NODE_DIRECTIVE, } from '../pending-node.js';
5
+ import { FIELD_ERROR_NODE_DIRECTIVE, } from '../field-error-node.js';
6
+ import { SCHEDULE_FOR_DIRECTIVE, isScheduleForDirective, } from '../for-scheduling.js';
7
7
  export const CRAFT_NODE_CSS_VARS = Symbol('craft-node-css-vars');
8
8
  export const CRAFT_NODE_DIRECTIVES = Symbol('craft-node-directives');
9
9
  let activeCraftRenderContext;
@@ -30,15 +30,15 @@ export function isCraftNode(value) {
30
30
  value.kind === 'component' ||
31
31
  value.kind === 'angular' ||
32
32
  value.kind === 'directive' ||
33
- value.kind === 'each' ||
33
+ value.kind === 'for' ||
34
34
  value.kind === 'if' ||
35
35
  value.kind === 'heading' ||
36
36
  value.kind === 'heading-section' ||
37
37
  value.kind === 'defer' ||
38
- value.kind === 'catch-block' ||
39
- value.kind === 'pending-block' ||
40
- value.kind === 'field-exception-block' ||
41
- value.kind === 'match-block' ||
38
+ value.kind === 'catch' ||
39
+ value.kind === 'pending' ||
40
+ value.kind === 'field-error' ||
41
+ value.kind === 'match' ||
42
42
  value.kind === 'projection' ||
43
43
  value.kind === 'template');
44
44
  }
@@ -49,13 +49,13 @@ function withPipe(node) {
49
49
  };
50
50
  }
51
51
  export function pipeCraftNode(node, directive) {
52
- if (isScheduleEachDirective(directive)) {
53
- if (node.kind !== 'each') {
54
- throw new TypeError('scheduleEach(...) can only be piped onto an each(...) block.');
52
+ if (isScheduleForDirective(directive)) {
53
+ if (node.kind !== 'for') {
54
+ throw new TypeError('scheduleFor(...) can only be piped onto a forNode(...) block.');
55
55
  }
56
56
  return withPipe({
57
57
  ...node,
58
- schedule: directive[SCHEDULE_EACH_DIRECTIVE],
58
+ schedule: directive[SCHEDULE_FOR_DIRECTIVE],
59
59
  });
60
60
  }
61
61
  if (isCraftNodeDirective(directive)) {
@@ -75,21 +75,21 @@ export function pipeCraftNode(node, directive) {
75
75
  // Angular directive definitions (`ɵdir`) are gone.
76
76
  return node;
77
77
  }
78
- const catchBlockDefinition = directive[CATCH_BLOCK_DIRECTIVE];
79
- if (catchBlockDefinition) {
80
- const definition = catchBlockDefinition;
78
+ const catchNodeDefinition = directive[CATCH_NODE_DIRECTIVE];
79
+ if (catchNodeDefinition) {
80
+ const definition = catchNodeDefinition;
81
81
  return {
82
- kind: 'catch-block',
82
+ kind: 'catch',
83
83
  source: node,
84
84
  handlers: definition.handlers,
85
85
  position: definition.position,
86
86
  };
87
87
  }
88
- const pendingBlockDefinition = directive[PENDING_BLOCK_DIRECTIVE];
89
- if (pendingBlockDefinition) {
90
- const definition = pendingBlockDefinition;
88
+ const pendingNodeDefinition = directive[PENDING_NODE_DIRECTIVE];
89
+ if (pendingNodeDefinition) {
90
+ const definition = pendingNodeDefinition;
91
91
  return withPipe({
92
- kind: 'pending-block',
92
+ kind: 'pending',
93
93
  source: node,
94
94
  handlers: definition.handlers,
95
95
  fallback: definition.fallback,
@@ -98,11 +98,11 @@ export function pipeCraftNode(node, directive) {
98
98
  ssr: definition.ssr,
99
99
  });
100
100
  }
101
- const fieldExceptionBlockDefinition = directive[FIELD_EXCEPTION_BLOCK_DIRECTIVE];
102
- if (fieldExceptionBlockDefinition) {
103
- const definition = fieldExceptionBlockDefinition;
101
+ const fieldErrorNodeDefinition = directive[FIELD_ERROR_NODE_DIRECTIVE];
102
+ if (fieldErrorNodeDefinition) {
103
+ const definition = fieldErrorNodeDefinition;
104
104
  return withPipe({
105
- kind: 'field-exception-block',
105
+ kind: 'field-error',
106
106
  source: node,
107
107
  handlers: definition.handlers,
108
108
  options: definition.options,
@@ -290,4 +290,3 @@ export function normalizeChildren(children) {
290
290
  visit(children);
291
291
  return result;
292
292
  }
293
- //# sourceMappingURL=vnode.js.map
@@ -0,0 +1,54 @@
1
+ declare const CRAFT_SAFE_HTML: unique symbol;
2
+ declare const CRAFT_UNSAFE_HTML: unique symbol;
3
+ export type CraftSafeHtml = Readonly<{
4
+ readonly value: string;
5
+ readonly [CRAFT_SAFE_HTML]: true;
6
+ }>;
7
+ export type CraftUnsafeHtml = Readonly<{
8
+ readonly value: string;
9
+ readonly [CRAFT_UNSAFE_HTML]: true;
10
+ }>;
11
+ export declare class CraftDomSecurityError extends Error {
12
+ readonly code: string;
13
+ constructor(code: string, message: string);
14
+ }
15
+ export type CraftUrlOptions = Readonly<{
16
+ /**
17
+ * Origins a resource URL may point to, e.g. `https://cdn.example.com`.
18
+ * Empty means same-document URLs only.
19
+ */
20
+ readonly allowedOrigins?: readonly string[];
21
+ /** Schemes allowed for navigation URLs beyond http(s) and relative URLs. */
22
+ readonly allowedSchemes?: readonly string[];
23
+ }>;
24
+ /** Allows relative URLs and safe schemes, never scriptable ones. */
25
+ export declare function safeUrl(value: unknown, options?: CraftUrlOptions): string;
26
+ /**
27
+ * URL helper for resource-bearing attributes. Absolute URLs must match
28
+ * `options.allowedOrigins`; without one, only same-document URLs pass.
29
+ */
30
+ export declare function safeResourceUrl(value: unknown, options?: CraftUrlOptions): string;
31
+ /** Validates a `srcset`/`ping` style list of URLs, keeping its descriptors. */
32
+ export declare function safeUrlList(value: unknown, resource: boolean, options?: CraftUrlOptions): string;
33
+ export type CraftSanitizeHtmlOptions = CraftUrlOptions;
34
+ /**
35
+ * Sanitizes a presentation fragment against a fixed allowlist of elements and
36
+ * attributes. Unknown elements are dropped, their text is kept and re-encoded.
37
+ *
38
+ * This covers formatted text, links, lists, tables and images. It is not a
39
+ * general-purpose sanitizer: rich user-generated documents belong to a
40
+ * project-approved sanitizer (DOMPurify in the browser, a parser-backed
41
+ * pipeline on the server) behind an audited `unsafeHtml` exception.
42
+ */
43
+ export declare function sanitizedHtml(value: unknown, options?: CraftSanitizeHtmlOptions): CraftSafeHtml;
44
+ /** Deliberate escape hatch. Prefer sanitizedHtml and record an exception. */
45
+ export declare function unsafeHtml(value: string): CraftUnsafeHtml;
46
+ export declare function isCraftSafeHtml(value: unknown): value is CraftSafeHtml;
47
+ export declare function isCraftUnsafeHtml(value: unknown): value is CraftUnsafeHtml;
48
+ export declare function isCraftUrlAttribute(name: string): boolean;
49
+ export declare function isCraftResourceUrlAttribute(name: string): boolean;
50
+ export declare function isCraftMultiUrlAttribute(name: string): boolean;
51
+ export declare function isCraftBlockedElement(name: string): boolean;
52
+ export declare function assertCraftTagName(tag: string): string;
53
+ export declare function assertCraftAttributeName(name: string): string;
54
+ export {};
@@ -0,0 +1,502 @@
1
+ const CRAFT_SAFE_HTML = Symbol('craft-safe-html');
2
+ const CRAFT_UNSAFE_HTML = Symbol('craft-unsafe-html');
3
+ export class CraftDomSecurityError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(`${code}: ${message}`);
7
+ this.code = code;
8
+ this.name = 'CraftDomSecurityError';
9
+ }
10
+ }
11
+ /**
12
+ * Attributes whose value is fetched or navigated to by the browser. `srcset`
13
+ * and `ping` hold several URLs, `xlink:href` is the SVG spelling and still
14
+ * accepts scriptable schemes in some engines.
15
+ */
16
+ const URL_ATTRIBUTES = new Set([
17
+ 'href',
18
+ 'xlink:href',
19
+ 'src',
20
+ 'srcset',
21
+ 'action',
22
+ 'formaction',
23
+ 'poster',
24
+ 'ping',
25
+ 'data',
26
+ 'background',
27
+ 'cite',
28
+ 'longdesc',
29
+ 'manifest',
30
+ ]);
31
+ /**
32
+ * URLs the browser loads as code or as an embedded document. They deserve the
33
+ * stricter treatment: same-document by default, cross-origin only against an
34
+ * explicit allowlist.
35
+ */
36
+ const RESOURCE_URL_ATTRIBUTES = new Set([
37
+ 'src',
38
+ 'srcset',
39
+ 'poster',
40
+ 'data',
41
+ 'background',
42
+ 'manifest',
43
+ ]);
44
+ const MULTI_URL_ATTRIBUTES = new Set(['srcset', 'ping']);
45
+ /** Elements that rewrite the document base or navigate on their own. */
46
+ const BLOCKED_ELEMENTS = new Set(['base', 'meta']);
47
+ const DEFAULT_NAVIGATION_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'];
48
+ const PLACEHOLDER_ORIGIN = 'https://craft.invalid';
49
+ /** Allows relative URLs and safe schemes, never scriptable ones. */
50
+ export function safeUrl(value, options = {}) {
51
+ return sanitizeUrl(value, false, options);
52
+ }
53
+ /**
54
+ * URL helper for resource-bearing attributes. Absolute URLs must match
55
+ * `options.allowedOrigins`; without one, only same-document URLs pass.
56
+ */
57
+ export function safeResourceUrl(value, options = {}) {
58
+ return sanitizeUrl(value, true, options);
59
+ }
60
+ function sanitizeUrl(value, resource, options) {
61
+ if (value === null || value === undefined || value === '')
62
+ return '';
63
+ if (typeof value !== 'string') {
64
+ throw new CraftDomSecurityError('CRAFT_DOM_URL_INVALID', 'DOM URLs must be strings.');
65
+ }
66
+ const normalized = value.trim();
67
+ // A protocol-relative URL inherits the current scheme and leaves the origin,
68
+ // which is never what a template author means by "a link".
69
+ if (normalized.startsWith('//')) {
70
+ throw new CraftDomSecurityError('CRAFT_DOM_URL_BLOCKED', 'Protocol-relative URLs are not allowed; use an absolute https URL.');
71
+ }
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(normalized, PLACEHOLDER_ORIGIN);
75
+ }
76
+ catch {
77
+ throw new CraftDomSecurityError('CRAFT_DOM_URL_INVALID', 'Invalid URL.');
78
+ }
79
+ const relative = parsed.origin === PLACEHOLDER_ORIGIN && !/^[a-z][a-z0-9+.-]*:/i.test(normalized);
80
+ const allowedSchemes = resource
81
+ ? ['http:', 'https:']
82
+ : (options.allowedSchemes ?? DEFAULT_NAVIGATION_SCHEMES);
83
+ if (!relative && !allowedSchemes.includes(parsed.protocol)) {
84
+ throw new CraftDomSecurityError('CRAFT_DOM_URL_BLOCKED', `The ${parsed.protocol} URL scheme is not allowed.`);
85
+ }
86
+ if (resource && !relative) {
87
+ const allowedOrigins = options.allowedOrigins ?? [];
88
+ if (!allowedOrigins.includes(parsed.origin)) {
89
+ throw new CraftDomSecurityError('CRAFT_DOM_RESOURCE_ORIGIN_BLOCKED', `Resource origin ${parsed.origin} is not in the allowed origins.`);
90
+ }
91
+ }
92
+ return normalized;
93
+ }
94
+ /** Validates a `srcset`/`ping` style list of URLs, keeping its descriptors. */
95
+ export function safeUrlList(value, resource, options = {}) {
96
+ if (typeof value !== 'string') {
97
+ throw new CraftDomSecurityError('CRAFT_DOM_URL_INVALID', 'DOM URLs must be strings.');
98
+ }
99
+ return value
100
+ .split(',')
101
+ .map((candidate) => {
102
+ const trimmed = candidate.trim();
103
+ if (trimmed === '')
104
+ return '';
105
+ const [url, ...descriptors] = trimmed.split(/\s+/);
106
+ const safe = resource
107
+ ? safeResourceUrl(url, options)
108
+ : safeUrl(url, options);
109
+ return [safe, ...descriptors].join(' ');
110
+ })
111
+ .filter((candidate) => candidate !== '')
112
+ .join(', ');
113
+ }
114
+ /* ------------------------------------------------------------------ *
115
+ * HTML sanitizer
116
+ *
117
+ * The sanitizer tokenizes the input and rebuilds the output from the tokens
118
+ * it recognises. It never edits markup in place: removing a pattern from a
119
+ * string can assemble a new tag out of its neighbours, which is how naive
120
+ * filters turn `<scr<script>ipt>` into a live `<script>`.
121
+ * ------------------------------------------------------------------ */
122
+ const ALLOWED_ELEMENTS = new Map([
123
+ ['a', new Set(['href', 'target', 'rel', 'title'])],
124
+ ['abbr', new Set(['title'])],
125
+ ['address', new Set()],
126
+ ['article', new Set()],
127
+ ['aside', new Set()],
128
+ ['b', new Set()],
129
+ ['blockquote', new Set(['cite'])],
130
+ ['br', new Set()],
131
+ ['caption', new Set()],
132
+ ['cite', new Set()],
133
+ ['code', new Set()],
134
+ ['col', new Set(['span'])],
135
+ ['colgroup', new Set(['span'])],
136
+ ['dd', new Set()],
137
+ ['del', new Set(['datetime'])],
138
+ ['details', new Set(['open'])],
139
+ ['div', new Set()],
140
+ ['dl', new Set()],
141
+ ['dt', new Set()],
142
+ ['em', new Set()],
143
+ ['figcaption', new Set()],
144
+ ['figure', new Set()],
145
+ ['footer', new Set()],
146
+ ['h1', new Set()],
147
+ ['h2', new Set()],
148
+ ['h3', new Set()],
149
+ ['h4', new Set()],
150
+ ['h5', new Set()],
151
+ ['h6', new Set()],
152
+ ['header', new Set()],
153
+ ['hr', new Set()],
154
+ ['i', new Set()],
155
+ ['img', new Set(['src', 'alt', 'width', 'height', 'loading', 'title'])],
156
+ ['ins', new Set(['datetime'])],
157
+ ['kbd', new Set()],
158
+ ['li', new Set(['value'])],
159
+ ['main', new Set()],
160
+ ['mark', new Set()],
161
+ ['nav', new Set()],
162
+ ['ol', new Set(['start', 'reversed', 'type'])],
163
+ ['p', new Set()],
164
+ ['pre', new Set()],
165
+ ['q', new Set(['cite'])],
166
+ ['s', new Set()],
167
+ ['samp', new Set()],
168
+ ['section', new Set()],
169
+ ['small', new Set()],
170
+ ['span', new Set()],
171
+ ['strong', new Set()],
172
+ ['sub', new Set()],
173
+ ['summary', new Set()],
174
+ ['sup', new Set()],
175
+ ['table', new Set()],
176
+ ['tbody', new Set()],
177
+ ['td', new Set(['colspan', 'rowspan', 'headers'])],
178
+ ['tfoot', new Set()],
179
+ ['th', new Set(['colspan', 'rowspan', 'scope', 'headers'])],
180
+ ['thead', new Set()],
181
+ ['time', new Set(['datetime'])],
182
+ ['tr', new Set()],
183
+ ['u', new Set()],
184
+ ['ul', new Set()],
185
+ ['var', new Set()],
186
+ ['wbr', new Set()],
187
+ ]);
188
+ /**
189
+ * Attributes accepted on every allowed element. `id` and `name` are absent on
190
+ * purpose: they let injected markup shadow `document.<name>` properties.
191
+ */
192
+ const GLOBAL_ATTRIBUTES = new Set(['class', 'dir', 'lang', 'role']);
193
+ const VOID_ELEMENTS = new Set([
194
+ 'br',
195
+ 'col',
196
+ 'hr',
197
+ 'img',
198
+ 'wbr',
199
+ ]);
200
+ /** Elements whose text content is markup or code, dropped with their content. */
201
+ const RAW_TEXT_ELEMENTS = new Set([
202
+ 'script',
203
+ 'style',
204
+ 'iframe',
205
+ 'noscript',
206
+ 'noembed',
207
+ 'object',
208
+ 'embed',
209
+ 'template',
210
+ 'title',
211
+ 'textarea',
212
+ 'svg',
213
+ 'math',
214
+ ]);
215
+ /**
216
+ * Sanitizes a presentation fragment against a fixed allowlist of elements and
217
+ * attributes. Unknown elements are dropped, their text is kept and re-encoded.
218
+ *
219
+ * This covers formatted text, links, lists, tables and images. It is not a
220
+ * general-purpose sanitizer: rich user-generated documents belong to a
221
+ * project-approved sanitizer (DOMPurify in the browser, a parser-backed
222
+ * pipeline on the server) behind an audited `unsafeHtml` exception.
223
+ */
224
+ export function sanitizedHtml(value, options = {}) {
225
+ if (typeof value !== 'string') {
226
+ throw new CraftDomSecurityError('CRAFT_DOM_HTML_INVALID', 'HTML must be a string.');
227
+ }
228
+ const output = [];
229
+ const open = [];
230
+ let index = 0;
231
+ while (index < value.length) {
232
+ const next = value.indexOf('<', index);
233
+ if (next === -1) {
234
+ output.push(escapeHtmlText(value.slice(index)));
235
+ break;
236
+ }
237
+ if (next > index)
238
+ output.push(escapeHtmlText(value.slice(index, next)));
239
+ if (value.startsWith('<!--', next)) {
240
+ const end = value.indexOf('-->', next + 4);
241
+ index = end === -1 ? value.length : end + 3;
242
+ continue;
243
+ }
244
+ if (value.startsWith('<!', next) || value.startsWith('<?', next)) {
245
+ const end = value.indexOf('>', next + 2);
246
+ index = end === -1 ? value.length : end + 1;
247
+ continue;
248
+ }
249
+ const tag = readTag(value, next);
250
+ if (!tag) {
251
+ // A lone `<` is text, not the start of a tag.
252
+ output.push(escapeHtmlText('<'));
253
+ index = next + 1;
254
+ continue;
255
+ }
256
+ index = tag.end;
257
+ if (RAW_TEXT_ELEMENTS.has(tag.name)) {
258
+ if (!tag.closing && !tag.selfClosing) {
259
+ index = skipRawTextContent(value, tag.end, tag.name);
260
+ }
261
+ continue;
262
+ }
263
+ if (BLOCKED_ELEMENTS.has(tag.name))
264
+ continue;
265
+ const allowedAttributes = ALLOWED_ELEMENTS.get(tag.name);
266
+ if (!allowedAttributes)
267
+ continue;
268
+ if (tag.closing) {
269
+ const position = open.lastIndexOf(tag.name);
270
+ if (position === -1)
271
+ continue;
272
+ while (open.length > position) {
273
+ output.push(`</${open.pop()}>`);
274
+ }
275
+ continue;
276
+ }
277
+ const attributes = renderAttributes(tag.name, tag.attributes, allowedAttributes, options);
278
+ if (VOID_ELEMENTS.has(tag.name)) {
279
+ output.push(`<${tag.name}${attributes}>`);
280
+ continue;
281
+ }
282
+ output.push(`<${tag.name}${attributes}>`);
283
+ if (!tag.selfClosing)
284
+ open.push(tag.name);
285
+ else
286
+ output.push(`</${tag.name}>`);
287
+ }
288
+ while (open.length > 0)
289
+ output.push(`</${open.pop()}>`);
290
+ return Object.freeze({ value: output.join(''), [CRAFT_SAFE_HTML]: true });
291
+ }
292
+ function readTag(input, start) {
293
+ let index = start + 1;
294
+ const closing = input[index] === '/';
295
+ if (closing)
296
+ index += 1;
297
+ const nameStart = index;
298
+ while (index < input.length && /[A-Za-z0-9:_-]/.test(input[index]))
299
+ index += 1;
300
+ const name = input.slice(nameStart, index).toLowerCase();
301
+ if (name === '' || !/^[a-z]/.test(name))
302
+ return undefined;
303
+ const attributes = [];
304
+ let selfClosing = false;
305
+ while (index < input.length) {
306
+ while (index < input.length && /[\s/]/.test(input[index])) {
307
+ if (input[index] === '/' && input[index + 1] === '>')
308
+ selfClosing = true;
309
+ index += 1;
310
+ }
311
+ if (index >= input.length)
312
+ break;
313
+ if (input[index] === '>') {
314
+ index += 1;
315
+ break;
316
+ }
317
+ const attributeStart = index;
318
+ while (index < input.length && !/[\s/>=]/.test(input[index]))
319
+ index += 1;
320
+ const attributeName = input.slice(attributeStart, index).toLowerCase();
321
+ let attributeValue = '';
322
+ while (index < input.length && /\s/.test(input[index]))
323
+ index += 1;
324
+ if (input[index] === '=') {
325
+ index += 1;
326
+ while (index < input.length && /\s/.test(input[index]))
327
+ index += 1;
328
+ const quote = input[index];
329
+ if (quote === '"' || quote === "'") {
330
+ index += 1;
331
+ const end = input.indexOf(quote, index);
332
+ attributeValue = input.slice(index, end === -1 ? input.length : end);
333
+ index = end === -1 ? input.length : end + 1;
334
+ }
335
+ else {
336
+ const valueStart = index;
337
+ while (index < input.length && !/[\s>]/.test(input[index]))
338
+ index += 1;
339
+ attributeValue = input.slice(valueStart, index);
340
+ }
341
+ }
342
+ if (attributeName !== '')
343
+ attributes.push([attributeName, attributeValue]);
344
+ }
345
+ return { name, closing, selfClosing, attributes, end: index };
346
+ }
347
+ function skipRawTextContent(input, from, name) {
348
+ const pattern = new RegExp(`<\\s*/\\s*${name}[^>]*>`, 'i');
349
+ const rest = input.slice(from);
350
+ const match = pattern.exec(rest);
351
+ return match ? from + match.index + match[0].length : input.length;
352
+ }
353
+ function renderAttributes(element, attributes, allowed, options) {
354
+ const rendered = [];
355
+ let hasBlankTarget = false;
356
+ let relation;
357
+ for (const [name, rawValue] of attributes) {
358
+ const isAria = name.startsWith('aria-');
359
+ const isData = name.startsWith('data-');
360
+ if (!allowed.has(name) && !GLOBAL_ATTRIBUTES.has(name) && !isAria && !isData) {
361
+ continue;
362
+ }
363
+ // Entities are decoded before validation: a browser reads
364
+ // `jav&#x09;ascript:` as `javascript:`, and so must the check.
365
+ const value = decodeHtmlEntities(rawValue);
366
+ if (URL_ATTRIBUTES.has(name)) {
367
+ const resource = RESOURCE_URL_ATTRIBUTES.has(name);
368
+ try {
369
+ const safe = MULTI_URL_ATTRIBUTES.has(name)
370
+ ? safeUrlList(value, resource, options)
371
+ : resource
372
+ ? safeResourceUrl(value, options)
373
+ : safeUrl(value, options);
374
+ if (safe === '')
375
+ continue;
376
+ rendered.push(` ${name}="${escapeHtmlAttribute(safe)}"`);
377
+ }
378
+ catch {
379
+ // A rejected URL drops the attribute; the element itself stays.
380
+ continue;
381
+ }
382
+ continue;
383
+ }
384
+ if (name === 'target') {
385
+ if (value !== '_blank' && value !== '_self')
386
+ continue;
387
+ hasBlankTarget = value === '_blank';
388
+ rendered.push(` target="${escapeHtmlAttribute(value)}"`);
389
+ continue;
390
+ }
391
+ if (name === 'rel') {
392
+ relation = value;
393
+ continue;
394
+ }
395
+ rendered.push(` ${name}="${escapeHtmlAttribute(value)}"`);
396
+ }
397
+ if (element === 'a' && hasBlankTarget) {
398
+ const parts = new Set((relation ?? '').split(/\s+/).filter(Boolean));
399
+ parts.add('noopener');
400
+ parts.add('noreferrer');
401
+ relation = [...parts].join(' ');
402
+ }
403
+ if (relation)
404
+ rendered.push(` rel="${escapeHtmlAttribute(relation)}"`);
405
+ return rendered.join('');
406
+ }
407
+ const NAMED_ENTITIES = {
408
+ amp: '&',
409
+ lt: '<',
410
+ gt: '>',
411
+ quot: '"',
412
+ apos: "'",
413
+ nbsp: ' ',
414
+ tab: '\t',
415
+ newline: '\n',
416
+ colon: ':',
417
+ sol: '/',
418
+ lpar: '(',
419
+ rpar: ')',
420
+ };
421
+ function decodeHtmlEntities(value) {
422
+ return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);?/gi, (match, entity) => {
423
+ const lower = entity.toLowerCase();
424
+ if (lower.startsWith('#x')) {
425
+ const code = Number.parseInt(lower.slice(2), 16);
426
+ return Number.isNaN(code) ? match : safeFromCodePoint(code, match);
427
+ }
428
+ if (lower.startsWith('#')) {
429
+ const code = Number.parseInt(lower.slice(1), 10);
430
+ return Number.isNaN(code) ? match : safeFromCodePoint(code, match);
431
+ }
432
+ return NAMED_ENTITIES[lower] ?? match;
433
+ });
434
+ }
435
+ function safeFromCodePoint(code, fallback) {
436
+ if (code < 0 || code > 0x10ffff)
437
+ return fallback;
438
+ try {
439
+ return String.fromCodePoint(code);
440
+ }
441
+ catch {
442
+ return fallback;
443
+ }
444
+ }
445
+ function escapeHtmlText(value) {
446
+ return decodeHtmlEntities(value)
447
+ .replaceAll('&', '&amp;')
448
+ .replaceAll('<', '&lt;')
449
+ .replaceAll('>', '&gt;');
450
+ }
451
+ function escapeHtmlAttribute(value) {
452
+ return value
453
+ .replaceAll('&', '&amp;')
454
+ .replaceAll('<', '&lt;')
455
+ .replaceAll('>', '&gt;')
456
+ .replaceAll('"', '&quot;')
457
+ .replaceAll("'", '&#39;');
458
+ }
459
+ /** Deliberate escape hatch. Prefer sanitizedHtml and record an exception. */
460
+ export function unsafeHtml(value) {
461
+ return Object.freeze({ value, [CRAFT_UNSAFE_HTML]: true });
462
+ }
463
+ export function isCraftSafeHtml(value) {
464
+ return isMarked(value, CRAFT_SAFE_HTML);
465
+ }
466
+ export function isCraftUnsafeHtml(value) {
467
+ return isMarked(value, CRAFT_UNSAFE_HTML);
468
+ }
469
+ export function isCraftUrlAttribute(name) {
470
+ return URL_ATTRIBUTES.has(name.toLowerCase());
471
+ }
472
+ export function isCraftResourceUrlAttribute(name) {
473
+ return RESOURCE_URL_ATTRIBUTES.has(name.toLowerCase());
474
+ }
475
+ export function isCraftMultiUrlAttribute(name) {
476
+ return MULTI_URL_ATTRIBUTES.has(name.toLowerCase());
477
+ }
478
+ export function isCraftBlockedElement(name) {
479
+ return BLOCKED_ELEMENTS.has(name.toLowerCase());
480
+ }
481
+ function isMarked(value, marker) {
482
+ return typeof value === 'object' && value !== null && marker in value;
483
+ }
484
+ /**
485
+ * Element and attribute names are validated wherever markup is produced: a
486
+ * name that carries a quote, a space or an angle bracket would break out of
487
+ * its own markup, and value escaping cannot save it.
488
+ */
489
+ const TAG_NAME = /^[A-Za-z][A-Za-z0-9-]*$/;
490
+ const ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_:.-]*$/;
491
+ export function assertCraftTagName(tag) {
492
+ if (!TAG_NAME.test(tag)) {
493
+ throw new CraftDomSecurityError('CRAFT_DOM_TAG_NAME_INVALID', `"${tag}" is not a valid element name.`);
494
+ }
495
+ return tag;
496
+ }
497
+ export function assertCraftAttributeName(name) {
498
+ if (!ATTRIBUTE_NAME.test(name)) {
499
+ throw new CraftDomSecurityError('CRAFT_DOM_ATTRIBUTE_NAME_INVALID', `"${name}" is not a valid attribute name.`);
500
+ }
501
+ return name;
502
+ }