@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,759 @@
1
+ import { loadBentoShell } from "../render/shell.ts";
2
+ import { IMAGE_CODEC_VERSION } from "../source/image.ts";
3
+ import {
4
+ preserveRescuedKpiGeometry,
5
+ retainedKpiAppearances,
6
+ } from "./kpi-appearance.ts";
7
+ import { retainedComponentFrames } from "./component-frames.ts";
8
+ import { kpiReadabilityDiagnostics } from "../render/kpi.ts";
9
+ import { refreshOverflowDiagnostics } from "../render/diagnostics.ts";
10
+ import { textOverflowDiagnostics } from "../render/text-overflow.ts";
11
+ import { assertGraphTypography } from "../render/graphs.ts";
12
+ import { prepareRichTables } from "../rich/project.ts";
13
+ import { missingDesignDiagnostics } from "./design-diagnostics.ts";
14
+ import { prepareWidgets, projectPresenter, stageWidgets } from "./widgets.ts";
15
+ import {
16
+ assertDesignRegistryUnchanged,
17
+ loadDesignRegistry,
18
+ } from "../render/registry.ts";
19
+ import { LIST_CODEC_VERSION } from "../render/lists.ts";
20
+ import { INLINE_CODEC_VERSION } from "../render/inline.ts";
21
+ import { readSourceInline } from "../render/source-inline.ts";
22
+ import { MATH_CODEC_VERSION } from "../render/math.ts";
23
+ import { basename, dirname, resolve } from "node:path";
24
+ import { executeQuarto } from "../render/quarto.ts";
25
+ import {
26
+ assertSupportedExecutionSource,
27
+ projectAst,
28
+ validateProjectionOrigins,
29
+ } from "../render/project.ts";
30
+ import { spliceDocument } from "../render/html.ts";
31
+ import {
32
+ consumeCapture,
33
+ preparePublication,
34
+ recordPublication,
35
+ verifyCapture,
36
+ } from "./capture.ts";
37
+ import { hashSource, scanSource } from "../source/mod.ts";
38
+ import { NOTES_CODEC_VERSION, sourceNotes } from "../source/notes.ts";
39
+ import { sourceDesignAttribute } from "../source/attributes.ts";
40
+ import { validateDesignValue } from "../render/design.ts";
41
+ import {
42
+ assertOutputUnchanged,
43
+ type BentoDoc,
44
+ captureEdits,
45
+ compose,
46
+ createState,
47
+ equal,
48
+ type JsonObject,
49
+ markEmitted,
50
+ type OwnershipMap,
51
+ type ProjectState,
52
+ type ProvenanceMap,
53
+ sha256,
54
+ StateError,
55
+ StateStore,
56
+ targetKey,
57
+ } from "../state/mod.ts";
58
+ import {
59
+ ISOLATION_BOOTSTRAP,
60
+ OFFLINE_CSP,
61
+ prepareIsolatedDocument,
62
+ } from "../bridge/isolation.ts";
63
+ import type {
64
+ Ast,
65
+ BentoDoc as RenderDoc,
66
+ Diagnostic,
67
+ } from "../render/types.ts";
68
+ import { syncDirectory, writeDurable } from "./io.ts";
69
+ import { assertProjectDocument } from "../render/validate.ts";
70
+
71
+ export function projectPaths(source: string) {
72
+ source = resolve(source);
73
+ if (!source.endsWith(".qmd")) {
74
+ throw new Error("SOURCE_TYPE: expected a single .qmd source");
75
+ }
76
+ return {
77
+ source,
78
+ directory: dirname(source),
79
+ stateDir: resolve(dirname(source), ".qbento"),
80
+ output: source.replace(/\.qmd$/, ".bento.html"),
81
+ };
82
+ }
83
+ async function exists(path: string) {
84
+ try {
85
+ await Deno.lstat(path);
86
+ return true;
87
+ } catch (e) {
88
+ if (e instanceof Deno.errors.NotFound) return false;
89
+ throw e;
90
+ }
91
+ }
92
+ export async function withProject<T>(
93
+ source: string,
94
+ action: (
95
+ paths: ReturnType<typeof projectPaths>,
96
+ store: StateStore,
97
+ ) => Promise<T>,
98
+ ): Promise<T> {
99
+ const paths = projectPaths(source);
100
+ await Deno.mkdir(paths.stateDir, { recursive: true });
101
+ const stateInfo = await Deno.lstat(paths.stateDir);
102
+ if (stateInfo.isSymlink || !stateInfo.isDirectory) {
103
+ throw new Error("UNSAFE_STATE_PATH: .qbento must be a real directory");
104
+ }
105
+ const lock = resolve(paths.stateDir, "operation.lock");
106
+ try {
107
+ await Deno.mkdir(lock);
108
+ } catch (e) {
109
+ if (e instanceof Deno.errors.AlreadyExists) {
110
+ throw new Error(
111
+ "PROJECT_BUSY: another operation or interrupted transaction owns .qbento/operation.lock",
112
+ );
113
+ }
114
+ throw e;
115
+ }
116
+ try {
117
+ await Deno.writeTextFile(
118
+ resolve(lock, "owner.json"),
119
+ JSON.stringify({
120
+ pid: Deno.pid,
121
+ source: paths.source,
122
+ started: new Date().toISOString(),
123
+ }),
124
+ );
125
+ const identity = resolve(paths.stateDir, "project.json");
126
+ if (await exists(identity)) {
127
+ const meta = JSON.parse(await Deno.readTextFile(identity));
128
+ if (meta.version !== 1 || meta.source !== basename(paths.source)) {
129
+ throw new Error(
130
+ "PROJECT_SOURCE_MISMATCH: .qbento belongs to a different source",
131
+ );
132
+ }
133
+ } else {await Deno.writeTextFile(
134
+ identity,
135
+ JSON.stringify(
136
+ { version: 1, source: basename(paths.source) },
137
+ null,
138
+ 2,
139
+ ) + "\n",
140
+ { createNew: true },
141
+ );}
142
+ if (await exists(resolve(paths.stateDir, "render-transaction.json"))) {
143
+ throw new Error(
144
+ "RECOVERY_REQUIRED: inspect the interrupted render transaction before continuing",
145
+ );
146
+ }
147
+ if (await exists(resolve(paths.stateDir, "sync-transaction.json"))) {
148
+ throw new Error(
149
+ "RECOVERY_REQUIRED: inspect the interrupted source synchronization before continuing",
150
+ );
151
+ }
152
+ return await action(paths, new StateStore(paths.stateDir));
153
+ } finally {
154
+ await Deno.remove(lock, { recursive: true });
155
+ }
156
+ }
157
+ export async function renderProject(source: string) {
158
+ return await render(source);
159
+ }
160
+ export interface ExecutedRender {
161
+ ast: Ast;
162
+ sourceHash: string;
163
+ assetDirectories: string[];
164
+ expectedImageAssets?: Record<string, string>;
165
+ expectedRevision?: number | null;
166
+ designHash?: string | null;
167
+ }
168
+ export async function renderProjectFromAst(
169
+ source: string,
170
+ executed: ExecutedRender,
171
+ ) {
172
+ return await render(source, executed);
173
+ }
174
+ /** Palette references are derivations, and must never bypass source ownership. */
175
+ export function assertSourceThemeReferences(
176
+ document: BentoDoc,
177
+ ownership: OwnershipMap,
178
+ allowOwned = false,
179
+ ): void {
180
+ const semantic = new Set([
181
+ "id",
182
+ "type",
183
+ "html",
184
+ "text",
185
+ "content",
186
+ "src",
187
+ "svg",
188
+ "data",
189
+ "option",
190
+ "preset",
191
+ "source",
192
+ "rows",
193
+ "header",
194
+ "columns",
195
+ "cells",
196
+ "code",
197
+ "language",
198
+ "grammarName",
199
+ "latex",
200
+ "notes",
201
+ "title",
202
+ "url",
203
+ ]);
204
+ for (
205
+ const resource of [
206
+ "poster",
207
+ "asset",
208
+ "grammarAssetId",
209
+ "themeAssetId",
210
+ "view",
211
+ "doc",
212
+ "elements",
213
+ "layouts",
214
+ "themeRefs",
215
+ ]
216
+ ) semantic.add(resource);
217
+ function check(holder: JsonObject, key: string) {
218
+ const refs = holder.themeRefs;
219
+ if (!refs || typeof refs !== "object" || Array.isArray(refs)) return;
220
+ const owned = ownership[key] ?? [];
221
+ for (const path of Object.keys(refs)) {
222
+ const root = path.split(".")[0];
223
+ if (semantic.has(root)) {
224
+ throw new Error(
225
+ "UNSUPPORTED_THEME_REFERENCE: palette references cannot derive semantic content, identity or resources.",
226
+ );
227
+ }
228
+ if (!allowOwned && owned.includes("themeRefs")) {
229
+ throw new Error(
230
+ "UNMAPPED_DESIGN: a native palette reference on a source-owned object is retained pending; remove the reference or resolve it explicitly before recomputing.",
231
+ );
232
+ }
233
+ }
234
+ }
235
+ for (const slide of document.slides) {
236
+ check(slide, targetKey({ slideId: slide.id }));
237
+ for (const element of slide.elements) {
238
+ check(element, targetKey({ slideId: slide.id, elementId: element.id }));
239
+ }
240
+ }
241
+ // Pinned Bento also derives references on native reusable slide templates.
242
+ for (const layout of (document.layouts ?? []) as JsonObject[]) {
243
+ check(layout, "");
244
+ for (const element of (layout.elements ?? []) as JsonObject[]) {
245
+ check(element, "");
246
+ }
247
+ }
248
+ }
249
+ /** Execute and project one source snapshot; callers hold the project operation lock. */
250
+ export async function projectProjection(
251
+ source: string,
252
+ state: ProjectState | null | undefined,
253
+ executed?: ExecutedRender,
254
+ ) {
255
+ const paths = projectPaths(source);
256
+ const text = await Deno.readTextFile(paths.source),
257
+ mapping = scanSource(text);
258
+ assertSupportedExecutionSource(mapping);
259
+ const registry = await loadDesignRegistry(paths.directory);
260
+ if (state) assertSourceThemeReferences(state.emitted, state.ownership);
261
+ if (executed?.designHash !== undefined) {
262
+ await assertDesignRegistryUnchanged(paths.directory, executed.designHash);
263
+ }
264
+ if (executed && mapping.hash !== executed.sourceHash) {
265
+ throw new Error(
266
+ "SOURCE_CHANGED: QMD differs from the executed source snapshot",
267
+ );
268
+ }
269
+ if (
270
+ mapping.blocks.some((block) =>
271
+ block.kind === "image" && block.origin === "computed"
272
+ )
273
+ ) {
274
+ throw new Error(
275
+ "UNSUPPORTED_AUTHORED_IMAGE: computed image captions/paths require a verified producer adapter",
276
+ );
277
+ }
278
+ const imageProofs = await readSourceInline(mapping, paths.directory);
279
+ if (executed?.expectedImageAssets) {
280
+ const actual = Object.fromEntries(
281
+ [...imageProofs].filter(([, proof]) => proof.kind === "image").map((
282
+ [id, proof],
283
+ ) => [id, hashSource(proof.image!.data!)]),
284
+ );
285
+ if (!equal(actual, executed.expectedImageAssets)) {
286
+ throw new Error(
287
+ "AUTHORED_IMAGE_ASSET_CHANGED: original source asset changed during Quarto execution; preserve the project and render from a stable asset",
288
+ );
289
+ }
290
+ }
291
+ const build = resolve(paths.stateDir, "build");
292
+ const ast = executed?.ast ?? await executeQuarto(paths.source, build);
293
+ if (hashSource(await Deno.readTextFile(paths.source)) !== mapping.hash) {
294
+ throw new Error(
295
+ "SOURCE_CHANGED: QMD changed during execution; render aborted",
296
+ );
297
+ }
298
+ await assertDesignRegistryUnchanged(paths.directory, registry.hash);
299
+ const widgets = await prepareWidgets(ast, mapping);
300
+ const richTables = await prepareRichTables(ast, mapping);
301
+ const kpiAppearances = retainedKpiAppearances(state, mapping);
302
+ const projected = validateProjectionOrigins(
303
+ mapping,
304
+ await projectAst(
305
+ ast,
306
+ state?.docId ?? crypto.randomUUID(),
307
+ executed?.assetDirectories ?? [build, paths.directory],
308
+ mapping,
309
+ widgets.projections,
310
+ registry.defaults,
311
+ richTables,
312
+ retainedComponentFrames(state, mapping),
313
+ kpiAppearances,
314
+ imageProofs,
315
+ paths.directory,
316
+ ),
317
+ imageProofs,
318
+ );
319
+ const generated = prepareIsolatedDocument(
320
+ projected.document,
321
+ ) as unknown as BentoDoc;
322
+ const provenance: ProvenanceMap = {}, ownership: OwnershipMap = {};
323
+ for (const [slideId, els] of Object.entries(projected.origins)) {
324
+ for (const [elementId, origin] of Object.entries(els)) {
325
+ const block = mapping.blocks.find((b) => b.id === origin.sourceId);
326
+ const declaration = projected.design?.[slideId]?.[elementId];
327
+ const design: JsonObject = {};
328
+ if (declaration) {
329
+ ownership[targetKey({ slideId, elementId })] = [
330
+ ...declaration.ownedPaths,
331
+ ...(declaration.ownedPaths.length ? ["themeRefs"] : []),
332
+ ];
333
+ const element = generated.slides.find((s) => s.id === slideId)!.elements
334
+ .find((e) => e.id === elementId)!;
335
+ for (const [field, binding] of Object.entries(declaration.bindings)) {
336
+ const value = sourceDesignAttribute(
337
+ mapping,
338
+ binding.sourceId,
339
+ binding.attribute,
340
+ );
341
+ if (
342
+ !equal(
343
+ validateDesignValue(binding.attribute, value),
344
+ element[field],
345
+ )
346
+ ) {
347
+ throw new Error(
348
+ "UNMAPPED_DESIGN: executed visual attribute differs from its original source declaration",
349
+ );
350
+ }
351
+ design[field] = {
352
+ blockId: binding.sourceId,
353
+ attribute: binding.attribute,
354
+ value,
355
+ };
356
+ }
357
+ }
358
+ provenance[targetKey({ slideId, elementId })] = {
359
+ origin: origin.designOnly
360
+ ? "authored"
361
+ : block?.origin === "computed"
362
+ ? "computed"
363
+ : origin.origin,
364
+ ...(origin.sourceId ? { semanticId: origin.sourceId } : {}),
365
+ ...(origin.producer ? { producer: origin.producer } : {}),
366
+ source: {
367
+ hash: mapping.hash,
368
+ ...(origin.graph ? { graph: origin.graph } : {}),
369
+ ...(origin.image ? { image: origin.image } : {}),
370
+ ...(origin.richTable ? { richTable: origin.richTable } : {}),
371
+ ...(declaration ? { design } : {}),
372
+ ...(block
373
+ ? {
374
+ inlineCodec: INLINE_CODEC_VERSION,
375
+ ...(block.kind === "image"
376
+ ? { imageCodec: IMAGE_CODEC_VERSION }
377
+ : {}),
378
+ ...(block.kind === "list"
379
+ ? { listCodec: LIST_CODEC_VERSION }
380
+ : {}),
381
+ ...(block.kind === "math"
382
+ ? { mathCodec: MATH_CODEC_VERSION }
383
+ : {}),
384
+ blockId: block.id,
385
+ content: block.content,
386
+ kind: block.kind,
387
+ stable: block.stable,
388
+ ...(block.parentId
389
+ ? {
390
+ parentId: block.parentId,
391
+ siblings: mapping.blocks.filter((b) =>
392
+ b.parentId === block.parentId && b.kind === block.kind
393
+ ).length,
394
+ }
395
+ : {}),
396
+ }
397
+ : {}),
398
+ },
399
+ };
400
+ }
401
+ }
402
+ for (const [slideId, binding] of Object.entries(projected.notes ?? {})) {
403
+ const { block } = sourceNotes(mapping, binding.sourceId, slideId);
404
+ provenance[targetKey({ slideId })] = {
405
+ origin: "authored",
406
+ semanticId: block.id,
407
+ source: {
408
+ hash: mapping.hash,
409
+ blockId: block.id,
410
+ content: block.content,
411
+ kind: "notes",
412
+ stable: true,
413
+ notesCodec: NOTES_CODEC_VERSION,
414
+ },
415
+ };
416
+ }
417
+ if (state) assertSourceThemeReferences(state.emitted, ownership);
418
+ return {
419
+ document: generated,
420
+ provenance,
421
+ ownership,
422
+ sourceHash: mapping.hash,
423
+ designHash: registry.hash,
424
+ diagnostics: [
425
+ ...projected.diagnostics,
426
+ ...missingDesignDiagnostics(mapping, state, registry),
427
+ ],
428
+ widgets,
429
+ mapping,
430
+ kpiAppearances,
431
+ kpiAppearanceRescues: projected.kpiAppearanceRescues ?? [],
432
+ };
433
+ }
434
+ export function assertProjectionIdentity(
435
+ state: ProjectState,
436
+ provenance: ProvenanceMap,
437
+ sourceHash: string,
438
+ ) {
439
+ if (state) {
440
+ for (const [key, old] of Object.entries(state.provenance)) {
441
+ const fresh = provenance[key];
442
+ if (old.source?.hash !== sourceHash && old.origin !== "computed") {
443
+ const safeExplicit = old.source?.stable === true &&
444
+ (!fresh || fresh.semanticId === old.semanticId);
445
+ const safeSingleton = old.source?.parentId &&
446
+ old.source?.siblings === 1 && fresh?.source?.siblings === 1 &&
447
+ fresh?.source?.parentId === old.source.parentId;
448
+ if (!safeExplicit && !safeSingleton) {
449
+ throw new Error(
450
+ "AMBIGUOUS_IDENTITY: source changed around an unpinned element; explicitly pin identities before regenerating retained design",
451
+ );
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ async function render(source: string, executed?: ExecutedRender) {
459
+ return await withProject(source, async (paths, store) => {
460
+ let state = await store.load();
461
+ if (
462
+ executed?.expectedRevision !== undefined &&
463
+ (state?.revision ?? null) !== executed.expectedRevision
464
+ ) {
465
+ throw new Error(
466
+ "STALE_REVISION: project changed while Quarto was executing",
467
+ );
468
+ }
469
+ await assertOutputUnchanged(state, paths.output);
470
+ const projected = await projectProjection(source, state, executed);
471
+ const generated = projected.document, provenance = projected.provenance;
472
+ const approvalPath = resolve(paths.stateDir, "sync-approved.json");
473
+ let approvalText: string | undefined;
474
+ if (await exists(approvalPath)) {
475
+ const info = await Deno.lstat(approvalPath);
476
+ if (!info.isFile || info.isSymlink || !state) {
477
+ throw new Error(
478
+ "INVALID_APPROVAL: expected a saved project and regular approval file",
479
+ );
480
+ }
481
+ approvalText = await Deno.readTextFile(approvalPath);
482
+ const { prepareApprovedSync } = await import("./sync.ts");
483
+ state = prepareApprovedSync(
484
+ state,
485
+ generated,
486
+ projected.sourceHash,
487
+ JSON.parse(approvalText),
488
+ provenance,
489
+ );
490
+ }
491
+ if (state) {
492
+ assertProjectionIdentity(state, provenance, projected.sourceHash);
493
+ }
494
+ if (!state) state = createState(generated, provenance, projected.ownership);
495
+ preserveRescuedKpiGeometry(
496
+ state,
497
+ projected.kpiAppearances,
498
+ projected.kpiAppearanceRescues,
499
+ projected.ownership,
500
+ );
501
+ const composed = compose(state, generated, provenance, projected.ownership);
502
+ await assertGraphTypography(
503
+ composed.document as unknown as RenderDoc,
504
+ composed.state.provenance,
505
+ );
506
+ state = composed.state;
507
+ const kpiDiagnostics = await kpiReadabilityDiagnostics(
508
+ projected.mapping,
509
+ composed.document as unknown as RenderDoc,
510
+ );
511
+ const textDiagnostics = await textOverflowDiagnostics(
512
+ composed.document as unknown as RenderDoc,
513
+ );
514
+ const shell = await loadBentoShell();
515
+ const assembled = spliceDocument(
516
+ shell,
517
+ composed.document as unknown as RenderDoc,
518
+ ISOLATION_BOOTSTRAP,
519
+ OFFLINE_CSP,
520
+ );
521
+ const { html, receipt } = await preparePublication(
522
+ composed.document,
523
+ assembled,
524
+ );
525
+ const widgetPublication = await stageWidgets(
526
+ paths.stateDir,
527
+ receipt.generation,
528
+ state.docId,
529
+ projected.mapping,
530
+ projected.widgets,
531
+ composed.document,
532
+ new Set(
533
+ state.pending.filter((p) => p.origin === "detached").map((p) =>
534
+ targetKey(p.target)
535
+ ),
536
+ ),
537
+ );
538
+ // Recheck immediately before publishing: edits during execution must also survive.
539
+ await assertOutputUnchanged(state, paths.output);
540
+ if (
541
+ hashSource(await Deno.readTextFile(paths.source)) !== projected.sourceHash
542
+ ) {
543
+ throw new Error(
544
+ "SOURCE_CHANGED: QMD changed during projection; publication aborted",
545
+ );
546
+ }
547
+ await assertDesignRegistryUnchanged(paths.directory, projected.designHash);
548
+ const staging = paths.output + ".pending-" + crypto.randomUUID();
549
+ await writeDurable(staging, html);
550
+ await syncDirectory(paths.directory);
551
+ const journal = resolve(paths.stateDir, "render-transaction.json");
552
+ const history = resolve(paths.stateDir, "history");
553
+ await Deno.mkdir(history, { recursive: true });
554
+ const historyInfo = await Deno.lstat(history);
555
+ if (!historyInfo.isDirectory || historyInfo.isSymlink) {
556
+ throw new Error("UNSAFE_STATE_PATH: history must be a real directory");
557
+ }
558
+ const backup = resolve(history, `output-${crypto.randomUUID()}.html`);
559
+ const approval = approvalText === undefined ? undefined : {
560
+ path: approvalPath,
561
+ sha256: await sha256(approvalText),
562
+ archive: resolve(history, `consumed-sync-${crypto.randomUUID()}.json`),
563
+ };
564
+ await writeDurable(
565
+ journal,
566
+ JSON.stringify(
567
+ {
568
+ version: 1,
569
+ staging,
570
+ backup,
571
+ output: paths.output,
572
+ oldRevision: state.revision,
573
+ nextRevision: state.revision + 1,
574
+ sha256: await sha256(html),
575
+ previousOutput: state.output ?? null,
576
+ ...(approval ? { approval } : {}),
577
+ },
578
+ null,
579
+ 2,
580
+ ),
581
+ );
582
+ await syncDirectory(paths.stateDir);
583
+ if (
584
+ hashSource(await Deno.readTextFile(paths.source)) !== projected.sourceHash
585
+ ) {
586
+ throw new Error(
587
+ "SOURCE_CHANGED: QMD changed while preparing publication; recover the retained transaction",
588
+ );
589
+ }
590
+ await assertDesignRegistryUnchanged(paths.directory, projected.designHash);
591
+ if (await exists(paths.output)) {
592
+ // Rename preserves even a native save arriving after the last hash check.
593
+ // Keep this recoverable copy; never discard it as part of publication.
594
+ await Deno.rename(paths.output, backup);
595
+ await syncDirectory(paths.directory);
596
+ await syncDirectory(history);
597
+ if (await sha256(await Deno.readFile(backup)) !== state.output?.sha256) {
598
+ try {
599
+ await Deno.link(backup, paths.output);
600
+ await syncDirectory(paths.directory);
601
+ } catch (e) {
602
+ if (!(e instanceof Deno.errors.AlreadyExists)) throw e;
603
+ }
604
+ throw new Error(
605
+ "UNCAPTURED_HTML_EDITS: concurrent native save preserved in history; recover before render",
606
+ );
607
+ }
608
+ }
609
+ state = await recordPublication(
610
+ state,
611
+ composed.document,
612
+ paths.output,
613
+ html,
614
+ receipt,
615
+ );
616
+ if (widgetPublication) state.widgets = widgetPublication;
617
+ else delete state.widgets;
618
+ state = await store.save(state);
619
+ // Atomic no-clobber publication refuses an output recreated by a native save.
620
+ await Deno.link(staging, paths.output);
621
+ await syncDirectory(paths.directory);
622
+ await Deno.remove(staging);
623
+ await syncDirectory(paths.directory);
624
+ if (approval) {
625
+ if (
626
+ await sha256(await Deno.readFile(approval.path)) !== approval.sha256
627
+ ) {
628
+ throw new Error(
629
+ "STALE_APPROVAL: approval changed during publication; recover the retained transaction",
630
+ );
631
+ }
632
+ await Deno.link(approval.path, approval.archive);
633
+ await syncDirectory(history);
634
+ await Deno.remove(approval.path);
635
+ await syncDirectory(paths.stateDir);
636
+ }
637
+ await Deno.remove(journal);
638
+ await syncDirectory(paths.stateDir);
639
+ return {
640
+ output: paths.output,
641
+ state,
642
+ diagnostics: refreshOverflowDiagnostics(
643
+ composed.document as unknown as RenderDoc,
644
+ [...projected.diagnostics, ...kpiDiagnostics, ...textDiagnostics],
645
+ ),
646
+ ...(widgetPublication
647
+ ? {
648
+ presenter: resolve(
649
+ paths.stateDir,
650
+ "widgets",
651
+ receipt.generation,
652
+ "presenter.html",
653
+ ),
654
+ }
655
+ : {}),
656
+ };
657
+ });
658
+ }
659
+ export async function captureProject(source: string, editedPath?: string) {
660
+ return await withProject(source, async (paths, store) => {
661
+ let state = await store.load();
662
+ if (!state) {
663
+ throw new Error(
664
+ "NO_BASELINE: render this project before capturing local HTML",
665
+ );
666
+ }
667
+ const target = resolve(editedPath || paths.output);
668
+ if (target !== paths.output) {
669
+ await assertOutputUnchanged(state, paths.output);
670
+ }
671
+ const html = await Deno.readTextFile(target);
672
+ const doc = await verifyCapture(state, html);
673
+ assertSourceThemeReferences(doc, {}, true);
674
+ state = captureEdits(state, doc);
675
+ if (target === paths.output) {
676
+ state = await markEmitted(state, doc, paths.output, html);
677
+ }
678
+ state = await store.save(consumeCapture(state));
679
+ return {
680
+ state,
681
+ pending: state.pending.length,
682
+ overrides: state.overrides.length,
683
+ };
684
+ });
685
+ }
686
+ export async function saveProject(
687
+ source: string,
688
+ doc: BentoDoc,
689
+ revision: number,
690
+ expectedSourceHash?: string,
691
+ ) {
692
+ assertProjectDocument(doc);
693
+ assertSourceThemeReferences(doc, {}, true);
694
+ return await withProject(source, async (paths, store) => {
695
+ const state = await store.load();
696
+ if (!state) throw new Error("NO_BASELINE: render first");
697
+ if (state.revision !== revision) {
698
+ throw new StateError(
699
+ "STALE_REVISION",
700
+ "Reload the project before saving newer edits",
701
+ );
702
+ }
703
+ await assertOutputUnchanged(state, paths.output);
704
+ if (
705
+ expectedSourceHash !== undefined &&
706
+ hashSource(await Deno.readTextFile(paths.source)) !== expectedSourceHash
707
+ ) {
708
+ throw new StateError(
709
+ "STALE_SOURCE",
710
+ "QMD changed after checking new identities; reload before saving.",
711
+ );
712
+ }
713
+ if (equal(state.emitted, doc)) return state;
714
+ return await store.save(captureEdits(state, doc));
715
+ });
716
+ }
717
+ export async function readProject(source: string): Promise<ProjectState> {
718
+ const state = await new StateStore(projectPaths(source).stateDir).load();
719
+ if (!state) throw new Error("NO_BASELINE: render this project first");
720
+ return state;
721
+ }
722
+
723
+ /** Read-only Studio diagnostics: no Quarto execution, state writes or source cleanup. */
724
+ export async function readProjectDiagnostics(
725
+ source: string,
726
+ ): Promise<Diagnostic[]> {
727
+ const paths = projectPaths(source);
728
+ const state = await new StateStore(paths.stateDir).load();
729
+ const registry = await loadDesignRegistry(paths.directory);
730
+ const mapping = scanSource(await Deno.readTextFile(paths.source));
731
+ const designDiagnostics = missingDesignDiagnostics(mapping, state, registry);
732
+ if (!state) return designDiagnostics;
733
+ const { pendingComponentDiagnostics } = await import(
734
+ "./component-actions.ts"
735
+ );
736
+ return refreshOverflowDiagnostics(state.emitted as unknown as RenderDoc, [
737
+ ...designDiagnostics,
738
+ ...pendingComponentDiagnostics(state),
739
+ ...await textOverflowDiagnostics(state.emitted as unknown as RenderDoc),
740
+ ]);
741
+ }
742
+
743
+ export async function readProjectPresenter(
744
+ source: string,
745
+ revision: number,
746
+ ): Promise<string> {
747
+ return await withProject(source, async (paths, store) => {
748
+ const state = await store.load();
749
+ if (!state) throw new Error("NO_BASELINE: render first");
750
+ if (state.revision !== revision) {
751
+ throw new StateError(
752
+ "STALE_REVISION",
753
+ "Reload before presenting a newer project snapshot.",
754
+ );
755
+ }
756
+ await assertOutputUnchanged(state, paths.output);
757
+ return await projectPresenter(paths.source, paths.stateDir, state);
758
+ });
759
+ }