@brianmsm/quarto-bento 0.0.0-stage → 0.1.1

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 +93 -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 +232 -0
  20. package/distribution/runtime/src/core/agent.ts +546 -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 +851 -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 +2557 -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 +892 -0
  100. package/distribution/runtime/src/studio/components.js +188 -0
  101. package/distribution/runtime/src/studio/index.html +283 -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 +461 -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,384 @@
1
+ /** Original-YAML configuration and bounded external-design signals for sync only. */
2
+ import { resolve } from "node:path";
3
+ import {
4
+ assertDesignRegistryReady,
5
+ validateDesignRegistry,
6
+ } from "../render/registry.ts";
7
+ import type { ProjectState } from "../state/mod.ts";
8
+ import { canonical, sha256, StateError } from "../state/mod.ts";
9
+ import { type SourceDocument, SourceError } from "../source/mod.ts";
10
+ import type {
11
+ AnnotationDiagnostic,
12
+ AnnotationFeature,
13
+ AnnotationLevel,
14
+ AnnotationRequest,
15
+ } from "../source/annotations.ts";
16
+ import {
17
+ originalAttributeTokens,
18
+ originalPandocAttributes,
19
+ } from "../source/attributes.ts";
20
+ const key = "qbento-annotations";
21
+ function invalid(message: string): never {
22
+ throw new StateError(
23
+ "INVALID_ANNOTATION_CONFIG",
24
+ `${message} Use one top-level qbento-annotations: none, minimal, or detailed in the original QMD frontmatter.`,
25
+ );
26
+ }
27
+ /** Pandoc parses metadata only: no source body, execution, filters, or project hooks. */
28
+ async function metadataOnly(
29
+ frontmatter: string,
30
+ ): Promise<Record<string, unknown>> {
31
+ const process = new Deno.Command("quarto", {
32
+ args: ["pandoc", "--from", "markdown", "--to", "json"],
33
+ stdin: "piped",
34
+ stdout: "piped",
35
+ stderr: "piped",
36
+ }).spawn();
37
+ const output = process.output();
38
+ const writer = process.stdin.getWriter();
39
+ await writer.write(new TextEncoder().encode(frontmatter));
40
+ await writer.close();
41
+ const result = await output;
42
+ if (!result.success) {
43
+ invalid("Quarto/Pandoc could not parse the original YAML configuration.");
44
+ }
45
+ try {
46
+ const parsed = JSON.parse(new TextDecoder().decode(result.stdout));
47
+ if (!parsed.meta || typeof parsed.meta !== "object") {
48
+ invalid("Original metadata is malformed.");
49
+ }
50
+ return parsed.meta;
51
+ } catch {
52
+ invalid("Quarto/Pandoc did not return valid original metadata.");
53
+ }
54
+ }
55
+ /** No execution metadata or caller-provided AST can supply this setting. */
56
+ export async function originalAnnotationLevel(
57
+ text: string,
58
+ parse: (frontmatter: string) => Promise<Record<string, unknown>> =
59
+ metadataOnly,
60
+ ): Promise<AnnotationLevel> {
61
+ if (!/^(?:\uFEFF)?---[ \t]*\r?\n/.test(text)) return "minimal";
62
+ const match =
63
+ /^(?:\uFEFF)?---[ \t]*\r?\n([\s\S]*?)^(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/m
64
+ .exec(text);
65
+ if (!match) {
66
+ if (/^(?:\uFEFF)?---[ \t]*\r?\n/.test(text)) {
67
+ invalid("The YAML frontmatter has no closing delimiter.");
68
+ }
69
+ return "minimal";
70
+ }
71
+ if (new TextEncoder().encode(match[0]).length > 1024 * 1024) {
72
+ invalid("Original frontmatter exceeds the bounded metadata limit.");
73
+ }
74
+ const meta = await parse(match[0] + "\n");
75
+ const declarations: { indent: string; raw: string }[] = [];
76
+ let literalIndent: number | undefined;
77
+ for (const line of match[1].split(/\r?\n/)) {
78
+ const indentation = /^[ \t]*/.exec(line)![0].length;
79
+ if (literalIndent !== undefined) {
80
+ if (!line.trim() || indentation > literalIndent) continue;
81
+ literalIndent = undefined;
82
+ }
83
+ if (/:[ \t]*[|>](?:[+-]?[1-9]?|[1-9][+-]?)[ \t]*(?:#.*)?$/.test(line)) {
84
+ literalIndent = indentation;
85
+ }
86
+ const declaration =
87
+ /^(\s*)("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[A-Za-z0-9_.-]+)[ \t]*:[ \t]*(.*)$/
88
+ .exec(line);
89
+ if (!declaration) continue;
90
+ let name = declaration[2];
91
+ if (name.startsWith('"')) {
92
+ try {
93
+ name = JSON.parse(name);
94
+ } catch {
95
+ invalid("A quoted configuration key is malformed.");
96
+ }
97
+ } else if (name.startsWith("'")) {
98
+ name = name.slice(1, -1).replace(/''/g, "'");
99
+ }
100
+ if (name === key) {
101
+ declarations.push({ indent: declaration[1], raw: declaration[3] });
102
+ }
103
+ }
104
+ if (!Object.hasOwn(meta, key)) {
105
+ const nestedSetting = (value: unknown): boolean => {
106
+ if (!value || typeof value !== "object") return false;
107
+ const node = value as { t?: unknown; c?: unknown };
108
+ if (node.t === "MetaMap" && node.c && typeof node.c === "object") {
109
+ return Object.hasOwn(node.c, key) ||
110
+ Object.values(node.c).some(nestedSetting);
111
+ }
112
+ return node.t === "MetaList" && Array.isArray(node.c) &&
113
+ node.c.some(nestedSetting);
114
+ };
115
+ if (Object.values(meta).some(nestedSetting)) {
116
+ invalid(
117
+ "The namespaced setting is nested or does not parse as a top-level scalar.",
118
+ );
119
+ }
120
+ return "minimal";
121
+ }
122
+ if (declarations.length !== 1 || declarations[0].indent !== "") {
123
+ invalid(
124
+ "The setting is duplicated, nested, or expressed through unsupported YAML indirection.",
125
+ );
126
+ }
127
+ const raw = declarations[0].raw;
128
+ let scalar: string;
129
+ if (raw.startsWith('"')) {
130
+ const quoted = /^("(?:[^"\\]|\\.)*")[ \t]*(?:#.*)?$/.exec(raw);
131
+ if (!quoted) {
132
+ invalid("The setting must be one scalar on its declaration line.");
133
+ }
134
+ try {
135
+ scalar = JSON.parse(quoted[1]);
136
+ } catch {
137
+ invalid("The quoted setting is malformed.");
138
+ }
139
+ } else if (raw.startsWith("'")) {
140
+ const quoted = /^'((?:[^']|'')*)'[ \t]*(?:#.*)?$/.exec(raw);
141
+ if (!quoted) {
142
+ invalid("The setting must be one scalar on its declaration line.");
143
+ }
144
+ scalar = quoted[1].replace(/''/g, "'");
145
+ } else scalar = raw.replace(/[ \t]+#.*$/, "").trim();
146
+ if (!["none", "minimal", "detailed"].includes(scalar)) {
147
+ invalid("The annotation level is unsupported or is not a literal scalar.");
148
+ }
149
+ const value = meta[key] as { t?: unknown; c?: unknown };
150
+ const parsed = value?.t === "MetaString"
151
+ ? value.c
152
+ : value?.t === "MetaInlines" && Array.isArray(value.c) &&
153
+ value.c.length === 1 && value.c[0]?.t === "Str"
154
+ ? value.c[0].c
155
+ : undefined;
156
+ if (parsed !== scalar) {
157
+ invalid(
158
+ "The literal declaration differs from Quarto/Pandoc's original metadata.",
159
+ );
160
+ }
161
+ return scalar as AnnotationLevel;
162
+ }
163
+ export interface AnnotationRegistry {
164
+ hash: string | null;
165
+ activeTheme?: string;
166
+ layouts: string[];
167
+ }
168
+ /** Registry contents are never emitted into QMD; the exact bytes protect preview/apply. */
169
+ export async function annotationRegistry(
170
+ directory: string,
171
+ ): Promise<AnnotationRegistry> {
172
+ await assertDesignRegistryReady(directory);
173
+ const path = resolve(directory, ".qbento/design.json");
174
+ let text: string;
175
+ let bytes: Uint8Array;
176
+ try {
177
+ const stat = await Deno.lstat(path);
178
+ if (!stat.isFile || stat.isSymlink) {
179
+ throw new StateError(
180
+ "UNSAFE_DESIGN_REGISTRY",
181
+ "Design registry must be a regular local file.",
182
+ );
183
+ }
184
+ if (stat.size > 256 * 1024) {
185
+ throw new StateError(
186
+ "INVALID_DESIGN_REGISTRY",
187
+ "Design registry exceeds 256 KiB.",
188
+ );
189
+ }
190
+ try {
191
+ bytes = await Deno.readFile(path);
192
+ if (bytes.length > 256 * 1024) {
193
+ throw new StateError(
194
+ "INVALID_DESIGN_REGISTRY",
195
+ "Design registry exceeds 256 KiB.",
196
+ );
197
+ }
198
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
199
+ } catch {
200
+ throw new StateError(
201
+ "INVALID_DESIGN_REGISTRY",
202
+ "Design registry must contain valid original UTF-8 bytes; repair the file before syncing.",
203
+ );
204
+ }
205
+ } catch (error) {
206
+ if (error instanceof Deno.errors.NotFound) {
207
+ return { hash: null, layouts: [] };
208
+ }
209
+ throw error;
210
+ }
211
+ let data;
212
+ try {
213
+ data = validateDesignRegistry(JSON.parse(text));
214
+ } catch (error) {
215
+ throw new StateError(
216
+ "INVALID_DESIGN_REGISTRY",
217
+ `Repair the original design registry before annotation sync: ${
218
+ String(error)
219
+ }`,
220
+ );
221
+ }
222
+ return {
223
+ hash: await sha256(bytes),
224
+ ...(typeof data.activeTheme === "string"
225
+ ? { activeTheme: data.activeTheme }
226
+ : {}),
227
+ layouts: Object.keys(data.layouts ?? {}).sort(),
228
+ };
229
+ }
230
+ export function annotationRequests(
231
+ doc: SourceDocument,
232
+ state: ProjectState | null | undefined,
233
+ selected: string[],
234
+ registry: AnnotationRegistry,
235
+ diagnostics: AnnotationDiagnostic[] = [],
236
+ ): AnnotationRequest[] {
237
+ const requests = new Map<string, AnnotationRequest>();
238
+ function add(
239
+ slideId: string | undefined,
240
+ feature: AnnotationFeature,
241
+ registryRef = false,
242
+ ) {
243
+ const ids = slideId ? [slideId] : doc.slides.map((s) => s.id);
244
+ for (const id of ids) {
245
+ let request = requests.get(id);
246
+ if (!request) {
247
+ requests.set(
248
+ id,
249
+ request = { slideId: id, features: [], references: [] },
250
+ );
251
+ }
252
+ const reference = registryRef
253
+ ? ".qbento/design.json"
254
+ : ".qbento/state.json";
255
+ if (!request.features.includes(feature)) request.features.push(feature);
256
+ if (!request.references.includes(reference)) {
257
+ request.references.push(reference);
258
+ }
259
+ }
260
+ }
261
+ for (const override of state?.overrides ?? []) {
262
+ if (override.fields.length) add(override.target.slideId, "overrides");
263
+ }
264
+ for (const decoration of state?.bentoOnly ?? []) {
265
+ add(decoration.slideId, "decorations");
266
+ }
267
+ for (const order of state?.elementOrders ?? []) {
268
+ add(order.slideId, "stacking");
269
+ }
270
+ for (const change of state?.pending ?? []) {
271
+ if (
272
+ selected.includes(change.id) &&
273
+ ["create-slide", "create-element"].includes(change.kind)
274
+ ) add(change.target.slideId, "created-design");
275
+ }
276
+ if (registry.activeTheme) add(undefined, "theme", true);
277
+ for (
278
+ const block of registry.layouts.length
279
+ ? doc.blocks.filter((b) =>
280
+ b.kind === "div" && b.slideId &&
281
+ b.raw.split(/\r?\n/)[0].includes(".bento-layout")
282
+ )
283
+ : []
284
+ ) {
285
+ const attrs = /\{([^{}]*)\}[ \t]*$/.exec(block.raw.split(/\r?\n/)[0]);
286
+ if (!attrs) continue;
287
+ let attr: ReturnType<typeof originalPandocAttributes>;
288
+ try {
289
+ attr = originalPandocAttributes(
290
+ attrs[1],
291
+ originalAttributeTokens(attrs[1]),
292
+ );
293
+ } catch (error) {
294
+ if (!(error instanceof SourceError)) throw error;
295
+ diagnostics.push({
296
+ code: "ANNOTATION_UNSUPPORTED_ATTRIBUTES",
297
+ slideId: block.slideId,
298
+ message:
299
+ "Optional registry-layout trace omitted: this original div uses syntax outside the bounded attribute parser. Existing source and pending semantic changes are preserved.",
300
+ });
301
+ continue;
302
+ }
303
+ const name = attr[2].find(([k]) => k === "bento-layout-name")?.[1];
304
+ if (
305
+ attr[1].includes("bento-layout") && name &&
306
+ registry.layouts.includes(name)
307
+ ) add(block.slideId, "layout", true);
308
+ }
309
+ return [...requests.values()].sort((a, b) =>
310
+ a.slideId.localeCompare(b.slideId)
311
+ );
312
+ }
313
+ /** Optional source edits must not invalidate existing weak provenance on rebuild. */
314
+ export function annotationIdentityDiagnostics(
315
+ doc: SourceDocument,
316
+ state: ProjectState,
317
+ ): AnnotationDiagnostic[] {
318
+ const unsafe = Object.entries(state.provenance).filter(([, p]) => {
319
+ if (p.origin === "computed") return false;
320
+ const source = p.source;
321
+ if (
322
+ source?.stable === true &&
323
+ doc.blocks.some((b) => b.id === source.blockId && b.stable)
324
+ ) return false;
325
+ const parentId = source?.parentId, kind = source?.kind;
326
+ return !(typeof parentId === "string" && source?.siblings === 1 &&
327
+ doc.blocks.some((b) => b.id === parentId && b.stable) &&
328
+ doc.blocks.filter((b) => b.parentId === parentId && b.kind === kind)
329
+ .length === 1);
330
+ }).map(([target]) => target);
331
+ return unsafe.length
332
+ ? [{
333
+ code: "ANNOTATION_UNSAFE_REBUILD",
334
+ message:
335
+ `Optional annotations omitted: existing unpinned source identities (${
336
+ unsafe.slice(0, 8).map((id) => id.slice(0, 120)).join(", ")
337
+ }${
338
+ unsafe.length > 8 ? ", …" : ""
339
+ }) cannot safely survive a source hash change. Keep the original QMD and synchronize compatible semantic changes without optional traces. Enabling traces requires an explicit, verified identity migration; pending edits remain available.`,
340
+ }]
341
+ : [];
342
+ }
343
+ export function registryEqual(a: AnnotationRegistry, b: AnnotationRegistry) {
344
+ return canonical(a) === canonical(b);
345
+ }
346
+
347
+ export interface AnnotationSummary {
348
+ level: AnnotationLevel;
349
+ patchCount: number;
350
+ diagnostics: AnnotationDiagnostic[];
351
+ }
352
+ /** Backward-compatible journal/receipt summary; no invented source declarations. */
353
+ export function validateAnnotationSummary(
354
+ value: unknown,
355
+ ): asserts value is AnnotationSummary {
356
+ const summary = value as AnnotationSummary;
357
+ const codes = new Set([
358
+ "ANNOTATION_UNPINNED_SLIDE",
359
+ "ANNOTATION_UNMAPPED_SLIDE",
360
+ "ANNOTATION_UNSAFE_REBUILD",
361
+ "ANNOTATION_UNSUPPORTED_ATTRIBUTES",
362
+ ]);
363
+ if (
364
+ !summary ||
365
+ Object.keys(summary).sort().join(",") !== "diagnostics,level,patchCount" ||
366
+ !["none", "minimal", "detailed"].includes(summary.level) ||
367
+ !Number.isSafeInteger(summary.patchCount) || summary.patchCount < 0 ||
368
+ summary.patchCount > 1000000 ||
369
+ !Array.isArray(summary.diagnostics) || summary.diagnostics.some((d) =>
370
+ !d ||
371
+ Object.keys(d).some((k) => !["code", "message", "slideId"].includes(k)) ||
372
+ !codes.has(d.code) || typeof d.message !== "string" || !d.message ||
373
+ d.message.length > 65536 ||
374
+ (d.slideId !== undefined &&
375
+ (typeof d.slideId !== "string" ||
376
+ !/^[\p{L}_][\p{L}\p{N}\p{M}_:@.-]*$/u.test(d.slideId)))
377
+ )
378
+ ) {
379
+ throw new StateError(
380
+ "INVALID_ANNOTATION_SUMMARY",
381
+ "Annotation journal/receipt summary is malformed; preserve it for explicit recovery.",
382
+ );
383
+ }
384
+ }
@@ -0,0 +1,203 @@
1
+ import { extractDocument } from "../render/html.ts";
2
+ import { assertProjectDocument } from "../render/validate.ts";
3
+ import {
4
+ type BentoDoc,
5
+ canonical,
6
+ clone,
7
+ equal,
8
+ markEmitted,
9
+ type ProjectState,
10
+ type PublicationReceipt,
11
+ sha256,
12
+ StateError,
13
+ validateDoc,
14
+ validateState,
15
+ } from "../state/mod.ts";
16
+ const RECEIPT_BLOCK =
17
+ /<script\b(?=[^>]*\s+id\s*=\s*["']qbento-receipt["'])[^>]*>([\s\S]*?)<\/script\s*>/gi;
18
+ const GENERATION =
19
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
20
+ const HASH = /^[a-f0-9]{64}$/;
21
+ const guidance =
22
+ "Preserve this HTML unchanged. Publish a fresh project HTML before editing/capturing again; an older copy must be reconciled explicitly.";
23
+ function fail(code: string, message: string): never {
24
+ throw new StateError(code, message + " " + guidance);
25
+ }
26
+ function receiptFromHtml(html: string): PublicationReceipt {
27
+ const blocks = [...html.matchAll(RECEIPT_BLOCK)];
28
+ if (blocks.length !== 1) {
29
+ fail(
30
+ "CAPTURE_RECEIPT_MISSING",
31
+ "Expected exactly one project publication receipt; legacy or unrelated native HTML cannot establish a safe baseline.",
32
+ );
33
+ }
34
+ const opening = blocks[0][0].slice(0, blocks[0][0].indexOf(">") + 1);
35
+ if (!/\s+type\s*=\s*["']application\/json["']/i.test(opening)) {
36
+ fail("CAPTURE_RECEIPT_INVALID", "Publication receipt must be inert JSON.");
37
+ }
38
+ let receipt: PublicationReceipt;
39
+ try {
40
+ receipt = JSON.parse(blocks[0][1]);
41
+ } catch {
42
+ fail("CAPTURE_RECEIPT_INVALID", "Publication receipt is not valid JSON.");
43
+ }
44
+ if (
45
+ !receipt || receipt.version !== 1 ||
46
+ typeof receipt.generation !== "string" ||
47
+ !GENERATION.test(receipt.generation) || typeof receipt.docId !== "string" ||
48
+ !receipt.docId || typeof receipt.publishedDocSha256 !== "string" ||
49
+ !HASH.test(receipt.publishedDocSha256) ||
50
+ !equal(Object.keys(receipt).sort(), [
51
+ "docId",
52
+ "generation",
53
+ "publishedDocSha256",
54
+ "version",
55
+ ])
56
+ ) {
57
+ fail(
58
+ "CAPTURE_RECEIPT_INVALID",
59
+ "Publication receipt has an incompatible or malformed schema.",
60
+ );
61
+ }
62
+ return receipt;
63
+ }
64
+ /** Create a fresh generation. Native Bento Save retains this inert script outside #bento-doc. */
65
+ export async function preparePublication(
66
+ doc: BentoDoc,
67
+ html: string,
68
+ ): Promise<{ html: string; receipt: PublicationReceipt }> {
69
+ validateDoc(doc);
70
+ assertProjectDocument(doc);
71
+ const parsed = extractDocument(html) as unknown as BentoDoc;
72
+ validateDoc(parsed);
73
+ if (!equal(doc, parsed)) {
74
+ fail(
75
+ "PUBLICATION_DOCUMENT_MISMATCH",
76
+ "HTML payload differs from the document being published.",
77
+ );
78
+ }
79
+ const previous = [...html.matchAll(RECEIPT_BLOCK)];
80
+ if (previous.length > 1) {
81
+ fail(
82
+ "CAPTURE_RECEIPT_INVALID",
83
+ "Cannot publish a shell containing duplicate receipts.",
84
+ );
85
+ }
86
+ if (previous.length === 1) {
87
+ receiptFromHtml(html);
88
+ html = html.replace(RECEIPT_BLOCK, "");
89
+ }
90
+ const receipt: PublicationReceipt = {
91
+ version: 1,
92
+ generation: crypto.randomUUID(),
93
+ docId: doc.docId,
94
+ publishedDocSha256: await sha256(canonical(doc)),
95
+ };
96
+ const script = '<script id="qbento-receipt" type="application/json">' +
97
+ JSON.stringify(receipt).replaceAll("<", "\\u003c") + "</script>";
98
+ if (/<\/head\s*>/i.test(html)) {
99
+ html = html.replace(/<\/head\s*>/i, script + "</head>");
100
+ } else html = script + html;
101
+ return { html, receipt };
102
+ }
103
+ /** Prepare receipt state for journaled publication; the caller commits bytes/state safely. */
104
+ export async function recordPublication(
105
+ state: ProjectState,
106
+ doc: BentoDoc,
107
+ path: string,
108
+ html: string,
109
+ receipt: PublicationReceipt,
110
+ ): Promise<ProjectState> {
111
+ validateState(state);
112
+ validateDoc(doc);
113
+ const embedded = receiptFromHtml(html), parsed = extractDocument(html);
114
+ if (
115
+ !equal(receipt, embedded) || receipt.docId !== state.docId ||
116
+ doc.docId !== state.docId ||
117
+ receipt.publishedDocSha256 !== await sha256(canonical(doc)) ||
118
+ !equal(parsed, doc)
119
+ ) {
120
+ fail(
121
+ "PUBLICATION_RECEIPT_MISMATCH",
122
+ "Publication receipt, document and HTML do not agree.",
123
+ );
124
+ }
125
+ const result = await markEmitted(state, doc, path, html);
126
+ result.output = {
127
+ ...result.output!,
128
+ generation: receipt.generation,
129
+ publishedDocSha256: receipt.publishedDocSha256,
130
+ captureConsumed: false,
131
+ };
132
+ return result;
133
+ }
134
+ /** Validate lineage before captureEdits; do not compare native edits against a newer Studio snapshot. */
135
+ export async function verifyCapture(
136
+ state: ProjectState,
137
+ html: string,
138
+ ): Promise<BentoDoc> {
139
+ validateState(state);
140
+ const receipt = receiptFromHtml(html), output = state.output;
141
+ if (
142
+ !output?.generation || !output.publishedDocSha256 ||
143
+ typeof output.captureConsumed !== "boolean"
144
+ ) {
145
+ fail(
146
+ "CAPTURE_BASELINE_MISSING",
147
+ "Saved project predates publication receipts.",
148
+ );
149
+ }
150
+ if (
151
+ receipt.docId !== state.docId || receipt.generation !== output.generation ||
152
+ receipt.publishedDocSha256 !== output.publishedDocSha256
153
+ ) {
154
+ fail(
155
+ "STALE_CAPTURE_GENERATION",
156
+ "This native HTML was produced by a different publication or project.",
157
+ );
158
+ }
159
+ if (output.captureConsumed) {
160
+ fail(
161
+ "CAPTURE_GENERATION_CONSUMED",
162
+ "This publication has already been captured. Another copy from the same generation cannot replace its saved edits.",
163
+ );
164
+ }
165
+ if (await sha256(canonical(state.emitted)) !== output.publishedDocSha256) {
166
+ fail(
167
+ "STALE_CAPTURE_BASELINE",
168
+ "Studio or project edits are newer than this native HTML baseline.",
169
+ );
170
+ }
171
+ const doc = extractDocument(html) as unknown as BentoDoc;
172
+ validateDoc(doc);
173
+ assertProjectDocument(doc);
174
+ if (doc.docId !== receipt.docId) {
175
+ fail(
176
+ "UNRELATED_DOCUMENT",
177
+ "Native Bento document identity differs from its publication receipt.",
178
+ );
179
+ }
180
+ return doc;
181
+ }
182
+ /** Consume only after a validated capture; persist this state atomically with its saved edits. */
183
+ export function consumeCapture(input: ProjectState): ProjectState {
184
+ validateState(input);
185
+ if (
186
+ !input.output?.generation || !input.output.publishedDocSha256 ||
187
+ typeof input.output.captureConsumed !== "boolean"
188
+ ) {
189
+ fail(
190
+ "CAPTURE_BASELINE_MISSING",
191
+ "Cannot consume a capture without a publication receipt.",
192
+ );
193
+ }
194
+ if (input.output.captureConsumed) {
195
+ fail(
196
+ "CAPTURE_GENERATION_CONSUMED",
197
+ "This publication has already been captured.",
198
+ );
199
+ }
200
+ const state = clone(input);
201
+ state.output!.captureConsumed = true;
202
+ return state;
203
+ }