@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,457 @@
1
+ import { readSourceInline } from "../render/source-inline.ts";
2
+ import {
3
+ assertCompatibleProducerFilters,
4
+ attachProducerCapture,
5
+ prepareProducerCapture,
6
+ type ProducerManifest,
7
+ } from "../render/producer-capture.ts";
8
+ import {
9
+ assertDesignRegistryUnchanged,
10
+ loadDesignRegistry,
11
+ } from "../render/registry.ts";
12
+ import { basename, dirname, resolve } from "node:path";
13
+ import { extractDocument } from "../render/html.ts";
14
+ import { hashSource, scanSource } from "../source/mod.ts";
15
+ import { assertCompatibleComponentSource } from "../render/components.ts";
16
+ import { assertOutputUnchanged, sha256, StateError } from "../state/mod.ts";
17
+ import {
18
+ assertSourceThemeReferences,
19
+ projectPaths,
20
+ renderProjectFromAst,
21
+ withProject,
22
+ } from "../core/project.ts";
23
+
24
+ import { assertSafeQuartoInvocation, processStartTime } from "./invocation.ts";
25
+
26
+ interface QuartoRun {
27
+ version: 1;
28
+ token: string;
29
+ source: string;
30
+ sourceHash: string;
31
+ designHash?: string | null;
32
+ producerHash?: string;
33
+ imageAssets?: Record<string, string>;
34
+ revision: number | null;
35
+ backupHash: string | null;
36
+ process: { pid: number; startTime: string };
37
+ }
38
+ const receiptName = "quarto-run.json";
39
+ const stageName = "quarto-stage";
40
+ const producerManifestName = "producer-manifest.json";
41
+ const producerOutputName = "producer-output.jsonl";
42
+ async function assertPrivateStageFile(path: string): Promise<void> {
43
+ if (!(await fileExists(path))) return;
44
+ const info = await Deno.lstat(path);
45
+ if (
46
+ !info.isFile || info.isSymlink || (info.nlink !== null && info.nlink > 1)
47
+ ) {
48
+ throw new Error(
49
+ "UNSAFE_STAGE_PATH: producer receipts must be ordinary unlinked files",
50
+ );
51
+ }
52
+ }
53
+ async function clearProducerStage(stateDir: string): Promise<void> {
54
+ const stage = resolve(stateDir, stageName);
55
+ if (!(await fileExists(stage))) return;
56
+ await realDirectory(stage);
57
+ for (const name of [producerManifestName, producerOutputName]) {
58
+ const path = resolve(stateDir, stageName, name);
59
+ if (await fileExists(path)) await Deno.remove(path);
60
+ }
61
+ }
62
+ function backupPath(stateDir: string, run: QuartoRun): string {
63
+ return resolve(stateDir, "history", `quarto-baseline-${run.token}.html`);
64
+ }
65
+ async function fileExists(path: string): Promise<boolean> {
66
+ try {
67
+ await Deno.lstat(path);
68
+ return true;
69
+ } catch (e) {
70
+ if (e instanceof Deno.errors.NotFound) return false;
71
+ throw e;
72
+ }
73
+ }
74
+ async function checkOutput(
75
+ state: Parameters<typeof assertOutputUnchanged>[0],
76
+ output: string,
77
+ ) {
78
+ try {
79
+ await assertOutputUnchanged(state, output);
80
+ } catch (e) {
81
+ if (e instanceof StateError) throw new Error(`${e.code}: ${e.message}`);
82
+ throw e;
83
+ }
84
+ }
85
+ /** inspect includes YAML inheritance; unlike rendering, it does not execute chunks. */
86
+ async function inspectOutput(source: string): Promise<void> {
87
+ const inspected = await new Deno.Command("quarto", {
88
+ args: ["inspect", source],
89
+ cwd: dirname(source),
90
+ stdout: "piped",
91
+ stderr: "piped",
92
+ }).output();
93
+ if (!inspected.success) {
94
+ throw new Error(
95
+ `QUARTO_INSPECT_FAILED: ${new TextDecoder().decode(inspected.stderr)}`,
96
+ );
97
+ }
98
+ const formats =
99
+ JSON.parse(new TextDecoder().decode(inspected.stdout)).formats;
100
+ if (
101
+ !formats || Object.keys(formats).length !== 1 || !formats["qbento-html"]
102
+ ) {
103
+ throw new Error(
104
+ "QBENTO_FORMAT_REQUIRED: select only qbento-html in QMD/project YAML; remove format: html (source remains unchanged)",
105
+ );
106
+ }
107
+ const expected = basename(source).replace(/\.qmd$/, ".qbento-stage.html");
108
+ if (
109
+ formats["qbento-html"].pandoc?.["output-file"] !== expected ||
110
+ formats["qbento-html"].render?.["output-ext"] !== "qbento-stage.html"
111
+ ) {
112
+ throw new Error(
113
+ "UNSAFE_QUARTO_OUTPUT: remove output-file/output-ext overrides; Quarto must write its disposable stage",
114
+ );
115
+ }
116
+ const stageOutput = resolve(dirname(source), expected);
117
+ if (await fileExists(stageOutput)) {
118
+ const info = await Deno.lstat(stageOutput);
119
+ if (
120
+ !info.isFile || info.isSymlink || (info.nlink !== null && info.nlink > 1)
121
+ ) {
122
+ throw new Error(
123
+ "UNSAFE_STAGE_PATH: disposable stage must be an ordinary unlinked file, never an alias to the final deck",
124
+ );
125
+ }
126
+ }
127
+ const outputDir = Deno.env.get("QUARTO_PROJECT_OUTPUT_DIR");
128
+ if (outputDir && resolve(outputDir) !== dirname(source)) {
129
+ throw new Error(
130
+ "UNSUPPORTED_QUARTO_OUTPUT_DIR: keep the default project output directory for qbento",
131
+ );
132
+ }
133
+ }
134
+ async function fileList(name: "INPUT" | "OUTPUT"): Promise<string[]> {
135
+ const file = Deno.env.get(`QUARTO_USE_FILE_FOR_PROJECT_${name}_FILES`);
136
+ const value = file
137
+ ? await Deno.readTextFile(file)
138
+ : Deno.env.get(`QUARTO_PROJECT_${name}_FILES`) ?? "";
139
+ return value.split(/\r?\n/).filter(Boolean);
140
+ }
141
+ async function realDirectory(path: string) {
142
+ await Deno.mkdir(path, { recursive: true });
143
+ const info = await Deno.lstat(path);
144
+ if (!info.isDirectory || info.isSymlink) {
145
+ throw new Error(`UNSAFE_STAGE_PATH: ${path} must be a real directory`);
146
+ }
147
+ }
148
+ async function readReceipt(path: string): Promise<QuartoRun> {
149
+ const value = JSON.parse(await Deno.readTextFile(path));
150
+ if (
151
+ value.version !== 1 || typeof value.token !== "string" ||
152
+ !/^[a-f0-9-]{36}$/.test(value.token) ||
153
+ typeof value.source !== "string" || typeof value.sourceHash !== "string" ||
154
+ !(value.designHash === undefined || value.designHash === null ||
155
+ typeof value.designHash === "string" &&
156
+ /^[a-f0-9]{64}$/.test(value.designHash)) ||
157
+ !(value.producerHash === undefined ||
158
+ typeof value.producerHash === "string" &&
159
+ /^[a-f0-9]{64}$/.test(value.producerHash)) ||
160
+ !(value.imageAssets === undefined ||
161
+ value.imageAssets && typeof value.imageAssets === "object" &&
162
+ !Array.isArray(value.imageAssets) &&
163
+ Object.entries(value.imageAssets).every(([id, digest]) =>
164
+ id && typeof digest === "string" && /^[a-f0-9]{64}$/.test(digest)
165
+ )) ||
166
+ !(value.revision === null || Number.isInteger(value.revision)) ||
167
+ !(value.backupHash === null || typeof value.backupHash === "string") ||
168
+ !Number.isInteger(value.process?.pid) ||
169
+ typeof value.process?.startTime !== "string"
170
+ ) {
171
+ throw new Error("INVALID_QUARTO_RECEIPT: inspect .qbento/quarto-run.json");
172
+ }
173
+ return value;
174
+ }
175
+
176
+ /** A durable receipt prevents overlapping external renders. */
177
+ export async function beforeQuartoRender(): Promise<void> {
178
+ if (Deno.env.get("QBENTO_INTERNAL_RENDER") === "1") return;
179
+ const inputs = await fileList("INPUT");
180
+ if (inputs.length !== 1 || !inputs[0].endsWith(".qmd")) {
181
+ throw new Error(
182
+ "SINGLE_QMD_REQUIRED: qbento project renders exactly one QMD",
183
+ );
184
+ }
185
+ const source = resolve(inputs[0]);
186
+ if (dirname(source) !== Deno.cwd()) {
187
+ throw new Error(
188
+ "SOURCE_AT_PROJECT_ROOT: place the single QMD beside _quarto.yml",
189
+ );
190
+ }
191
+ const process = await assertSafeQuartoInvocation(source);
192
+ await assertCompatibleProducerFilters(source, "qbento-html");
193
+ const original = scanSource(await Deno.readTextFile(source));
194
+ assertCompatibleComponentSource(original);
195
+ if (
196
+ original.blocks.some((block) =>
197
+ block.kind === "image" && block.origin === "computed"
198
+ )
199
+ ) {
200
+ throw new Error(
201
+ "UNSUPPORTED_AUTHORED_IMAGE: computed image captions/paths require a verified producer adapter",
202
+ );
203
+ }
204
+ const imageProofs = original.blocks.some((block) => block.kind === "image")
205
+ ? await readSourceInline(original, dirname(source))
206
+ : new Map();
207
+ const imageAssets = Object.fromEntries(
208
+ [...imageProofs].filter(([, proof]) => proof.kind === "image").map((
209
+ [id, proof],
210
+ ) => [id, hashSource(proof.image!.data!)]),
211
+ );
212
+ const registry = await loadDesignRegistry(dirname(source));
213
+ await inspectOutput(source);
214
+ await withProject(source, async (paths, store) => {
215
+ const state = await store.load();
216
+ if (state) assertSourceThemeReferences(state.emitted, state.ownership);
217
+ await checkOutput(state, paths.output);
218
+ const receipt = resolve(paths.stateDir, receiptName);
219
+ const run: QuartoRun = {
220
+ version: 1,
221
+ token: crypto.randomUUID(),
222
+ source,
223
+ sourceHash: hashSource(await Deno.readTextFile(source)),
224
+ designHash: registry.hash,
225
+ imageAssets,
226
+ revision: state?.revision ?? null,
227
+ backupHash: null,
228
+ process,
229
+ };
230
+ if (run.sourceHash !== original.hash) {
231
+ throw new Error(
232
+ "SOURCE_CHANGED: QMD changed during image/source preflight; execution refused",
233
+ );
234
+ }
235
+ try {
236
+ await Deno.writeTextFile(receipt, JSON.stringify(run, null, 2) + "\n", {
237
+ createNew: true,
238
+ });
239
+ } catch (e) {
240
+ if (e instanceof Deno.errors.AlreadyExists) {
241
+ throw new Error(
242
+ "QUARTO_RENDER_PENDING: another render or failed execution has a receipt; run the explicit Quarto recovery operation after the render stops (final HTML and project edits remain intact)",
243
+ );
244
+ }
245
+ throw e;
246
+ }
247
+ // Independent copy: a hardlink would also be truncated by Pandoc --output.
248
+ if (await fileExists(paths.output)) {
249
+ const bytes = await Deno.readFile(paths.output);
250
+ if (await sha256(bytes) !== state?.output?.sha256) {
251
+ throw new Error(
252
+ "UNCAPTURED_HTML_EDITS: output changed during preflight; execution refused",
253
+ );
254
+ }
255
+ await realDirectory(resolve(paths.stateDir, "history"));
256
+ await Deno.writeFile(backupPath(paths.stateDir, run), bytes, {
257
+ createNew: true,
258
+ });
259
+ run.backupHash = await sha256(bytes);
260
+ await Deno.writeTextFile(receipt, JSON.stringify(run, null, 2) + "\n");
261
+ }
262
+ const stage = resolve(paths.stateDir, stageName);
263
+ await realDirectory(stage);
264
+ const capture = await prepareProducerCapture(source, {
265
+ environment: false,
266
+ });
267
+ try {
268
+ if (capture.manifest.sourceHash !== run.sourceHash) {
269
+ throw new Error(
270
+ "QUARTO_SOURCE_MISMATCH: source changed while producer receipts were prepared",
271
+ );
272
+ }
273
+ const manifestPath = resolve(stage, producerManifestName);
274
+ const outputReceiptPath = resolve(stage, producerOutputName);
275
+ await assertPrivateStageFile(manifestPath);
276
+ await assertPrivateStageFile(outputReceiptPath);
277
+ capture.manifest.outputReceiptPath = outputReceiptPath;
278
+ const bytes = new TextEncoder().encode(
279
+ JSON.stringify(capture.manifest) + "\n",
280
+ );
281
+ await Deno.writeTextFile(outputReceiptPath, "");
282
+ await Deno.writeFile(manifestPath, bytes);
283
+ run.producerHash = await sha256(bytes);
284
+ await Deno.writeTextFile(receipt, JSON.stringify(run, null, 2) + "\n");
285
+ } finally {
286
+ await capture.cleanup();
287
+ }
288
+ try {
289
+ await Deno.remove(resolve(stage, "executed.json"));
290
+ } catch (e) {
291
+ if (!(e instanceof Deno.errors.NotFound)) throw e;
292
+ }
293
+ });
294
+ }
295
+
296
+ /** Consume the actual executed AST once; never execute Quarto again here. */
297
+ export async function afterQuartoRender(): Promise<void> {
298
+ if (Deno.env.get("QBENTO_INTERNAL_RENDER") === "1") return;
299
+ const outputs = await fileList("OUTPUT");
300
+ const staged = outputs.filter((path) => path.endsWith(".qbento-stage.html"));
301
+ if (staged.length !== 1 || outputs.length !== 1) {
302
+ throw new Error(
303
+ "QBENTO_FORMAT_REQUIRED: render the single qbento-html format",
304
+ );
305
+ }
306
+ const stateDir = resolve(Deno.cwd(), ".qbento");
307
+ const receipt = resolve(stateDir, receiptName);
308
+ const run = await readReceipt(receipt);
309
+ const paths = projectPaths(run.source);
310
+ if (
311
+ paths.stateDir !== stateDir ||
312
+ basename(staged[0]) !==
313
+ basename(run.source).replace(/\.qmd$/, ".qbento-stage.html")
314
+ ) {
315
+ throw new Error(
316
+ "QUARTO_SOURCE_MISMATCH: stage and receipt must belong to this project",
317
+ );
318
+ }
319
+ const captured = JSON.parse(
320
+ await Deno.readTextFile(resolve(stateDir, stageName, "executed.json")),
321
+ );
322
+ if (captured.token !== run.token || !Array.isArray(captured.ast?.blocks)) {
323
+ throw new Error(
324
+ "STALE_QUARTO_AST: stage was not produced by this execution",
325
+ );
326
+ }
327
+ await realDirectory(resolve(stateDir, stageName));
328
+ const manifestPath = resolve(stateDir, stageName, producerManifestName);
329
+ const outputReceiptPath = resolve(stateDir, stageName, producerOutputName);
330
+ await assertPrivateStageFile(manifestPath);
331
+ await assertPrivateStageFile(outputReceiptPath);
332
+ const manifestBytes = await Deno.readFile(manifestPath);
333
+ if (!run.producerHash || await sha256(manifestBytes) !== run.producerHash) {
334
+ throw new Error(
335
+ "INVALID_PRODUCER_RECEIPT: producer manifest differs from the guarded Quarto run",
336
+ );
337
+ }
338
+ const manifest: ProducerManifest = JSON.parse(
339
+ new TextDecoder().decode(manifestBytes),
340
+ );
341
+ if (
342
+ manifest.sourceHash !== run.sourceHash ||
343
+ manifest.outputReceiptPath !== outputReceiptPath ||
344
+ manifest.input !== run.source.replace(/\.qmd$/, ".rmarkdown")
345
+ ) {
346
+ throw new Error(
347
+ "INVALID_PRODUCER_RECEIPT: producer manifest belongs to another source or stage",
348
+ );
349
+ }
350
+ const tokens: string[] = (await Deno.readTextFile(outputReceiptPath)).split(
351
+ "\n",
352
+ ).filter(Boolean).map((line) => {
353
+ const value = JSON.parse(line);
354
+ if (typeof value !== "string") {
355
+ throw new Error("INVALID_PRODUCER_RECEIPT: malformed execution ledger");
356
+ }
357
+ return value;
358
+ });
359
+ captured.ast = attachProducerCapture(captured.ast, manifest, tokens);
360
+ if (run.designHash !== undefined) {
361
+ await assertDesignRegistryUnchanged(paths.directory, run.designHash);
362
+ }
363
+ const result = await renderProjectFromAst(run.source, {
364
+ ast: captured.ast,
365
+ sourceHash: run.sourceHash,
366
+ assetDirectories: [dirname(resolve(staged[0])), paths.directory],
367
+ expectedImageAssets: run.imageAssets ?? {},
368
+ expectedRevision: run.revision,
369
+ designHash: run.designHash,
370
+ });
371
+ await clearProducerStage(stateDir);
372
+ await Deno.remove(receipt);
373
+ console.log(`Bento output created: ${result.output}`);
374
+ for (const diagnostic of result.diagnostics) {
375
+ console.warn(`${diagnostic.code}: ${diagnostic.message}`);
376
+ }
377
+ }
378
+
379
+ /** Explicit recovery after a failed external Quarto run. Retains both outputs. */
380
+ export async function recoverQuartoRender(
381
+ source: string,
382
+ ): Promise<{ restored: boolean; retainedOutput: string | null }> {
383
+ return await withProject(source, async (paths, store) => {
384
+ const receipt = resolve(paths.stateDir, receiptName);
385
+ const run = await readReceipt(receipt);
386
+ if (Deno.build.os !== "linux") {
387
+ throw new Error(
388
+ "QUARTO_RECOVERY_PLATFORM: verify interrupted render manually on this platform",
389
+ );
390
+ }
391
+ if (await processStartTime(run.process.pid) === run.process.startTime) {
392
+ throw new Error(
393
+ "QUARTO_RENDER_ACTIVE: the original render process is still running",
394
+ );
395
+ }
396
+ if (run.source !== paths.source) {
397
+ throw new Error(
398
+ "QUARTO_SOURCE_MISMATCH: recovery source differs from receipt",
399
+ );
400
+ }
401
+ const state = await store.load();
402
+ if ((state?.revision ?? null) !== run.revision) {
403
+ throw new Error(
404
+ "STALE_REVISION: project changed since this Quarto render; inspect receipt and history manually",
405
+ );
406
+ }
407
+ // Recovery may rename an interrupted publication into history and write a
408
+ // restored baseline through stage. Verify both children before reading the
409
+ // backup or mutating either path; an imported .qbento subtree is untrusted.
410
+ await realDirectory(resolve(paths.stateDir, "history"));
411
+ await realDirectory(resolve(paths.stateDir, stageName));
412
+ let retainedOutput: string | null = null;
413
+ if (run.backupHash) {
414
+ const bytes = await Deno.readFile(backupPath(paths.stateDir, run));
415
+ if (await sha256(bytes) !== run.backupHash) {
416
+ throw new Error("INVALID_QUARTO_BACKUP: baseline checksum mismatch");
417
+ }
418
+ if (await fileExists(paths.output)) {
419
+ if (
420
+ await sha256(await Deno.readFile(paths.output)) !== run.backupHash
421
+ ) {
422
+ // Valid native edits remain in place for explicit capture after recovery.
423
+ let nativeEdit = false;
424
+ try {
425
+ nativeEdit =
426
+ extractDocument(await Deno.readTextFile(paths.output)).docId ===
427
+ state?.docId;
428
+ } catch { /* Truncated outputs are retained separately below. */ }
429
+ if (nativeEdit) {
430
+ await clearProducerStage(paths.stateDir);
431
+ await Deno.remove(receipt);
432
+ return { restored: false, retainedOutput: paths.output };
433
+ }
434
+ retainedOutput = resolve(
435
+ paths.stateDir,
436
+ "history",
437
+ `quarto-interrupted-${crypto.randomUUID()}.html`,
438
+ );
439
+ await Deno.rename(paths.output, retainedOutput);
440
+ }
441
+ }
442
+ if (!(await fileExists(paths.output))) {
443
+ const staging = resolve(
444
+ paths.stateDir,
445
+ stageName,
446
+ `recovered-${crypto.randomUUID()}.html`,
447
+ );
448
+ await Deno.writeFile(staging, bytes, { createNew: true });
449
+ await Deno.link(staging, paths.output);
450
+ await Deno.remove(staging);
451
+ }
452
+ }
453
+ await clearProducerStage(paths.stateDir);
454
+ await Deno.remove(receipt);
455
+ return { restored: run.backupHash !== null, retainedOutput };
456
+ });
457
+ }
@@ -0,0 +1,66 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ export async function assetData(
3
+ src: string,
4
+ assetRoots: string[],
5
+ ): Promise<string> {
6
+ // UNC and device paths must refuse before realPath can contact a share on Windows.
7
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || /^[/\\]{2}/.test(src)) {
8
+ throw new Error("EXTERNAL_ASSET: local bundled image required");
9
+ }
10
+ try {
11
+ src = decodeURIComponent(src);
12
+ } catch {
13
+ throw new Error("INVALID_ASSET_PATH: malformed escaped local image path");
14
+ }
15
+ if (
16
+ /^[a-z][a-z0-9+.-]*:/i.test(src) || /^[/\\]{2}/.test(src) ||
17
+ src.includes("\0")
18
+ ) throw new Error("EXTERNAL_ASSET: local bundled image required");
19
+ for (const root of assetRoots) {
20
+ try {
21
+ const candidate = resolve(root, src);
22
+ const lexical = relative(resolve(root), candidate);
23
+ if (
24
+ lexical === ".." || lexical.startsWith("../") ||
25
+ lexical.startsWith("..\\") || isAbsolute(lexical)
26
+ ) continue;
27
+ const actual = await Deno.realPath(candidate);
28
+ const rel = relative(await Deno.realPath(root), actual);
29
+ if (
30
+ rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") ||
31
+ isAbsolute(rel)
32
+ ) continue;
33
+ const bytes = await Deno.readFile(actual);
34
+ const extension = actual.split(".").pop()?.toLowerCase();
35
+ const mime = ({
36
+ svg: "image/svg+xml",
37
+ png: "image/png",
38
+ jpg: "image/jpeg",
39
+ jpeg: "image/jpeg",
40
+ webp: "image/webp",
41
+ } as Record<string, string>)[extension || ""];
42
+ if (!mime) {
43
+ throw new Error("UNSUPPORTED_ASSET: image format is not supported");
44
+ }
45
+ if (extension === "svg") {
46
+ const text = new TextDecoder().decode(bytes);
47
+ if (
48
+ /<(?:script|foreignObject)\b|\bon\w+\s*=|(?:href|src)\s*=\s*["']\s*(?:https?:|\/\/|javascript:)|<!ENTITY/i
49
+ .test(text)
50
+ ) {
51
+ throw new Error(
52
+ "UNSAFE_SVG: scripts, external references and entities are not supported",
53
+ );
54
+ }
55
+ }
56
+ let binary = "";
57
+ for (const byte of bytes) binary += String.fromCharCode(byte);
58
+ return `data:${mime};base64,${btoa(binary)}`;
59
+ } catch (error) {
60
+ if (!(error instanceof Deno.errors.NotFound)) throw error;
61
+ }
62
+ }
63
+ throw new Error(
64
+ `MISSING_ASSET: ${src} is not within the source/build directory`,
65
+ );
66
+ }
@@ -0,0 +1,8 @@
1
+ -- Runs after Quarto's HTML transforms, so figures have concrete output assets.
2
+ function Pandoc(doc)
3
+ local path = os.getenv('QBENTO_AST')
4
+ if not path then error('QBENTO_AST is required') end
5
+ local out = assert(io.open(path, 'w'))
6
+ out:write(pandoc.write(pandoc.Pandoc(doc.blocks, { title = doc.meta.title }), 'json'))
7
+ out:close()
8
+ end
@@ -0,0 +1,165 @@
1
+ /** Bounded chart contract for the actual pinned charts-lite renderer. */
2
+ import { type PlainTable, plainTable } from "./table.ts";
3
+ import type { Element, Json, Node } from "./types.ts";
4
+ import type { Frame } from "./design.ts";
5
+ export type ChartKind = "bar" | "line";
6
+ function fail(message: string): never {
7
+ throw new Error(`UNSUPPORTED_CHART: ${message}`);
8
+ }
9
+ /** Never use native tableChartColumns: its parseFloat/missing->zero loses data. */
10
+ export function chartNumber(text: string): number {
11
+ if (!/^-?(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(text)) {
12
+ fail(
13
+ "Values must be plain decimal numbers with at most two decimal places; missing values, percentages, exponents and locale separators need another adapter.",
14
+ );
15
+ }
16
+ const value = Number(text);
17
+ if (
18
+ !Number.isFinite(value) || Math.abs(value) > 1e6 || Object.is(value, -0)
19
+ ) {
20
+ fail(
21
+ "Values must be finite, within -1000000..1000000, and not negative zero.",
22
+ );
23
+ }
24
+ return value;
25
+ }
26
+ export function chartOption(
27
+ table: PlainTable,
28
+ kind: ChartKind,
29
+ frame: Pick<Frame, "w" | "h"> = { w: 1120, h: 460 },
30
+ ): Record<string, Json> {
31
+ if (kind !== "bar" && kind !== "line") fail("Choose bar or line explicitly.");
32
+ if (!table.header || table.columns.length < 2 || table.columns.length > 5) {
33
+ fail(
34
+ "Use a header row, one category column and one to four numeric series.",
35
+ );
36
+ }
37
+ const header = table.rows[0], body = table.rows.slice(1);
38
+ if (header[0].text !== "") {
39
+ fail(
40
+ "The first column header must be empty: pinned Bento does not render a category-axis title, so a meaningful title cannot be silently dropped.",
41
+ );
42
+ }
43
+ if (body.length < (kind === "line" ? 2 : 1) || body.length > 8) {
44
+ fail("Use one to eight categories for bars, or two to eight for lines.");
45
+ }
46
+ const names = header.slice(1).map((cell) => cell.text);
47
+ const labels = body.map((row) => row[0].text);
48
+ for (const values of [names, labels]) {
49
+ if (
50
+ values.some((value) => !value.trim() || /[\r\n\t\p{Cc}]/u.test(value)) ||
51
+ new Set(values).size !== values.length
52
+ ) {
53
+ fail(
54
+ "Category labels and series names must be nonempty, unique plain text.",
55
+ );
56
+ }
57
+ }
58
+ const width = frame.w, fontSize = 14, plotWidth = width - 112 - 24;
59
+ // Pinned charts-lite drops category labels below a 56px band. Six value-axis
60
+ // ticks also need actual vertical space: never resize only an outer box.
61
+ if (
62
+ !Number.isFinite(width) || !Number.isFinite(frame.h) ||
63
+ plotWidth < labels.length * 56 || frame.h < 242
64
+ ) {
65
+ fail(
66
+ "The final chart frame needs at least 56px per category and 242px height for readable axes and legend.",
67
+ );
68
+ }
69
+ if (
70
+ labels.some((s) =>
71
+ Array.from(s).length * fontSize * 2 > plotWidth / labels.length
72
+ )
73
+ ) {
74
+ fail(
75
+ "Category labels exceed the conservative single-line space; shorten the labels or use fewer categories.",
76
+ );
77
+ }
78
+ if (
79
+ names.reduce((n, s) => n + Array.from(s).length * fontSize * 2 + 40, 0) >
80
+ width - 32
81
+ ) {
82
+ fail("Series names exceed the conservative single-line legend space.");
83
+ }
84
+ const series = names.map((name, index) => ({
85
+ type: kind,
86
+ name,
87
+ data: body.map((row) => chartNumber(row[index + 1].text)),
88
+ ...(kind === "line" ? { smooth: false } : {}),
89
+ }));
90
+ const values = series.flatMap((s) => s.data);
91
+ const min = Math.min(0, Math.floor(Math.min(...values)));
92
+ let max = Math.max(0, Math.ceil(Math.max(...values)));
93
+ if (max === min) max = min + 1;
94
+ // Integer bounds yield six ticks at fifths, accurately shown by native fmt(2dp).
95
+ return {
96
+ textStyle: { fontFamily: "Arial, sans-serif" },
97
+ tooltip: { trigger: "axis" },
98
+ legend: { bottom: 0, textStyle: { fontSize } },
99
+ grid: { left: 112, right: 24, top: 24, bottom: 78 },
100
+ xAxis: { type: "category", data: labels, axisLabel: { fontSize } },
101
+ yAxis: { type: "value", min, max, axisLabel: { fontSize } },
102
+ series,
103
+ };
104
+ }
105
+ export function projectChart(
106
+ table: PlainTable,
107
+ kind: ChartKind,
108
+ id: string,
109
+ y = 166,
110
+ contentFrame?: Frame,
111
+ ): Element {
112
+ const frame = contentFrame ?? { x: 80, y, w: 1120, h: 460 };
113
+ if (
114
+ frame.x < 0 || frame.x + frame.w > 1280 || frame.y < 0 ||
115
+ frame.y + frame.h > 720
116
+ ) {
117
+ fail(
118
+ "The chart does not fit in the remaining slide area; place it on a separate slide.",
119
+ );
120
+ }
121
+ return {
122
+ id,
123
+ type: "chart",
124
+ preset: kind,
125
+ option: chartOption(table, kind, frame),
126
+ ...frame,
127
+ rotation: 0,
128
+ opacity: 1,
129
+ };
130
+ }
131
+ /** Execute no guessing/fallback: a single table under only Quarto cell wrappers. */
132
+ export function executedChartTable(
133
+ nodes: Node[],
134
+ ): { table: PlainTable; computed: boolean } {
135
+ let found: { table: PlainTable; computed: boolean } | undefined;
136
+ function walk(nodes: Node[], computed: boolean) {
137
+ for (const node of nodes) {
138
+ if (node.t === "Table") {
139
+ if (found) fail("Use exactly one table per chart producer.");
140
+ found = { table: plainTable(node), computed };
141
+ } else if (node.t === "Div") {
142
+ const [attr, children] = node.c as [
143
+ [string, string[], [string, string][]],
144
+ Node[],
145
+ ];
146
+ if (
147
+ attr[2].length || !attr[1].length ||
148
+ attr[1].some((c) => !["cell", "cell-output-display"].includes(c))
149
+ ) {
150
+ fail(
151
+ "Only plain Quarto cell/output wrappers may surround the chart data table; captions and floats need another adapter.",
152
+ );
153
+ }
154
+ walk(children, computed || attr[1].includes("cell"));
155
+ } else {fail(
156
+ `Chart data must be exactly one plain table; ${node.t} cannot be dropped.`,
157
+ );}
158
+ }
159
+ }
160
+ walk(nodes, false);
161
+ return found ??
162
+ fail(
163
+ "Chart data must contain one table; images cannot be inferred as native data.",
164
+ );
165
+ }