@brianmsm/quarto-bento 0.0.0-stage → 0.1.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 (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/bin/qbento-npm.mjs +62 -0
  4. package/distribution/LICENSE +21 -0
  5. package/distribution/PACKAGING.md +59 -0
  6. package/distribution/bin/qbento +5 -0
  7. package/distribution/bin/qbento.cmd +4 -0
  8. package/distribution/manifest.json +720 -0
  9. package/distribution/runtime/LICENSE +21 -0
  10. package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
  11. package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
  12. package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
  13. package/distribution/runtime/_extensions/qbento/post.ts +2 -0
  14. package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
  15. package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
  16. package/distribution/runtime/src/bridge/browser.js +168 -0
  17. package/distribution/runtime/src/bridge/isolation.ts +55 -0
  18. package/distribution/runtime/src/browser/chromium.ts +218 -0
  19. package/distribution/runtime/src/cli.ts +219 -0
  20. package/distribution/runtime/src/core/agent.ts +565 -0
  21. package/distribution/runtime/src/core/annotations.ts +384 -0
  22. package/distribution/runtime/src/core/capture.ts +203 -0
  23. package/distribution/runtime/src/core/component-actions.ts +550 -0
  24. package/distribution/runtime/src/core/component-frames.ts +59 -0
  25. package/distribution/runtime/src/core/component-sync.ts +290 -0
  26. package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
  27. package/distribution/runtime/src/core/design-registry.ts +287 -0
  28. package/distribution/runtime/src/core/graph-relations.ts +354 -0
  29. package/distribution/runtime/src/core/graph-scope.ts +63 -0
  30. package/distribution/runtime/src/core/graph-sync.ts +228 -0
  31. package/distribution/runtime/src/core/io.ts +27 -0
  32. package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
  33. package/distribution/runtime/src/core/layouts.ts +245 -0
  34. package/distribution/runtime/src/core/project.ts +759 -0
  35. package/distribution/runtime/src/core/reconcile.ts +384 -0
  36. package/distribution/runtime/src/core/recovery.ts +1255 -0
  37. package/distribution/runtime/src/core/slide-actions.ts +158 -0
  38. package/distribution/runtime/src/core/source-scope.ts +50 -0
  39. package/distribution/runtime/src/core/sync.ts +2423 -0
  40. package/distribution/runtime/src/core/widgets.ts +300 -0
  41. package/distribution/runtime/src/integration/install.ts +98 -0
  42. package/distribution/runtime/src/integration/invocation.ts +100 -0
  43. package/distribution/runtime/src/integration/quarto.ts +457 -0
  44. package/distribution/runtime/src/render/assets.ts +66 -0
  45. package/distribution/runtime/src/render/capture.lua +8 -0
  46. package/distribution/runtime/src/render/chart.ts +165 -0
  47. package/distribution/runtime/src/render/components.ts +199 -0
  48. package/distribution/runtime/src/render/design.ts +820 -0
  49. package/distribution/runtime/src/render/diagnostics.ts +36 -0
  50. package/distribution/runtime/src/render/graphs.ts +747 -0
  51. package/distribution/runtime/src/render/html.ts +82 -0
  52. package/distribution/runtime/src/render/images.ts +268 -0
  53. package/distribution/runtime/src/render/inline.ts +259 -0
  54. package/distribution/runtime/src/render/kpi.ts +385 -0
  55. package/distribution/runtime/src/render/lists.ts +183 -0
  56. package/distribution/runtime/src/render/math.ts +413 -0
  57. package/distribution/runtime/src/render/measure.ts +185 -0
  58. package/distribution/runtime/src/render/native-layout.ts +174 -0
  59. package/distribution/runtime/src/render/producer-capture.ts +319 -0
  60. package/distribution/runtime/src/render/producer-environment.R +49 -0
  61. package/distribution/runtime/src/render/producer-environment.ts +96 -0
  62. package/distribution/runtime/src/render/producer-profile.R +91 -0
  63. package/distribution/runtime/src/render/producer-site-profile.R +18 -0
  64. package/distribution/runtime/src/render/project.ts +1806 -0
  65. package/distribution/runtime/src/render/quarto.ts +127 -0
  66. package/distribution/runtime/src/render/registry.ts +233 -0
  67. package/distribution/runtime/src/render/resources.ts +292 -0
  68. package/distribution/runtime/src/render/shell.ts +110 -0
  69. package/distribution/runtime/src/render/source-inline.ts +169 -0
  70. package/distribution/runtime/src/render/table.ts +220 -0
  71. package/distribution/runtime/src/render/text-overflow.ts +208 -0
  72. package/distribution/runtime/src/render/types.ts +71 -0
  73. package/distribution/runtime/src/render/validate.ts +572 -0
  74. package/distribution/runtime/src/rich/project.ts +117 -0
  75. package/distribution/runtime/src/rich/table.ts +374 -0
  76. package/distribution/runtime/src/source/annotations.ts +490 -0
  77. package/distribution/runtime/src/source/attributes.ts +415 -0
  78. package/distribution/runtime/src/source/chart.ts +165 -0
  79. package/distribution/runtime/src/source/code-language.ts +277 -0
  80. package/distribution/runtime/src/source/component-insertion.ts +160 -0
  81. package/distribution/runtime/src/source/deletion.ts +278 -0
  82. package/distribution/runtime/src/source/graphs.ts +517 -0
  83. package/distribution/runtime/src/source/identities.ts +256 -0
  84. package/distribution/runtime/src/source/identity-id.ts +6 -0
  85. package/distribution/runtime/src/source/image.ts +50 -0
  86. package/distribution/runtime/src/source/kpi.ts +196 -0
  87. package/distribution/runtime/src/source/mod.ts +876 -0
  88. package/distribution/runtime/src/source/notes.ts +308 -0
  89. package/distribution/runtime/src/source/structure.ts +647 -0
  90. package/distribution/runtime/src/source/table.ts +290 -0
  91. package/distribution/runtime/src/source/text-conversion.ts +146 -0
  92. package/distribution/runtime/src/state/components.ts +281 -0
  93. package/distribution/runtime/src/state/graphs.ts +200 -0
  94. package/distribution/runtime/src/state/json.ts +162 -0
  95. package/distribution/runtime/src/state/merge.ts +1034 -0
  96. package/distribution/runtime/src/state/mod.ts +5 -0
  97. package/distribution/runtime/src/state/store.ts +226 -0
  98. package/distribution/runtime/src/state/types.ts +151 -0
  99. package/distribution/runtime/src/studio/client.js +790 -0
  100. package/distribution/runtime/src/studio/components.js +188 -0
  101. package/distribution/runtime/src/studio/index.html +278 -0
  102. package/distribution/runtime/src/studio/layouts.js +170 -0
  103. package/distribution/runtime/src/studio/registry.js +283 -0
  104. package/distribution/runtime/src/studio/relations.js +169 -0
  105. package/distribution/runtime/src/studio/server.ts +433 -0
  106. package/distribution/runtime/src/widgets/deck.ts +39 -0
  107. package/distribution/runtime/src/widgets/plotly.ts +354 -0
  108. package/distribution/runtime/src/widgets/presenter.ts +367 -0
  109. package/distribution/runtime/src/widgets/snapshot.ts +116 -0
  110. package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
  111. package/distribution/runtime/vendor/bento/LICENSE +21 -0
  112. package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
  113. package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
  114. package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
  115. package/distribution/runtime/vendor/bento/pin.json +69 -0
  116. package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
  117. package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
  118. package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
  119. package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
  120. package/distribution/runtime/vendor/bento/shell.html +269 -0
  121. package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
  122. package/package.json +40 -4
@@ -0,0 +1,876 @@
1
+ import { imageCaptionSyntax } from "./image.ts";
2
+ /** Conservative original-QMD mapping. All spans are UTF-8 bytes, end-exclusive. */
3
+ import { isSourceIdentity } from "./identity-id.ts";
4
+
5
+ export interface Span {
6
+ start: number;
7
+ end: number;
8
+ }
9
+ export type BlockKind =
10
+ | "heading"
11
+ | "paragraph"
12
+ | "image"
13
+ | "list"
14
+ | "div"
15
+ | "code"
16
+ | "chunk"
17
+ | "math"
18
+ | "unsupported";
19
+ export interface SourceBlock {
20
+ id: string;
21
+ explicitId?: string;
22
+ stable: boolean;
23
+ kind: BlockKind;
24
+ span: Span;
25
+ contentSpan: Span;
26
+ raw: string;
27
+ content: string;
28
+ origin: "authored" | "computed";
29
+ editable: boolean;
30
+ reason?: string;
31
+ level?: number;
32
+ parentId?: string;
33
+ slideId?: string;
34
+ }
35
+ export interface SourceSlide {
36
+ id: string;
37
+ stable: boolean;
38
+ span: Span;
39
+ headingId: string;
40
+ blockIds: string[];
41
+ }
42
+ export interface SourceDiagnostic {
43
+ code: string;
44
+ message: string;
45
+ span?: Span;
46
+ }
47
+ export interface SourceDocument {
48
+ text: string;
49
+ hash: string;
50
+ byteLength: number;
51
+ newline: "\n" | "\r\n";
52
+ blocks: SourceBlock[];
53
+ slides: SourceSlide[];
54
+ diagnostics: SourceDiagnostic[];
55
+ preambleSpan: Span;
56
+ }
57
+ export interface PatchRequest {
58
+ blockId: string;
59
+ replacement: string;
60
+ expected?: string;
61
+ }
62
+ export interface SourcePatch {
63
+ blockId: string;
64
+ span: Span;
65
+ expected: string;
66
+ replacement: string;
67
+ }
68
+ export interface PatchPreview {
69
+ version: 1;
70
+ sourceHash: string;
71
+ patches: SourcePatch[];
72
+ resultHash: string;
73
+ result: string;
74
+ }
75
+ export class SourceError extends Error {
76
+ constructor(public code: string, message: string) {
77
+ super(message);
78
+ this.name = "SourceError";
79
+ }
80
+ }
81
+ const encoder = new TextEncoder();
82
+ const decoder = new TextDecoder("utf-8", { fatal: true });
83
+ /** Dependency-free SHA-256 keeps synchronous scans fully offline. */
84
+ export function hashSource(text: string): string {
85
+ const k = [
86
+ 0x428a2f98,
87
+ 0x71374491,
88
+ 0xb5c0fbcf,
89
+ 0xe9b5dba5,
90
+ 0x3956c25b,
91
+ 0x59f111f1,
92
+ 0x923f82a4,
93
+ 0xab1c5ed5,
94
+ 0xd807aa98,
95
+ 0x12835b01,
96
+ 0x243185be,
97
+ 0x550c7dc3,
98
+ 0x72be5d74,
99
+ 0x80deb1fe,
100
+ 0x9bdc06a7,
101
+ 0xc19bf174,
102
+ 0xe49b69c1,
103
+ 0xefbe4786,
104
+ 0x0fc19dc6,
105
+ 0x240ca1cc,
106
+ 0x2de92c6f,
107
+ 0x4a7484aa,
108
+ 0x5cb0a9dc,
109
+ 0x76f988da,
110
+ 0x983e5152,
111
+ 0xa831c66d,
112
+ 0xb00327c8,
113
+ 0xbf597fc7,
114
+ 0xc6e00bf3,
115
+ 0xd5a79147,
116
+ 0x06ca6351,
117
+ 0x14292967,
118
+ 0x27b70a85,
119
+ 0x2e1b2138,
120
+ 0x4d2c6dfc,
121
+ 0x53380d13,
122
+ 0x650a7354,
123
+ 0x766a0abb,
124
+ 0x81c2c92e,
125
+ 0x92722c85,
126
+ 0xa2bfe8a1,
127
+ 0xa81a664b,
128
+ 0xc24b8b70,
129
+ 0xc76c51a3,
130
+ 0xd192e819,
131
+ 0xd6990624,
132
+ 0xf40e3585,
133
+ 0x106aa070,
134
+ 0x19a4c116,
135
+ 0x1e376c08,
136
+ 0x2748774c,
137
+ 0x34b0bcb5,
138
+ 0x391c0cb3,
139
+ 0x4ed8aa4a,
140
+ 0x5b9cca4f,
141
+ 0x682e6ff3,
142
+ 0x748f82ee,
143
+ 0x78a5636f,
144
+ 0x84c87814,
145
+ 0x8cc70208,
146
+ 0x90befffa,
147
+ 0xa4506ceb,
148
+ 0xbef9a3f7,
149
+ 0xc67178f2,
150
+ ];
151
+ const h = [
152
+ 0x6a09e667,
153
+ 0xbb67ae85,
154
+ 0x3c6ef372,
155
+ 0xa54ff53a,
156
+ 0x510e527f,
157
+ 0x9b05688c,
158
+ 0x1f83d9ab,
159
+ 0x5be0cd19,
160
+ ];
161
+ const input = encoder.encode(text),
162
+ bytes = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64);
163
+ bytes.set(input);
164
+ bytes[input.length] = 128;
165
+ const view = new DataView(bytes.buffer);
166
+ view.setUint32(bytes.length - 8, Math.floor(input.length / 0x20000000));
167
+ view.setUint32(bytes.length - 4, (input.length * 8) >>> 0);
168
+ const r = (n: number, bits: number) => (n >>> bits) | (n << (32 - bits));
169
+ for (let offset = 0; offset < bytes.length; offset += 64) {
170
+ const w = new Uint32Array(64);
171
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
172
+ for (let i = 16; i < 64; i++) {
173
+ w[i] =
174
+ (w[i - 16] + (r(w[i - 15], 7) ^ r(w[i - 15], 18) ^ (w[i - 15] >>> 3)) +
175
+ w[i - 7] +
176
+ (r(w[i - 2], 17) ^ r(w[i - 2], 19) ^ (w[i - 2] >>> 10))) >>> 0;
177
+ }
178
+ let [a, b, c, d, e, f, g, v] = h;
179
+ for (let i = 0; i < 64; i++) {
180
+ const t1 =
181
+ (v + (r(e, 6) ^ r(e, 11) ^ r(e, 25)) + ((e & f) ^ (~e & g)) + k[i] +
182
+ w[i]) >>> 0;
183
+ const t2 =
184
+ ((r(a, 2) ^ r(a, 13) ^ r(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
185
+ v = g;
186
+ g = f;
187
+ f = e;
188
+ e = (d + t1) >>> 0;
189
+ d = c;
190
+ c = b;
191
+ b = a;
192
+ a = (t1 + t2) >>> 0;
193
+ }
194
+ for (const [i, value] of [a, b, c, d, e, f, g, v].entries()) {
195
+ h[i] = (h[i] + value) >>> 0;
196
+ }
197
+ }
198
+ return h.map((n) => n.toString(16).padStart(8, "0")).join("");
199
+ }
200
+ export const sliceBytes = (text: string, span: Span): string =>
201
+ decoder.decode(encoder.encode(text).slice(span.start, span.end));
202
+ interface Line {
203
+ text: string;
204
+ start: number;
205
+ end: number;
206
+ fullEnd: number;
207
+ }
208
+ function linesOf(text: string): Line[] {
209
+ let offset = 0;
210
+ return (text.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g) ?? []).filter(Boolean).map(
211
+ (raw) => {
212
+ const body = raw.replace(/(?:\r\n|\n|\r)$/, "");
213
+ const line = {
214
+ text: body,
215
+ start: offset,
216
+ end: offset + encoder.encode(body).length,
217
+ fullEnd: offset + encoder.encode(raw).length,
218
+ };
219
+ offset = line.fullEnd;
220
+ return line;
221
+ },
222
+ );
223
+ }
224
+ const explicitId = (text: string): string | undefined => {
225
+ const attributes = text.trim().replace(/^\{/, "").replace(/\}$/, "");
226
+ const tokens = attributes.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) ?? [];
227
+ return tokens.find((token) =>
228
+ token[0] === "#" && isSourceIdentity(token.slice(1))
229
+ )?.slice(1);
230
+ };
231
+ const dynamic = (text: string): boolean =>
232
+ /`+(?:\{(?:r|python|julia|ojs)\}|(?:r|python|julia|ojs)\s)/i.test(text) ||
233
+ /\{\{[<%]/.test(text);
234
+ const unsupported = (text: string): boolean =>
235
+ /^(?: {4}|\t|\s*(?:>|[-+*]\s|\d+[.)]\s|\|)|\s*<(?!!--)|\s*\[[^\]]+\]:|\s*[-=]{3,}\s*$)/
236
+ .test(text) || /\|/.test(text);
237
+
238
+ /** Scans the original source, never a post-execution Markdown representation. */
239
+ export function scanSource(
240
+ text: string,
241
+ options: { slideLevel?: number } = {},
242
+ ): SourceDocument {
243
+ if (
244
+ text.includes("\u0000") ||
245
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u
246
+ .test(text)
247
+ ) {
248
+ throw new SourceError(
249
+ "invalid-source",
250
+ "Source must be valid Unicode text without NUL bytes.",
251
+ );
252
+ }
253
+ const lines = linesOf(text),
254
+ blocks: SourceBlock[] = [],
255
+ diagnostics: SourceDiagnostic[] = [];
256
+ const doc: SourceDocument = {
257
+ text,
258
+ hash: hashSource(text),
259
+ byteLength: encoder.encode(text).length,
260
+ newline: text.includes("\r\n") ? "\r\n" : "\n",
261
+ blocks,
262
+ slides: [],
263
+ diagnostics,
264
+ preambleSpan: { start: 0, end: encoder.encode(text).length },
265
+ };
266
+ const stack: { block: SourceBlock; fence: number }[] = [];
267
+ let marker: string | undefined;
268
+ let producerMarker: string | undefined;
269
+ const add = (
270
+ kind: BlockKind,
271
+ first: number,
272
+ last: number,
273
+ contentSpan?: Span,
274
+ id?: string,
275
+ ): SourceBlock => {
276
+ const span = { start: lines[first].start, end: lines[last].fullEnd };
277
+ const inner = contentSpan ?? { start: span.start, end: lines[last].end };
278
+ const content = sliceBytes(text, inner),
279
+ isDynamic = kind === "chunk" || dynamic(content) ||
280
+ kind === "image" && dynamic(sliceBytes(text, span));
281
+ const parentId = stack.at(-1)?.block.id;
282
+ const ordinal =
283
+ blocks.filter((b) => b.parentId === parentId && b.kind === kind).length +
284
+ 1;
285
+ if (producerMarker && (kind !== "chunk" || id || marker || parentId)) {
286
+ diagnostics.push({
287
+ code: "invalid-producer-marker",
288
+ message:
289
+ "A retained producer marker requires exactly one unlabelled top-level executable chunk.",
290
+ span,
291
+ });
292
+ }
293
+ const retainedProducer = kind === "chunk" && !id && !marker && !parentId
294
+ ? producerMarker
295
+ : undefined;
296
+ const block: SourceBlock = {
297
+ id: id ?? marker ?? retainedProducer ??
298
+ (parentId ? `${parentId}/${kind}:${ordinal}` : `${kind}@${span.start}`),
299
+ explicitId: id ?? marker ?? retainedProducer,
300
+ stable: Boolean(id ?? marker ?? retainedProducer),
301
+ kind,
302
+ span,
303
+ contentSpan: inner,
304
+ raw: sliceBytes(text, span),
305
+ content,
306
+ origin: isDynamic ? "computed" : "authored",
307
+ editable: !isDynamic && !["div", "unsupported"].includes(kind),
308
+ parentId: stack.at(-1)?.block.id,
309
+ };
310
+ marker = undefined;
311
+ producerMarker = undefined;
312
+ if (isDynamic) {
313
+ block.reason =
314
+ "Computed content cannot be reverse-written to executable source.";
315
+ }
316
+ if (kind === "unsupported") {
317
+ block.reason = "This Markdown construct has no verified reverse mapping.";
318
+ }
319
+ if (kind === "div") {
320
+ block.reason =
321
+ "Edit mapped children; replacing a complete fenced div is unsupported.";
322
+ }
323
+ blocks.push(block);
324
+ return block;
325
+ };
326
+ let i = 0;
327
+ if (lines[0]?.text.replace(/^\uFEFF/, "") === "---") {
328
+ i = 1;
329
+ while (i < lines.length && !/^(?:---|\.\.\.)\s*$/.test(lines[i].text)) i++;
330
+ if (i === lines.length) {
331
+ diagnostics.push({
332
+ code: "unclosed-frontmatter",
333
+ message: "YAML front matter is not closed.",
334
+ });
335
+ return doc;
336
+ }
337
+ i++;
338
+ }
339
+ while (i < lines.length) {
340
+ const line = lines[i], s = line.text;
341
+ // Check adjacency before dispatch: closing fences do not call add(), so a
342
+ // marker must never survive a scope boundary on its way to another chunk.
343
+ if (producerMarker && !/^ {0,3}(?:`{3,}|~{3,})\s*\{[A-Za-z]/.test(s)) {
344
+ diagnostics.push({
345
+ code: "invalid-producer-marker",
346
+ message:
347
+ "A producer marker must immediately precede its executable chunk.",
348
+ span: { start: line.start, end: line.fullEnd },
349
+ });
350
+ producerMarker = undefined;
351
+ }
352
+ if (!s.trim()) {
353
+ i++;
354
+ continue;
355
+ }
356
+ if (/^\s*<!--/.test(s)) {
357
+ const first = i;
358
+ while (i < lines.length && !lines[i].text.includes("-->")) i++;
359
+ const comment = lines.slice(first, Math.min(i + 1, lines.length)).map((
360
+ l,
361
+ ) => l.text).join("\n");
362
+ producerMarker = comment.match(
363
+ /^\s*<!--\s*qb-producer:(chunk@(?:0|[1-9][0-9]*))\s*-->\s*$/,
364
+ )?.[1];
365
+ if (/^\s*<!--\s*qb-producer:/.test(comment) && !producerMarker) {
366
+ diagnostics.push({
367
+ code: "invalid-producer-marker",
368
+ message: "Malformed retained producer identity.",
369
+ });
370
+ }
371
+ if (producerMarker && stack.length) {
372
+ diagnostics.push({
373
+ code: "invalid-producer-marker",
374
+ message: "Retained producer markers must be authored at top level.",
375
+ });
376
+ producerMarker = undefined;
377
+ }
378
+ marker = comment.match(/^\s*<!--\s*qb:([^\s]+)\s*-->\s*$/)?.[1];
379
+ if (marker && !isSourceIdentity(marker)) marker = undefined;
380
+ if (i === lines.length) {
381
+ diagnostics.push({
382
+ code: "unclosed-comment",
383
+ message: "HTML comment is not closed.",
384
+ span: { start: lines[first].start, end: doc.byteLength },
385
+ });
386
+ }
387
+ i++;
388
+ continue;
389
+ }
390
+ const fence = s.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
391
+ if (fence) {
392
+ const first = i, token = fence[1], info = fence[2].trim();
393
+ i++;
394
+ while (
395
+ i < lines.length &&
396
+ !new RegExp(`^ {0,3}${token[0]}{${token.length},}\\s*$`).test(
397
+ lines[i].text,
398
+ )
399
+ ) i++;
400
+ const closed = i < lines.length, last = closed ? i : lines.length - 1;
401
+ const body = lines.slice(first + 1, closed ? i : lines.length).map((l) =>
402
+ l.text
403
+ ).join("\n");
404
+ const executable = /^\{[A-Za-z]/.test(info);
405
+ const label =
406
+ body.match(/^\s*#\|\s*label:\s*["']?([A-Za-z][\w:.-]*)["']?\s*$/m)
407
+ ?.[1] ?? info.match(/^\{\w+[ ,]+([A-Za-z][\w:.-]*)(?:[, }]|$)/)?.[1];
408
+ const block = add(
409
+ executable ? "chunk" : /^\{=/.test(info) ? "unsupported" : "code",
410
+ first,
411
+ last,
412
+ {
413
+ start: lines[first].fullEnd,
414
+ end: closed ? lines[i].start : doc.byteLength,
415
+ },
416
+ label ?? explicitId(info),
417
+ );
418
+ if (!closed) {
419
+ block.editable = false;
420
+ block.reason = "Unclosed code fence.";
421
+ diagnostics.push({
422
+ code: "unclosed-fence",
423
+ message: block.reason,
424
+ span: block.span,
425
+ });
426
+ }
427
+ i++;
428
+ continue;
429
+ }
430
+ const div = s.match(/^ {0,3}(:{3,})(.*)$/);
431
+ if (div) {
432
+ if (!div[2].trim() && stack.length) {
433
+ const open = stack.pop()!;
434
+ open.block.span.end = line.fullEnd;
435
+ open.block.contentSpan.end = line.start;
436
+ open.block.raw = sliceBytes(text, open.block.span);
437
+ open.block.content = sliceBytes(text, open.block.contentSpan);
438
+ } else if (div[2].trim()) {
439
+ const block = add("div", i, i, {
440
+ start: line.fullEnd,
441
+ end: line.fullEnd,
442
+ }, explicitId(div[2].replace(/^\s*\{/, "")));
443
+ stack.push({ block, fence: div[1].length });
444
+ } else {diagnostics.push({
445
+ code: "unmatched-div",
446
+ message: "Closing div fence has no opener.",
447
+ span: { start: line.start, end: line.fullEnd },
448
+ });}
449
+ i++;
450
+ continue;
451
+ }
452
+ const heading = s.match(/^( {0,3})(#{1,6})([ \t]+)(.*)$/);
453
+ if (heading) {
454
+ const tail = heading[4],
455
+ attrs = tail.match(/\s+\{([^{}]*)\}\s*$/),
456
+ closing = (attrs ? tail.slice(0, attrs.index) : tail).match(
457
+ /\s+#+\s*$/,
458
+ );
459
+ const value = (attrs ? tail.slice(0, attrs.index) : tail).slice(
460
+ 0,
461
+ closing?.index,
462
+ );
463
+ const start = line.start +
464
+ encoder.encode(heading[1] + heading[2] + heading[3]).length;
465
+ const block = add("heading", i, i, {
466
+ start,
467
+ end: start + encoder.encode(value).length,
468
+ }, attrs ? explicitId(attrs[1]) : undefined);
469
+ block.level = heading[2].length;
470
+ i++;
471
+ continue;
472
+ }
473
+ if (/^\s*\$\$\s*$/.test(s)) {
474
+ const first = i++;
475
+ while (i < lines.length && !/^\s*\$\$\s*$/.test(lines[i].text)) i++;
476
+ const closed = i < lines.length;
477
+ const block = add("math", first, closed ? i : lines.length - 1, {
478
+ start: lines[first].fullEnd,
479
+ end: closed ? lines[i].start : doc.byteLength,
480
+ });
481
+ if (!closed) {
482
+ block.editable = false;
483
+ block.reason = "Unclosed display math.";
484
+ }
485
+ i++;
486
+ continue;
487
+ }
488
+ if (/^ {0,3}(?:[-+*] |[0-9]+[.)] )/.test(s)) {
489
+ const rootMarker = /^( {0,3})([-+*]|[0-9]+[.)])( +)/.exec(s)!;
490
+ const ordered = /^[0-9]/.test(rootMarker[2]);
491
+ // Markers before this column belong to the root list. A deeper marker
492
+ // belongs to an item; changing its type must not split the parent span.
493
+ let contentIndent = rootMarker[0].length;
494
+ const first = i++;
495
+ while (i < lines.length) {
496
+ if (!lines[i].text.trim()) {
497
+ let next = i + 1;
498
+ while (next < lines.length && !lines[next].text.trim()) next++;
499
+ if (
500
+ next >= lines.length ||
501
+ !/^(?: {2,}\S|\t| {0,3}(?:[-+*] |[0-9]+[.)] ))/.test(
502
+ lines[next].text,
503
+ )
504
+ ) break;
505
+ i = next;
506
+ }
507
+ const nextMarker = /^( *)([-+*]|[0-9]+[.)])( +)/.exec(lines[i].text);
508
+ if (nextMarker && nextMarker[1].length < contentIndent) {
509
+ if (/^[0-9]/.test(nextMarker[2]) !== ordered) break;
510
+ contentIndent = nextMarker[0].length;
511
+ }
512
+ if (
513
+ /^ {0,3}(?:#{1,6}\s|`{3,}|~{3,}|:{3,}|<!--|\$\$\s*$)/.test(
514
+ lines[i].text,
515
+ )
516
+ ) break;
517
+ i++;
518
+ }
519
+ // Blank separators are outside the authored unit, regardless of whether
520
+ // lookahead found another list kind or a reviewed identity wrapper.
521
+ let last = i - 1;
522
+ while (last > first && !lines[last].text.trim()) last--;
523
+ add("list", first, last);
524
+ continue;
525
+ }
526
+ const first = i++;
527
+ while (
528
+ i < lines.length && lines[i].text.trim() &&
529
+ !/^ {0,3}(?:#{1,6}\s|`{3,}|~{3,}|:{3,}|<!--|\$\$\s*$|[-+*] |[0-9]+[.)] )/
530
+ .test(lines[i].text)
531
+ ) i++;
532
+ const raw = lines.slice(first, i).map((l) => l.text).join("\n");
533
+ const image = imageCaptionSyntax(
534
+ sliceBytes(text, { start: lines[first].start, end: lines[i - 1].end }),
535
+ );
536
+ if (image) {
537
+ add("image", first, i - 1, {
538
+ start: lines[first].start + image.start,
539
+ end: lines[first].start + image.end,
540
+ });
541
+ continue;
542
+ }
543
+ add(
544
+ raw.split("\n").some(unsupported) || raw.includes("<!--")
545
+ ? "unsupported"
546
+ : "paragraph",
547
+ first,
548
+ i - 1,
549
+ );
550
+ }
551
+ for (const { block } of stack) {
552
+ block.span.end = doc.byteLength;
553
+ diagnostics.push({
554
+ code: "unclosed-div",
555
+ message: "Fenced div is not closed.",
556
+ span: block.span,
557
+ });
558
+ for (
559
+ const child of blocks.filter((b) => b.span.start >= block.span.start)
560
+ ) {
561
+ child.editable = false;
562
+ child.reason = "Contained in an unclosed div.";
563
+ }
564
+ }
565
+ const counts = new Map<string, number>();
566
+ for (const block of blocks) {
567
+ counts.set(block.id, (counts.get(block.id) ?? 0) + 1);
568
+ }
569
+ for (const block of blocks) {
570
+ if (counts.get(block.id)! > 1) {
571
+ block.editable = false;
572
+ block.reason = `Duplicate source identity: ${block.id}.`;
573
+ diagnostics.push({
574
+ code: "duplicate-id",
575
+ message: block.reason,
576
+ span: block.span,
577
+ });
578
+ }
579
+ }
580
+ // The native projector opens a slide at every top-level H1 or H2. Preserve
581
+ // the explicit scanner API's exact-level selection, but keep the ordinary
582
+ // mapping aligned with those rendered boundaries (including H1 title slides).
583
+ const headings = blocks.filter((b) =>
584
+ b.kind === "heading" && !b.parentId &&
585
+ (options.slideLevel === undefined
586
+ ? b.level === 1 || b.level === 2
587
+ : b.level === options.slideLevel)
588
+ );
589
+ doc.preambleSpan.end = headings[0]?.span.start ?? doc.byteLength;
590
+ doc.slides = headings.map((h, n) => {
591
+ const span = {
592
+ start: h.span.start,
593
+ end: headings[n + 1]?.span.start ?? doc.byteLength,
594
+ };
595
+ const members = blocks.filter((b) =>
596
+ b.span.start >= span.start && b.span.start < span.end
597
+ );
598
+ for (const block of members) block.slideId = h.id;
599
+ return {
600
+ id: h.id,
601
+ stable: h.stable,
602
+ span,
603
+ headingId: h.id,
604
+ blockIds: members.map((b) => b.id),
605
+ };
606
+ });
607
+ if (producerMarker) {
608
+ diagnostics.push({
609
+ code: "invalid-producer-marker",
610
+ message: "A producer marker has no executable chunk.",
611
+ });
612
+ }
613
+ return doc;
614
+ }
615
+
616
+ function findBlock(doc: SourceDocument, id: string): SourceBlock {
617
+ const matches = doc.blocks.filter((b) => b.id === id);
618
+ if (matches.length !== 1) {
619
+ throw new SourceError(
620
+ "ambiguous-source",
621
+ `Expected exactly one source block for ${id}, found ${matches.length}.`,
622
+ );
623
+ }
624
+ return matches[0];
625
+ }
626
+ function validateReplacement(block: SourceBlock, replacement: string): void {
627
+ if (block.kind === "image") {
628
+ const original = imageCaptionSyntax(block.raw);
629
+ if (!original) {
630
+ throw new SourceError(
631
+ "structural-replacement",
632
+ "Image caption boundary is not exact.",
633
+ );
634
+ }
635
+ const bytes = new TextEncoder().encode(block.raw);
636
+ const candidate =
637
+ new TextDecoder().decode(bytes.subarray(0, original.start)) +
638
+ replacement + new TextDecoder().decode(bytes.subarray(original.end));
639
+ const fresh = imageCaptionSyntax(candidate);
640
+ if (
641
+ !fresh || fresh.start !== original.start ||
642
+ fresh.caption !== replacement ||
643
+ fresh.end !==
644
+ original.start + new TextEncoder().encode(replacement).length
645
+ ) {
646
+ throw new SourceError(
647
+ "structural-replacement",
648
+ "Image caption edit must preserve the original URI and image boundaries.",
649
+ );
650
+ }
651
+ }
652
+ if (block.kind === "heading" && /[\r\n]|\s\{[^}]*\}\s*$/.test(replacement)) {
653
+ throw new SourceError(
654
+ "structural-replacement",
655
+ "Heading edits must contain only heading content.",
656
+ );
657
+ }
658
+ if (
659
+ block.kind === "paragraph" &&
660
+ (/\r?\n\s*\r?\n/.test(replacement) ||
661
+ replacement.split(/\r?\n/).some((l) =>
662
+ /^ {0,3}(?:#{1,6}\s|`{3,}|~{3,}|:{3,}|<!--|\$\$)/.test(l) ||
663
+ unsupported(l)
664
+ ))
665
+ ) {
666
+ throw new SourceError(
667
+ "structural-replacement",
668
+ "Paragraph edits must remain a single supported paragraph.",
669
+ );
670
+ }
671
+ if (block.kind === "code" && /^ {0,3}(?:`{3,}|~{3,})/m.test(replacement)) {
672
+ throw new SourceError(
673
+ "structural-replacement",
674
+ "Displayed-code edits cannot introduce fences.",
675
+ );
676
+ }
677
+ if (block.kind === "math" && /^\s*\$\$\s*$/m.test(replacement)) {
678
+ throw new SourceError(
679
+ "structural-replacement",
680
+ "Math edits cannot introduce display delimiters.",
681
+ );
682
+ }
683
+ if (dynamic(replacement)) {
684
+ throw new SourceError(
685
+ "computed-replacement",
686
+ "A semantic edit cannot introduce executable inline content.",
687
+ );
688
+ }
689
+ if (!replacement.trim() && block.kind !== "image") {
690
+ throw new SourceError(
691
+ "structural-replacement",
692
+ "Deletion is a separate explicitly reviewed operation.",
693
+ );
694
+ }
695
+ }
696
+ /** Produces a reviewable in-memory patch; it never writes a file. */
697
+ export function previewPatches(
698
+ doc: SourceDocument,
699
+ requests: PatchRequest[],
700
+ ): PatchPreview {
701
+ if (hashSource(doc.text) !== doc.hash) {
702
+ throw new SourceError(
703
+ "stale-document",
704
+ "Source document snapshot was modified.",
705
+ );
706
+ }
707
+ // Rescan the trusted bytes so mutation of caller-owned mapping metadata cannot broaden edits.
708
+ const fresh = scanSource(doc.text);
709
+ const patches = requests.map((request): SourcePatch => {
710
+ const block = findBlock(fresh, request.blockId);
711
+ if (!block.editable) {
712
+ throw new SourceError(
713
+ "unmapped",
714
+ block.reason ?? "Block is not editable.",
715
+ );
716
+ }
717
+ if (request.expected !== undefined && block.content !== request.expected) {
718
+ throw new SourceError(
719
+ "stale-fragment",
720
+ `Source fragment changed: ${block.id}.`,
721
+ );
722
+ }
723
+ validateReplacement(block, request.replacement);
724
+ let replacement = request.replacement.replace(/\r\n|\r|\n/g, doc.newline);
725
+ if (
726
+ ["code", "math"].includes(block.kind) &&
727
+ block.content.endsWith(doc.newline) && !replacement.endsWith(doc.newline)
728
+ ) replacement += doc.newline;
729
+ return {
730
+ blockId: block.id,
731
+ span: block.contentSpan,
732
+ expected: block.content,
733
+ replacement,
734
+ };
735
+ }).sort((a, b) => a.span.start - b.span.start);
736
+ for (let i = 1; i < patches.length; i++) {
737
+ if (
738
+ patches[i].span.start < patches[i - 1].span.end ||
739
+ patches[i].blockId === patches[i - 1].blockId
740
+ ) {
741
+ throw new SourceError(
742
+ "overlapping-patches",
743
+ "Patch requests overlap or repeat a block.",
744
+ );
745
+ }
746
+ }
747
+ const result = splice(doc.text, patches);
748
+ const rescanned = scanSource(result);
749
+ const shape = (b: SourceBlock) => [b.kind, b.explicitId, b.level, b.origin];
750
+ if (
751
+ JSON.stringify(fresh.blocks.map(shape)) !==
752
+ JSON.stringify(rescanned.blocks.map(shape))
753
+ ) {
754
+ throw new SourceError(
755
+ "structural-replacement",
756
+ "A content patch changed source structure or provenance.",
757
+ );
758
+ }
759
+ for (const patch of patches) {
760
+ const index = fresh.blocks.findIndex((b) => b.id === patch.blockId);
761
+ if (rescanned.blocks[index].content !== patch.replacement) {
762
+ throw new SourceError(
763
+ "structural-replacement",
764
+ "The replacement is parsed differently from its reviewed content.",
765
+ );
766
+ }
767
+ }
768
+ return {
769
+ version: 1,
770
+ sourceHash: doc.hash,
771
+ patches,
772
+ resultHash: hashSource(result),
773
+ result,
774
+ };
775
+ }
776
+ function splice(text: string, patches: SourcePatch[]): string {
777
+ let bytes = encoder.encode(text);
778
+ for (const patch of [...patches].reverse()) {
779
+ if (sliceBytes(text, patch.span) !== patch.expected) {
780
+ throw new SourceError(
781
+ "stale-fragment",
782
+ `Source fragment changed: ${patch.blockId}.`,
783
+ );
784
+ }
785
+ const replacement = encoder.encode(patch.replacement),
786
+ joined = new Uint8Array(
787
+ patch.span.start + replacement.length + bytes.length - patch.span.end,
788
+ );
789
+ joined.set(bytes.slice(0, patch.span.start));
790
+ joined.set(replacement, patch.span.start);
791
+ joined.set(
792
+ bytes.slice(patch.span.end),
793
+ patch.span.start + replacement.length,
794
+ );
795
+ bytes = joined;
796
+ }
797
+ return decoder.decode(bytes);
798
+ }
799
+ /** Apply an approved preview to exactly the reviewed current source snapshot. */
800
+ export function applyPatches(
801
+ currentText: string,
802
+ preview: PatchPreview,
803
+ ): string {
804
+ if (preview.version !== 1 || hashSource(currentText) !== preview.sourceHash) {
805
+ throw new SourceError(
806
+ "stale-source",
807
+ "Source changed after preview; review a new diff before applying.",
808
+ );
809
+ }
810
+ const verified = previewPatches(
811
+ scanSource(currentText),
812
+ preview.patches.map((p) => ({
813
+ blockId: p.blockId,
814
+ expected: p.expected,
815
+ replacement: p.replacement,
816
+ })),
817
+ );
818
+ if (
819
+ JSON.stringify(verified.patches) !== JSON.stringify(preview.patches) ||
820
+ verified.resultHash !== preview.resultHash ||
821
+ verified.result !== preview.result
822
+ ) {
823
+ throw new SourceError(
824
+ "invalid-preview",
825
+ "Patch preview was altered or has inconsistent bounds.",
826
+ );
827
+ }
828
+ return verified.result;
829
+ }
830
+
831
+ /** Resolve execution output only with original-source evidence; ambiguity is an error. */
832
+ export function resolveSourceMapping(
833
+ doc: SourceDocument,
834
+ evidence: {
835
+ sourceHash: string;
836
+ sourceId?: string;
837
+ literal?: string;
838
+ computed?: boolean;
839
+ },
840
+ ): SourceBlock {
841
+ if (evidence.sourceHash !== doc.hash) {
842
+ throw new SourceError(
843
+ "stale-source",
844
+ "Execution source snapshot differs from source map.",
845
+ );
846
+ }
847
+ if (evidence.computed) {
848
+ throw new SourceError(
849
+ "computed-source",
850
+ "Executed output is not reverse-editable source.",
851
+ );
852
+ }
853
+ const matches = evidence.sourceId
854
+ ? doc.blocks.filter((b) => b.id === evidence.sourceId)
855
+ : doc.blocks.filter((b) =>
856
+ b.content === evidence.literal &&
857
+ ["heading", "paragraph"].includes(b.kind)
858
+ );
859
+ if (matches.length !== 1) {
860
+ throw new SourceError(
861
+ "ambiguous-source",
862
+ "Output cannot be uniquely mapped to an original authored block.",
863
+ );
864
+ }
865
+ const block = matches[0];
866
+ if (!block.editable || block.origin === "computed") {
867
+ throw new SourceError("unmapped", block.reason ?? "Unsupported mapping.");
868
+ }
869
+ if (evidence.literal !== undefined && evidence.literal !== block.content) {
870
+ throw new SourceError(
871
+ "transformed-source",
872
+ "Rendered text differs from the source; a verified transform is required.",
873
+ );
874
+ }
875
+ return block;
876
+ }