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