@texuli/blocks 0.0.0-stage → 0.1.0-next.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 (257) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -2
  3. package/dist/code/CodeBlockView.svelte +135 -0
  4. package/dist/code/CodeBlockView.svelte.d.ts +5 -0
  5. package/dist/code/check.d.ts +15 -0
  6. package/dist/code/check.js +14 -0
  7. package/dist/code/code.css +201 -0
  8. package/dist/code/code.css.d.ts +1 -0
  9. package/dist/code/engine-entry.d.ts +10 -0
  10. package/dist/code/engine-entry.js +10 -0
  11. package/dist/code/engine.d.ts +18 -0
  12. package/dist/code/engine.js +18 -0
  13. package/dist/code/family-engine.d.ts +23 -0
  14. package/dist/code/family-engine.js +4 -0
  15. package/dist/code/family.d.ts +7 -0
  16. package/dist/code/family.js +5 -0
  17. package/dist/code/grammars.d.ts +12 -0
  18. package/dist/code/grammars.js +83 -0
  19. package/dist/code/highlight.d.ts +15 -0
  20. package/dist/code/highlight.js +53 -0
  21. package/dist/code/index.d.ts +13 -0
  22. package/dist/code/index.js +12 -0
  23. package/dist/code/labels.d.ts +27 -0
  24. package/dist/code/labels.js +24 -0
  25. package/dist/code/languages/bash.d.ts +3 -0
  26. package/dist/code/languages/bash.js +48 -0
  27. package/dist/code/languages/css.d.ts +10 -0
  28. package/dist/code/languages/css.js +67 -0
  29. package/dist/code/languages/html.d.ts +6 -0
  30. package/dist/code/languages/html.js +35 -0
  31. package/dist/code/languages/javascript.d.ts +3 -0
  32. package/dist/code/languages/javascript.js +113 -0
  33. package/dist/code/languages/json.d.ts +3 -0
  34. package/dist/code/languages/json.js +24 -0
  35. package/dist/code/languages/python.d.ts +3 -0
  36. package/dist/code/languages/python.js +35 -0
  37. package/dist/code/languages/shared.d.ts +22 -0
  38. package/dist/code/languages/shared.js +37 -0
  39. package/dist/code/languages/sql.d.ts +3 -0
  40. package/dist/code/languages/sql.js +32 -0
  41. package/dist/code/languages/yaml.d.ts +7 -0
  42. package/dist/code/languages/yaml.js +36 -0
  43. package/dist/code/renderers.d.ts +5 -0
  44. package/dist/code/renderers.js +5 -0
  45. package/dist/code/token-styles.d.ts +25 -0
  46. package/dist/code/token-styles.js +51 -0
  47. package/dist/code/tokenize-code.d.ts +17 -0
  48. package/dist/code/tokenize-code.js +34 -0
  49. package/dist/code/tokenizer.d.ts +76 -0
  50. package/dist/code/tokenizer.js +171 -0
  51. package/dist/core/catalog/entries.d.ts +10 -0
  52. package/dist/core/catalog/entries.js +589 -0
  53. package/dist/core/catalog/index.d.ts +4 -0
  54. package/dist/core/catalog/index.js +2 -0
  55. package/dist/core/catalog/types.d.ts +36 -0
  56. package/dist/core/catalog/types.js +1 -0
  57. package/dist/core/catalog/validate.d.ts +15 -0
  58. package/dist/core/catalog/validate.js +46 -0
  59. package/dist/core/formula/evaluate.d.ts +14 -0
  60. package/dist/core/formula/evaluate.js +181 -0
  61. package/dist/core/formula/index.d.ts +8 -0
  62. package/dist/core/formula/index.js +6 -0
  63. package/dist/core/formula/parse.d.ts +19 -0
  64. package/dist/core/formula/parse.js +452 -0
  65. package/dist/core/formula/types.d.ts +76 -0
  66. package/dist/core/formula/types.js +7 -0
  67. package/dist/core/index.d.ts +14 -0
  68. package/dist/core/index.js +13 -0
  69. package/dist/core/limits.d.ts +27 -0
  70. package/dist/core/limits.js +26 -0
  71. package/dist/core/safety.d.ts +30 -0
  72. package/dist/core/safety.js +110 -0
  73. package/dist/core/schema/blocks.d.ts +138 -0
  74. package/dist/core/schema/blocks.js +181 -0
  75. package/dist/core/schema/code-family.d.ts +4 -0
  76. package/dist/core/schema/code-family.js +75 -0
  77. package/dist/core/schema/line-spec.d.ts +7 -0
  78. package/dist/core/schema/line-spec.js +19 -0
  79. package/dist/core/schema/media-family.d.ts +4 -0
  80. package/dist/core/schema/media-family.js +260 -0
  81. package/dist/core/schema/parse-family.d.ts +14 -0
  82. package/dist/core/schema/parse-family.js +37 -0
  83. package/dist/core/schema/primitives.d.ts +73 -0
  84. package/dist/core/schema/primitives.js +140 -0
  85. package/dist/core/schema/text-family.d.ts +4 -0
  86. package/dist/core/schema/text-family.js +121 -0
  87. package/dist/core/schema/vocabulary.d.ts +20 -0
  88. package/dist/core/schema/vocabulary.js +33 -0
  89. package/dist/core/text.d.ts +31 -0
  90. package/dist/core/text.js +139 -0
  91. package/dist/core/untrusted.d.ts +20 -0
  92. package/dist/core/untrusted.js +377 -0
  93. package/dist/css-modules.d.ts +1 -0
  94. package/dist/css.d.ts +1 -0
  95. package/dist/data/ChartBlockView.svelte +77 -0
  96. package/dist/data/ChartBlockView.svelte.d.ts +10 -0
  97. package/dist/data/DataBlockView.svelte +109 -0
  98. package/dist/data/DataBlockView.svelte.d.ts +5 -0
  99. package/dist/data/check.d.ts +10 -0
  100. package/dist/data/check.js +9 -0
  101. package/dist/data/engine.d.ts +18 -0
  102. package/dist/data/engine.js +10 -0
  103. package/dist/data/family.d.ts +6 -0
  104. package/dist/data/family.js +4 -0
  105. package/dist/data/index.d.ts +9 -0
  106. package/dist/data/index.js +8 -0
  107. package/dist/data/labels.d.ts +8 -0
  108. package/dist/data/labels.js +8 -0
  109. package/dist/data/renderers.d.ts +2 -0
  110. package/dist/data/renderers.js +2 -0
  111. package/dist/data/schema.d.ts +84 -0
  112. package/dist/data/schema.js +385 -0
  113. package/dist/diagram/BoardDiagram.svelte +48 -0
  114. package/dist/diagram/BoardDiagram.svelte.d.ts +8 -0
  115. package/dist/diagram/DiagramFamily.svelte +46 -0
  116. package/dist/diagram/DiagramFamily.svelte.d.ts +10 -0
  117. package/dist/diagram/FlowDiagram.svelte +108 -0
  118. package/dist/diagram/FlowDiagram.svelte.d.ts +8 -0
  119. package/dist/diagram/TerminalDiagram.svelte +38 -0
  120. package/dist/diagram/TerminalDiagram.svelte.d.ts +8 -0
  121. package/dist/diagram/TreeDiagram.svelte +149 -0
  122. package/dist/diagram/TreeDiagram.svelte.d.ts +8 -0
  123. package/dist/diagram/diagram.css +287 -0
  124. package/dist/diagram/diagram.css.d.ts +1 -0
  125. package/dist/diagram/engine.d.ts +16 -0
  126. package/dist/diagram/engine.js +14 -0
  127. package/dist/diagram/family.d.ts +7 -0
  128. package/dist/diagram/family.js +5 -0
  129. package/dist/diagram/index.d.ts +10 -0
  130. package/dist/diagram/index.js +9 -0
  131. package/dist/diagram/layout.d.ts +26 -0
  132. package/dist/diagram/layout.js +165 -0
  133. package/dist/diagram/schema.d.ts +74 -0
  134. package/dist/diagram/schema.js +358 -0
  135. package/dist/diagram/tones.d.ts +6 -0
  136. package/dist/diagram/tones.js +16 -0
  137. package/dist/embed/EmbedApp.svelte +138 -0
  138. package/dist/embed/EmbedApp.svelte.d.ts +17 -0
  139. package/dist/embed/EmbedBlockView.svelte +32 -0
  140. package/dist/embed/EmbedBlockView.svelte.d.ts +5 -0
  141. package/dist/embed/LinkPreview.svelte +48 -0
  142. package/dist/embed/LinkPreview.svelte.d.ts +10 -0
  143. package/dist/embed/engine.d.ts +30 -0
  144. package/dist/embed/engine.js +17 -0
  145. package/dist/embed/family.d.ts +7 -0
  146. package/dist/embed/family.js +5 -0
  147. package/dist/embed/index.d.ts +5 -0
  148. package/dist/embed/index.js +3 -0
  149. package/dist/embed/renderers.d.ts +3 -0
  150. package/dist/embed/renderers.js +3 -0
  151. package/dist/embed/sandbox.d.ts +86 -0
  152. package/dist/embed/sandbox.js +114 -0
  153. package/dist/embed/schema.d.ts +30 -0
  154. package/dist/embed/schema.js +217 -0
  155. package/dist/index.d.ts +16 -0
  156. package/dist/index.js +15 -0
  157. package/dist/interactive/CalculatorBlock.svelte +61 -0
  158. package/dist/interactive/CalculatorBlock.svelte.d.ts +10 -0
  159. package/dist/interactive/ChecklistBlock.svelte +44 -0
  160. package/dist/interactive/ChecklistBlock.svelte.d.ts +10 -0
  161. package/dist/interactive/InteractiveBlockView.svelte +31 -0
  162. package/dist/interactive/InteractiveBlockView.svelte.d.ts +5 -0
  163. package/dist/interactive/PollBlock.svelte +111 -0
  164. package/dist/interactive/PollBlock.svelte.d.ts +10 -0
  165. package/dist/interactive/QuizBlock.svelte +75 -0
  166. package/dist/interactive/QuizBlock.svelte.d.ts +10 -0
  167. package/dist/interactive/check.d.ts +10 -0
  168. package/dist/interactive/check.js +9 -0
  169. package/dist/interactive/engine.d.ts +19 -0
  170. package/dist/interactive/engine.js +12 -0
  171. package/dist/interactive/family.d.ts +6 -0
  172. package/dist/interactive/family.js +4 -0
  173. package/dist/interactive/index.d.ts +13 -0
  174. package/dist/interactive/index.js +13 -0
  175. package/dist/interactive/interactive.css +97 -0
  176. package/dist/interactive/interactive.css.d.ts +1 -0
  177. package/dist/interactive/labels.d.ts +16 -0
  178. package/dist/interactive/labels.js +16 -0
  179. package/dist/interactive/renderers.d.ts +9 -0
  180. package/dist/interactive/renderers.js +9 -0
  181. package/dist/interactive/schema.d.ts +76 -0
  182. package/dist/interactive/schema.js +324 -0
  183. package/dist/map/MapBlockView.svelte +465 -0
  184. package/dist/map/MapBlockView.svelte.d.ts +5 -0
  185. package/dist/map/check.d.ts +11 -0
  186. package/dist/map/check.js +6 -0
  187. package/dist/map/engine.d.ts +19 -0
  188. package/dist/map/engine.js +8 -0
  189. package/dist/map/family.d.ts +7 -0
  190. package/dist/map/family.js +5 -0
  191. package/dist/map/index.d.ts +11 -0
  192. package/dist/map/index.js +10 -0
  193. package/dist/map/labels.d.ts +27 -0
  194. package/dist/map/labels.js +21 -0
  195. package/dist/map/renderers.d.ts +2 -0
  196. package/dist/map/renderers.js +2 -0
  197. package/dist/map/schema.d.ts +41 -0
  198. package/dist/map/schema.js +176 -0
  199. package/dist/map/view-model.d.ts +55 -0
  200. package/dist/map/view-model.js +168 -0
  201. package/dist/media/MediaBlockView.svelte +258 -0
  202. package/dist/media/MediaBlockView.svelte.d.ts +5 -0
  203. package/dist/media/check.d.ts +14 -0
  204. package/dist/media/check.js +13 -0
  205. package/dist/media/engine.d.ts +18 -0
  206. package/dist/media/engine.js +10 -0
  207. package/dist/media/family.d.ts +7 -0
  208. package/dist/media/family.js +5 -0
  209. package/dist/media/index.d.ts +11 -0
  210. package/dist/media/index.js +10 -0
  211. package/dist/media/labels.d.ts +7 -0
  212. package/dist/media/labels.js +12 -0
  213. package/dist/media/renderers.d.ts +5 -0
  214. package/dist/media/renderers.js +5 -0
  215. package/dist/media/resolve.d.ts +6 -0
  216. package/dist/media/resolve.js +15 -0
  217. package/dist/structure/NestedBlocks.svelte +65 -0
  218. package/dist/structure/NestedBlocks.svelte.d.ts +11 -0
  219. package/dist/structure/SafeBody.svelte +13 -0
  220. package/dist/structure/SafeBody.svelte.d.ts +7 -0
  221. package/dist/structure/StructureFamily.svelte +157 -0
  222. package/dist/structure/StructureFamily.svelte.d.ts +5 -0
  223. package/dist/structure/check.d.ts +14 -0
  224. package/dist/structure/check.js +27 -0
  225. package/dist/structure/engine.d.ts +18 -0
  226. package/dist/structure/engine.js +18 -0
  227. package/dist/structure/family.d.ts +7 -0
  228. package/dist/structure/family.js +5 -0
  229. package/dist/structure/index.d.ts +11 -0
  230. package/dist/structure/index.js +10 -0
  231. package/dist/structure/labels.d.ts +11 -0
  232. package/dist/structure/labels.js +11 -0
  233. package/dist/structure/renderers.d.ts +5 -0
  234. package/dist/structure/renderers.js +5 -0
  235. package/dist/structure/schema.d.ts +71 -0
  236. package/dist/structure/schema.js +327 -0
  237. package/dist/structure/structure.css +182 -0
  238. package/dist/structure/structure.css.d.ts +1 -0
  239. package/dist/text/RichText.svelte +169 -0
  240. package/dist/text/RichText.svelte.d.ts +13 -0
  241. package/dist/text/TextBlockView.svelte +177 -0
  242. package/dist/text/TextBlockView.svelte.d.ts +5 -0
  243. package/dist/text/check.d.ts +14 -0
  244. package/dist/text/check.js +13 -0
  245. package/dist/text/engine.d.ts +17 -0
  246. package/dist/text/engine.js +10 -0
  247. package/dist/text/family.d.ts +7 -0
  248. package/dist/text/family.js +5 -0
  249. package/dist/text/index.d.ts +12 -0
  250. package/dist/text/index.js +11 -0
  251. package/dist/text/labels.d.ts +22 -0
  252. package/dist/text/labels.js +29 -0
  253. package/dist/text/markdown.d.ts +80 -0
  254. package/dist/text/markdown.js +361 -0
  255. package/dist/text/renderers.d.ts +6 -0
  256. package/dist/text/renderers.js +6 -0
  257. package/package.json +167 -4
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import type { EmbedBlockLabels, EmbedMountResult } from './family.js';
4
+ import type { EmbedAppBlock } from './schema.js';
5
+
6
+ interface Props {
7
+ block: EmbedAppBlock;
8
+ /**
9
+ * Host callback after Start. Texuli does not run `block.app`. Prefer
10
+ * `createSandboxedEmbedMount` from `@texuli/blocks/embed` for URL frames
11
+ * (sandbox without allow-same-origin, no-referrer, origin allowlist).
12
+ * Host must enforce CSP frame-src and postMessage origin checks.
13
+ */
14
+ mountEmbed?: (
15
+ target: HTMLElement,
16
+ app: Record<string, unknown>
17
+ ) => EmbedMountResult | Promise<EmbedMountResult>;
18
+ labels: EmbedBlockLabels;
19
+ }
20
+
21
+ let { block, mountEmbed, labels }: Props = $props();
22
+ let started = $state(false);
23
+ let pending = $state(false);
24
+ let error = $state<string | null>(null);
25
+ let mountEl = $state<HTMLDivElement | undefined>(undefined);
26
+ let cleanup: (() => void) | undefined;
27
+ let cancelled = false;
28
+
29
+ $effect(() => {
30
+ const frame = mountEl;
31
+ if (!frame) return;
32
+ frame.addEventListener('keydown', onFrameKeydown);
33
+ return () => frame.removeEventListener('keydown', onFrameKeydown);
34
+ });
35
+
36
+ async function start() {
37
+ if (pending || started) return;
38
+ if (!mountEmbed || !mountEl) {
39
+ error = labels.unavailable;
40
+ return;
41
+ }
42
+ pending = true;
43
+ error = null;
44
+ cancelled = false;
45
+ try {
46
+ const dispose = await mountEmbed(mountEl, block.app);
47
+ if (cancelled) {
48
+ if (typeof dispose === 'function') dispose();
49
+ return;
50
+ }
51
+ if (typeof dispose === 'function') cleanup = dispose;
52
+ started = true;
53
+ mountEl.focus();
54
+ } catch {
55
+ if (!cancelled) error = labels.startError;
56
+ } finally {
57
+ if (!cancelled) pending = false;
58
+ }
59
+ }
60
+
61
+ function close() {
62
+ cancelled = true;
63
+ const dispose = cleanup;
64
+ cleanup = undefined;
65
+ try {
66
+ dispose?.();
67
+ } catch {
68
+ // Runtime cleanup must not disrupt its host document.
69
+ }
70
+ mountEl?.replaceChildren();
71
+ started = false;
72
+ pending = false;
73
+ }
74
+
75
+ function onFrameKeydown(event: KeyboardEvent) {
76
+ if (!started || event.key !== 'Tab' || !mountEl) return;
77
+ const focusable = [
78
+ ...mountEl.querySelectorAll<HTMLElement>(
79
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
80
+ ),
81
+ ].filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
82
+ const first = focusable[0];
83
+ const last = focusable.at(-1);
84
+ if (!first || !last) {
85
+ event.preventDefault();
86
+ mountEl.focus();
87
+ return;
88
+ }
89
+ const active = document.activeElement;
90
+ if (event.shiftKey && (active === first || active === mountEl)) {
91
+ event.preventDefault();
92
+ last.focus();
93
+ } else if (!event.shiftKey && active === last) {
94
+ event.preventDefault();
95
+ first.focus();
96
+ }
97
+ }
98
+
99
+ onDestroy(close);
100
+ </script>
101
+
102
+ <section class="tx-embed-app" data-tx-block="embed-app" data-tx-embed-started={started} aria-label={block.title}>
103
+ <header class="tx-embed-header">
104
+ <h3>{block.title}</h3>
105
+ {#if started}<button type="button" onclick={close}>{labels.close}</button>{/if}
106
+ </header>
107
+ <div class="tx-embed-content" data-size={block.height ?? 'md'}>
108
+ {#if !started}
109
+ <div class="tx-embed-start">
110
+ <p>{labels.idle}</p>
111
+ <button type="button" disabled={pending} aria-busy={pending || undefined} onclick={start}>
112
+ {pending ? labels.starting : block.startLabel ?? labels.start}
113
+ </button>
114
+ </div>
115
+ {/if}
116
+ {#if error}<p role="alert">{error}</p>{/if}
117
+ <!-- Host runtime is deferred until the user activates the start button. -->
118
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
119
+ <div bind:this={mountEl} class="tx-embed-mount" hidden={!started} tabindex={started ? 0 : -1} role="region" aria-label={`${block.title} app`}></div>
120
+ </div>
121
+ </section>
122
+
123
+ <style>
124
+ .tx-embed-app { overflow: hidden; border: 1px solid var(--tx-color-border, #9ca3af); border-radius: 0.625rem; background: var(--tx-color-surface, Canvas); color: var(--tx-color-text, CanvasText); }
125
+ .tx-embed-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.75rem 1rem; border-block-end: 1px solid var(--tx-color-border, #9ca3af); }
126
+ .tx-embed-header h3 { margin: 0; font-size: 1rem; }
127
+ .tx-embed-content { min-block-size: 16rem; padding: 1rem; }
128
+ .tx-embed-content[data-size='sm'] { min-block-size: 10rem; }
129
+ .tx-embed-content[data-size='lg'] { min-block-size: 24rem; }
130
+ .tx-embed-start { display: grid; min-block-size: inherit; align-content: center; justify-items: center; gap: 0.75rem; text-align: center; }
131
+ .tx-embed-start p { margin: 0; }
132
+ button { min-block-size: 2.5rem; padding-inline: 1rem; border: 1px solid currentColor; border-radius: 0.375rem; background: var(--tx-color-accent, #4338ca); color: var(--tx-color-on-accent, white); font: inherit; cursor: pointer; }
133
+ button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
134
+ button:disabled { cursor: wait; opacity: 0.65; }
135
+ .tx-embed-mount { min-block-size: 12rem; overflow: auto; }
136
+ [role='alert'] { color: var(--tx-color-danger, #b91c1c); }
137
+ @media (forced-colors: active) { .tx-embed-app, .tx-embed-header { border-color: CanvasText; } button { border: 1px solid ButtonText; } }
138
+ </style>
@@ -0,0 +1,17 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import type { EmbedBlockLabels, EmbedMountResult } from './family.js';
3
+ import type { EmbedAppBlock } from './schema.js';
4
+ interface Props {
5
+ block: EmbedAppBlock;
6
+ /**
7
+ * Host callback after Start. Texuli does not run `block.app`. Prefer
8
+ * `createSandboxedEmbedMount` from `@texuli/blocks/embed` for URL frames
9
+ * (sandbox without allow-same-origin, no-referrer, origin allowlist).
10
+ * Host must enforce CSP frame-src and postMessage origin checks.
11
+ */
12
+ mountEmbed?: (target: HTMLElement, app: Record<string, unknown>) => EmbedMountResult | Promise<EmbedMountResult>;
13
+ labels: EmbedBlockLabels;
14
+ }
15
+ declare const EmbedApp: import("svelte").Component<Props, {}, "">;
16
+ type EmbedApp = ReturnType<typeof EmbedApp>;
17
+ export default EmbedApp;
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import EmbedApp from './EmbedApp.svelte';
3
+ import type { EmbedBlockViewProps } from './family.js';
4
+ import { DEFAULT_EMBED_BLOCK_LABELS } from './family.js';
5
+ import LinkPreview from './LinkPreview.svelte';
6
+ import { checkEmbedBlock } from './schema.js';
7
+
8
+ let { block, mountEmbed, labels, oninvalid }: EmbedBlockViewProps = $props();
9
+ const check = $derived(checkEmbedBlock(block));
10
+ const text = $derived({ ...DEFAULT_EMBED_BLOCK_LABELS, ...labels });
11
+ $effect(() => {
12
+ if (!check.ok) oninvalid?.(check.issues);
13
+ });
14
+ </script>
15
+
16
+ {#if !check.ok}
17
+ <p class="tx-embed-invalid" data-tx-block="invalid" role="note">{text.invalid}</p>
18
+ {:else if check.block.type === 'embed-app'}
19
+ <EmbedApp block={check.block} {mountEmbed} labels={text} />
20
+ {:else}
21
+ <LinkPreview block={check.block} labels={text} />
22
+ {/if}
23
+
24
+ <style>
25
+ .tx-embed-invalid {
26
+ margin: 0;
27
+ padding: 0.625em 0.875em;
28
+ border: 1px dashed currentColor;
29
+ border-radius: 0.375em;
30
+ overflow-wrap: anywhere;
31
+ }
32
+ </style>
@@ -0,0 +1,5 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import type { EmbedBlockViewProps } from './family.js';
3
+ declare const EmbedBlockView: import("svelte").Component<EmbedBlockViewProps, {}, "">;
4
+ type EmbedBlockView = ReturnType<typeof EmbedBlockView>;
5
+ export default EmbedBlockView;
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import { safeHref } from '@texuli/core/url-policy';
3
+ import type { EmbedBlockLabels } from './family.js';
4
+ import type { LinkPreviewBlock } from './schema.js';
5
+
6
+ interface Props {
7
+ block: LinkPreviewBlock;
8
+ labels: EmbedBlockLabels;
9
+ }
10
+ let { block, labels }: Props = $props();
11
+ // Repeat the URL policy at the sink so this anchor remains safe if its caller changes.
12
+ const href = $derived(safeHref(block.href, 'untrusted'));
13
+ </script>
14
+
15
+ <article class="tx-link-preview" data-tx-block="link-preview">
16
+ <div class="tx-link-copy">
17
+ {#if block.site}<p class="tx-link-site">{block.site}</p>{/if}
18
+ <h3>{block.title}</h3>
19
+ {#if block.description}<p>{block.description}</p>{/if}
20
+ </div>
21
+ {#if href}
22
+ <a href={href} target="_blank" rel="noopener noreferrer">{labels.link}<span class="tx-link-sr"> ({labels.newWindow})</span></a>
23
+ {/if}
24
+ </article>
25
+
26
+ <style>
27
+ .tx-link-preview { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--tx-color-border, #9ca3af); border-radius: 0.625rem; background: var(--tx-color-surface, Canvas); color: var(--tx-color-text, CanvasText); }
28
+ .tx-link-copy { min-inline-size: 0; }
29
+ h3, p { margin: 0; overflow-wrap: anywhere; }
30
+ h3 { font-size: 1rem; }
31
+ .tx-link-copy > p { margin-block-start: 0.375rem; }
32
+ /* Site line uses foreground (not muted) so WCAG AA holds on surface in light/dark/HC. */
33
+ .tx-link-site { color: var(--tx-color-text, CanvasText); font-size: 0.8125rem; }
34
+ a { color: var(--tx-color-link, LinkText); font-weight: 600; }
35
+ a:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
36
+ .tx-link-sr { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
37
+ @media (forced-colors: active) {
38
+ .tx-link-preview {
39
+ background: Canvas;
40
+ color: CanvasText;
41
+ border-color: CanvasText;
42
+ }
43
+ .tx-link-site,
44
+ a {
45
+ color: LinkText;
46
+ }
47
+ }
48
+ </style>
@@ -0,0 +1,10 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ import type { EmbedBlockLabels } from './family.js';
3
+ import type { LinkPreviewBlock } from './schema.js';
4
+ interface Props {
5
+ block: LinkPreviewBlock;
6
+ labels: EmbedBlockLabels;
7
+ }
8
+ declare const LinkPreview: import("svelte").Component<Props, {}, "">;
9
+ type LinkPreview = ReturnType<typeof LinkPreview>;
10
+ export default LinkPreview;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@texuli/blocks/embed/engine` — Svelte-free embed sandbox helpers and parsers.
3
+ */
4
+ import type { FieldIssue } from '../core/schema/primitives.js';
5
+ import type { EmbedMountResult } from './sandbox.js';
6
+ import type { EmbedAppBlock, LinkPreviewBlock } from './schema.js';
7
+ export { type CreateSandboxedEmbedIframeOptions, createSandboxedEmbedIframe, createSandboxedEmbedMount, EMBED_IFRAME_REFERRER_POLICY, EMBED_IFRAME_SANDBOX, EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE, type EmbedMountCleanup, type EmbedMountResult, embedIframeSandbox, type ResolveEmbedFrameSrcOptions, resolveEmbedFrameSrc, type SandboxedEmbedMountOptions, } from './sandbox.js';
8
+ export type { EmbedAppBlock, EmbedBlock, EmbedBlockCheck, EmbedParseResult, LinkPreviewBlock, } from './schema.js';
9
+ export { checkEmbedBlock, EMBED_APP_MAX_BYTES, safeParseEmbedBlock } from './schema.js';
10
+ export declare const EMBED_BLOCK_TYPES: readonly ["embed-app", "link-preview"];
11
+ export type EmbedFamilyBlockType = (typeof EMBED_BLOCK_TYPES)[number];
12
+ export type EmbedFamilyBlock = EmbedAppBlock | LinkPreviewBlock;
13
+ export interface EmbedBlockLabels {
14
+ invalid: string;
15
+ start: string;
16
+ starting: string;
17
+ close: string;
18
+ unavailable: string;
19
+ idle: string;
20
+ startError: string;
21
+ link: string;
22
+ newWindow: string;
23
+ }
24
+ export declare const DEFAULT_EMBED_BLOCK_LABELS: EmbedBlockLabels;
25
+ export interface EmbedBlockViewProps {
26
+ block: unknown;
27
+ mountEmbed?: (target: HTMLElement, app: Record<string, unknown>) => EmbedMountResult | Promise<EmbedMountResult>;
28
+ labels?: Partial<EmbedBlockLabels>;
29
+ oninvalid?: (issues: FieldIssue[]) => void;
30
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@texuli/blocks/embed/engine` — Svelte-free embed sandbox helpers and parsers.
3
+ */
4
+ export { createSandboxedEmbedIframe, createSandboxedEmbedMount, EMBED_IFRAME_REFERRER_POLICY, EMBED_IFRAME_SANDBOX, EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE, embedIframeSandbox, resolveEmbedFrameSrc, } from "./sandbox.js";
5
+ export { checkEmbedBlock, EMBED_APP_MAX_BYTES, safeParseEmbedBlock } from "./schema.js";
6
+ export const EMBED_BLOCK_TYPES = ['embed-app', 'link-preview'];
7
+ export const DEFAULT_EMBED_BLOCK_LABELS = {
8
+ invalid: 'This embed block is not valid.',
9
+ start: 'Start the app',
10
+ starting: 'Starting…',
11
+ close: 'Close app',
12
+ unavailable: 'This host cannot start embedded apps.',
13
+ idle: 'Nothing loads until you start the app.',
14
+ startError: 'The embedded app could not be started.',
15
+ link: 'Open link',
16
+ newWindow: 'Opens in a new tab',
17
+ };
@@ -0,0 +1,7 @@
1
+ import type { Component } from 'svelte';
2
+ import type { EmbedBlockViewProps } from './engine.js';
3
+ export { DEFAULT_EMBED_BLOCK_LABELS, EMBED_BLOCK_TYPES, type EmbedBlockLabels, type EmbedBlockViewProps, type EmbedFamilyBlock, type EmbedFamilyBlockType, type EmbedMountCleanup, type EmbedMountResult, } from './engine.js';
4
+ /** Loads the embed family as a separate Svelte chunk. */
5
+ export declare function loadEmbedFamily(): Promise<{
6
+ default: Component<EmbedBlockViewProps>;
7
+ }>;
@@ -0,0 +1,5 @@
1
+ export { DEFAULT_EMBED_BLOCK_LABELS, EMBED_BLOCK_TYPES, } from "./engine.js";
2
+ /** Loads the embed family as a separate Svelte chunk. */
3
+ export function loadEmbedFamily() {
4
+ return import('./EmbedBlockView.svelte');
5
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ export { DEFAULT_EMBED_BLOCK_LABELS, EMBED_BLOCK_TYPES, type EmbedBlockLabels, type EmbedBlockViewProps, type EmbedFamilyBlock, type EmbedFamilyBlockType, type EmbedMountCleanup, type EmbedMountResult, loadEmbedFamily, } from './family.js';
3
+ export { type CreateSandboxedEmbedIframeOptions, createSandboxedEmbedIframe, createSandboxedEmbedMount, EMBED_IFRAME_REFERRER_POLICY, EMBED_IFRAME_SANDBOX, EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE, embedIframeSandbox, type ResolveEmbedFrameSrcOptions, resolveEmbedFrameSrc, type SandboxedEmbedMountOptions, } from './sandbox.js';
4
+ export type { EmbedAppBlock, EmbedBlock, EmbedBlockCheck, EmbedParseResult, LinkPreviewBlock, } from './schema.js';
5
+ export { checkEmbedBlock, EMBED_APP_MAX_BYTES, safeParseEmbedBlock } from './schema.js';
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_EMBED_BLOCK_LABELS, EMBED_BLOCK_TYPES, loadEmbedFamily, } from "./family.js";
2
+ export { createSandboxedEmbedIframe, createSandboxedEmbedMount, EMBED_IFRAME_REFERRER_POLICY, EMBED_IFRAME_SANDBOX, EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE, embedIframeSandbox, resolveEmbedFrameSrc, } from "./sandbox.js";
3
+ export { checkEmbedBlock, EMBED_APP_MAX_BYTES, safeParseEmbedBlock } from "./schema.js";
@@ -0,0 +1,3 @@
1
+ export { default as EmbedApp } from './EmbedApp.svelte';
2
+ export { default as EmbedBlockView } from './EmbedBlockView.svelte';
3
+ export { default as LinkPreview } from './LinkPreview.svelte';
@@ -0,0 +1,3 @@
1
+ export { default as EmbedApp } from './EmbedApp.svelte';
2
+ export { default as EmbedBlockView } from './EmbedBlockView.svelte';
3
+ export { default as LinkPreview } from './LinkPreview.svelte';
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Host helper for sandboxed embed iframes (F5 / SECURITY.md embed contract).
3
+ *
4
+ * Texuli never evaluates embed-app JSON. When a host needs a URL frame, this
5
+ * module builds an iframe that:
6
+ * - uses a sandbox **without** `allow-same-origin` (opaque origin)
7
+ * - sets `referrerpolicy="no-referrer"`
8
+ * - accepts only HTTPS `src` values whose origin is on an explicit allowlist
9
+ * - omits `allow-popups-to-escape-sandbox` by default (opt in only)
10
+ *
11
+ * The host must still set CSP `frame-src`, check `postMessage` origins, and
12
+ * must never widen the sandbox string returned here casually.
13
+ */
14
+ /** Cleanup returned by a host mountEmbed implementation. */
15
+ export type EmbedMountCleanup = () => void;
16
+ export type EmbedMountResult = void | EmbedMountCleanup;
17
+ /**
18
+ * Default sandbox tokens for embed iframes. Intentionally omits:
19
+ * - `allow-same-origin` (frame cannot read the parent document)
20
+ * - `allow-popups-to-escape-sandbox` (popups stay sandboxed unless opted in)
21
+ */
22
+ export declare const EMBED_IFRAME_SANDBOX: "allow-scripts allow-forms allow-popups";
23
+ /** Optional extra token when the host accepts unsandboxed popups from the frame. */
24
+ export declare const EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE: "allow-popups-to-escape-sandbox";
25
+ /** Referrer policy applied to every sandboxed embed iframe. */
26
+ export declare const EMBED_IFRAME_REFERRER_POLICY: "no-referrer";
27
+ export type EmbedSandboxOptions = {
28
+ /**
29
+ * When true, appends `allow-popups-to-escape-sandbox` so a user-gesture popup
30
+ * from the frame runs with a full top-level origin (phishing risk). Default false.
31
+ */
32
+ allowPopupsToEscapeSandbox?: boolean;
33
+ };
34
+ /** Build the sandbox attribute value for an embed iframe. */
35
+ export declare function embedIframeSandbox(options?: EmbedSandboxOptions): string;
36
+ export type ResolveEmbedFrameSrcOptions = {
37
+ /** Exact HTTPS origins permitted as iframe `src` (e.g. `https://widgets.example.com`). */
38
+ allowedOrigins: readonly string[];
39
+ };
40
+ /**
41
+ * Validate an iframe `src` for an untrusted embed frame.
42
+ * Returns the cleaned URL or `undefined` when the value must not load.
43
+ */
44
+ export declare function resolveEmbedFrameSrc(value: unknown, options: ResolveEmbedFrameSrcOptions): string | undefined;
45
+ export type CreateSandboxedEmbedIframeOptions = {
46
+ /** Candidate frame URL (validated against `allowedOrigins`). */
47
+ src: unknown;
48
+ /** Exact HTTPS origins allowed for `src`. */
49
+ allowedOrigins: readonly string[];
50
+ /** Accessible name for the iframe. */
51
+ title?: string;
52
+ /**
53
+ * Opt-in: allow popups opened by the frame to escape the sandbox.
54
+ * Default false — popups stay constrained.
55
+ */
56
+ allowPopupsToEscapeSandbox?: boolean;
57
+ };
58
+ /**
59
+ * Build a sandboxed iframe element, or `null` when `src` is refused.
60
+ * Does not attach the element to the document.
61
+ */
62
+ export declare function createSandboxedEmbedIframe(options: CreateSandboxedEmbedIframeOptions): HTMLIFrameElement | null;
63
+ export type SandboxedEmbedMountOptions = {
64
+ /** Exact HTTPS origins allowed when `app.src` (or `resolveSrc`) yields a URL. */
65
+ allowedOrigins: readonly string[];
66
+ /**
67
+ * Optional map from validated embed-app JSON to a frame URL.
68
+ * Default reads `app.src` when it is a string.
69
+ */
70
+ resolveSrc?: (app: Record<string, unknown>) => unknown;
71
+ /** Iframe accessible name; defaults to `app.title` or "Embedded app". */
72
+ title?: string | ((app: Record<string, unknown>) => string);
73
+ /**
74
+ * Opt-in: allow popups from the frame to escape the sandbox.
75
+ * Default false.
76
+ */
77
+ allowPopupsToEscapeSandbox?: boolean;
78
+ };
79
+ /**
80
+ * Factory for a host `mountEmbed` that loads allowlisted HTTPS URLs in a
81
+ * sandboxed, no-referrer iframe. Returns a cleanup that removes the frame.
82
+ *
83
+ * If the resolved src is not allowlisted, the mount throws so EmbedApp shows
84
+ * the start-error label instead of painting an open frame.
85
+ */
86
+ export declare function createSandboxedEmbedMount(options: SandboxedEmbedMountOptions): (target: HTMLElement, app: Record<string, unknown>) => EmbedMountResult;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Default sandbox tokens for embed iframes. Intentionally omits:
3
+ * - `allow-same-origin` (frame cannot read the parent document)
4
+ * - `allow-popups-to-escape-sandbox` (popups stay sandboxed unless opted in)
5
+ */
6
+ export const EMBED_IFRAME_SANDBOX = 'allow-scripts allow-forms allow-popups';
7
+ /** Optional extra token when the host accepts unsandboxed popups from the frame. */
8
+ export const EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE = 'allow-popups-to-escape-sandbox';
9
+ /** Referrer policy applied to every sandboxed embed iframe. */
10
+ export const EMBED_IFRAME_REFERRER_POLICY = 'no-referrer';
11
+ /** Build the sandbox attribute value for an embed iframe. */
12
+ export function embedIframeSandbox(options = {}) {
13
+ if (options.allowPopupsToEscapeSandbox === true) {
14
+ return `${EMBED_IFRAME_SANDBOX} ${EMBED_IFRAME_SANDBOX_POPUPS_ESCAPE}`;
15
+ }
16
+ return EMBED_IFRAME_SANDBOX;
17
+ }
18
+ function normalizeAllowedOrigins(origins) {
19
+ const out = new Set();
20
+ for (const entry of origins) {
21
+ if (typeof entry !== 'string' || entry.trim() === '')
22
+ continue;
23
+ try {
24
+ const url = new URL(entry.includes('://') ? entry : `https://${entry}`);
25
+ if (url.protocol !== 'https:')
26
+ continue;
27
+ if (url.username !== '' || url.password !== '')
28
+ continue;
29
+ out.add(url.origin);
30
+ }
31
+ catch {
32
+ // ignore malformed allowlist rows
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * Validate an iframe `src` for an untrusted embed frame.
39
+ * Returns the cleaned URL or `undefined` when the value must not load.
40
+ */
41
+ export function resolveEmbedFrameSrc(value, options) {
42
+ if (typeof value !== 'string')
43
+ return undefined;
44
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control chars are stripped on purpose
45
+ const cleaned = value.trim().replace(/[\u0000-\u001f\u007f]/g, '');
46
+ if (cleaned === '' || cleaned.startsWith('//') || cleaned.startsWith('\\'))
47
+ return undefined;
48
+ let parsed;
49
+ try {
50
+ parsed = new URL(cleaned);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ if (parsed.protocol !== 'https:')
56
+ return undefined;
57
+ if (parsed.username !== '' || parsed.password !== '')
58
+ return undefined;
59
+ if (!normalizeAllowedOrigins(options.allowedOrigins).has(parsed.origin))
60
+ return undefined;
61
+ return parsed.href;
62
+ }
63
+ /**
64
+ * Build a sandboxed iframe element, or `null` when `src` is refused.
65
+ * Does not attach the element to the document.
66
+ */
67
+ export function createSandboxedEmbedIframe(options) {
68
+ const src = resolveEmbedFrameSrc(options.src, {
69
+ allowedOrigins: options.allowedOrigins,
70
+ });
71
+ if (!src)
72
+ return null;
73
+ const iframe = document.createElement('iframe');
74
+ iframe.setAttribute('sandbox', embedIframeSandbox({ allowPopupsToEscapeSandbox: options.allowPopupsToEscapeSandbox }));
75
+ iframe.setAttribute('referrerpolicy', EMBED_IFRAME_REFERRER_POLICY);
76
+ iframe.setAttribute('title', options.title?.trim() || 'Embedded app');
77
+ iframe.src = src;
78
+ return iframe;
79
+ }
80
+ /**
81
+ * Factory for a host `mountEmbed` that loads allowlisted HTTPS URLs in a
82
+ * sandboxed, no-referrer iframe. Returns a cleanup that removes the frame.
83
+ *
84
+ * If the resolved src is not allowlisted, the mount throws so EmbedApp shows
85
+ * the start-error label instead of painting an open frame.
86
+ */
87
+ export function createSandboxedEmbedMount(options) {
88
+ const allowedOrigins = options.allowedOrigins;
89
+ return (target, app) => {
90
+ const rawSrc = options.resolveSrc?.(app) ??
91
+ (typeof app.src === 'string' ? app.src : typeof app.url === 'string' ? app.url : undefined);
92
+ const title = typeof options.title === 'function'
93
+ ? options.title(app)
94
+ : (options.title ??
95
+ (typeof app.title === 'string' ? app.title : undefined) ??
96
+ 'Embedded app');
97
+ const iframe = createSandboxedEmbedIframe({
98
+ src: rawSrc,
99
+ allowedOrigins,
100
+ title,
101
+ allowPopupsToEscapeSandbox: options.allowPopupsToEscapeSandbox,
102
+ });
103
+ if (!iframe) {
104
+ throw new Error('Embed frame src is missing or not on the host allowlist.');
105
+ }
106
+ target.replaceChildren(iframe);
107
+ const cleanup = () => {
108
+ iframe.removeAttribute('src');
109
+ iframe.remove();
110
+ target.replaceChildren();
111
+ };
112
+ return cleanup;
113
+ };
114
+ }
@@ -0,0 +1,30 @@
1
+ import type { BlockBase, FieldIssue, ParseResult } from '../core/schema/primitives.js';
2
+ export declare const EMBED_BLOCK_TYPES: readonly ["embed-app", "link-preview"];
3
+ export declare const EMBED_APP_MAX_BYTES = 2400;
4
+ export type EmbedAppBlock = BlockBase & {
5
+ type: 'embed-app';
6
+ title: string;
7
+ startLabel?: string;
8
+ height?: 'sm' | 'md' | 'lg';
9
+ app: Record<string, unknown>;
10
+ };
11
+ export type LinkPreviewBlock = BlockBase & {
12
+ type: 'link-preview';
13
+ href: string;
14
+ title: string;
15
+ description?: string;
16
+ site?: string;
17
+ };
18
+ export type EmbedBlock = EmbedAppBlock | LinkPreviewBlock;
19
+ export type EmbedParseResult = ParseResult<EmbedBlock>;
20
+ export type EmbedBlockCheck = {
21
+ ok: true;
22
+ block: EmbedBlock;
23
+ } | {
24
+ ok: false;
25
+ issues: FieldIssue[];
26
+ };
27
+ /** Validate a link preview or app frame block before exposing it to a renderer. */
28
+ export declare function safeParseEmbedBlock(value: unknown): EmbedParseResult;
29
+ /** Renderer-facing validation result, with untrusted values kept inert on failure. */
30
+ export declare function checkEmbedBlock(value: unknown): EmbedBlockCheck;