@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,174 @@
1
+ /** Frame adapters for existing simple native content; never rewrite semantic fields. */
2
+ import type { BentoElement, JsonObject } from "../state/types.ts";
3
+ import { canonical } from "../state/json.ts";
4
+ import { StateError } from "../state/types.ts";
5
+ import { type ChartKind, chartOption } from "./chart.ts";
6
+ import { type PlainTable, tableHeight } from "./table.ts";
7
+ import { escapeHtml } from "./html.ts";
8
+ import type { Frame } from "./design.ts";
9
+ function unsupported(message: string): never {
10
+ throw new StateError("UNSUPPORTED_LAYOUT_CONTENT", message);
11
+ }
12
+ function plainText(html: unknown): string {
13
+ if (typeof html !== "string") {
14
+ return unsupported("A table cell needs plain text.");
15
+ }
16
+ const text = html.replace(
17
+ /&(?:amp|lt|gt|quot|#39);/g,
18
+ (entity) =>
19
+ ({ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" })[
20
+ entity
21
+ ]!,
22
+ );
23
+ if (escapeHtml(text) !== html) {
24
+ unsupported("Rich table cells need their own measured adapter.");
25
+ }
26
+ return text;
27
+ }
28
+ /** Validate the actual saved metrics, not an assumed projection before overrides. */
29
+ export function nativeLayoutFrame(element: BentoElement, slot: Frame): Frame {
30
+ const refs = element.themeRefs;
31
+ if (
32
+ refs && typeof refs === "object" && !Array.isArray(refs) &&
33
+ Object.keys(refs).some((path) =>
34
+ ["x", "y", "w", "h", "rotation"].includes(path) ||
35
+ ["table", "chart"].includes(element.type) &&
36
+ /^(?:style(?:\.|$)|font|lineHeight)/.test(path)
37
+ )
38
+ ) {
39
+ return unsupported(
40
+ "This object's frame or measured metrics derive from a theme reference. Resolve that reference explicitly before applying a layout.",
41
+ );
42
+ }
43
+ if (element.rotation !== undefined && element.rotation !== 0) {
44
+ return unsupported(
45
+ "Reset rotation before placing this object in a column.",
46
+ );
47
+ }
48
+ if (["text", "code", "image"].includes(element.type)) return { ...slot };
49
+ if (element.type === "table") {
50
+ const style = element.style as JsonObject;
51
+ if (
52
+ !style || style.fontSize !== 18 || style.cellPadX !== 16 ||
53
+ style.cellPadY !== 11 || style.borderWidth !== 1
54
+ ) {
55
+ return unsupported(
56
+ "This table has custom cell metrics; retain its design or restore the simple table metrics before applying a layout.",
57
+ );
58
+ }
59
+ const columns = element.columns as JsonObject[],
60
+ rows = element.rows as JsonObject[];
61
+ if (
62
+ !Array.isArray(columns) || !columns.length || !Array.isArray(rows) ||
63
+ !rows.length
64
+ ) return unsupported("Malformed simple table.");
65
+ const table: PlainTable = {
66
+ header: element.header === true,
67
+ columns: columns.map((column) => {
68
+ if (
69
+ typeof column.w !== "number" || !Number.isFinite(column.w) ||
70
+ column.w <= 0
71
+ ) return unsupported("Invalid table column weight.");
72
+ return { width: column.w, align: "left" };
73
+ }),
74
+ rows: rows.map((row) => {
75
+ const cells = row.cells as JsonObject[];
76
+ if (!Array.isArray(cells) || cells.length !== columns.length) {
77
+ return unsupported("Use a rectangular simple table.");
78
+ }
79
+ return cells.map((cell) => ({
80
+ text: plainText(cell.html),
81
+ align: "left",
82
+ }));
83
+ }),
84
+ };
85
+ return { ...slot, h: tableHeight(table, slot) };
86
+ }
87
+ if (element.type === "chart") {
88
+ const option = element.option as JsonObject,
89
+ axis = option?.xAxis as JsonObject;
90
+ const labels = axis?.data, series = option?.series as JsonObject[];
91
+ const kind = element.preset as ChartKind;
92
+ if (
93
+ !Array.isArray(labels) || !Array.isArray(series) || !series.length ||
94
+ !["bar", "line"].includes(kind)
95
+ ) return unsupported("Use a simple bar or line chart.");
96
+ const cell = (text: unknown) => {
97
+ if (typeof text !== "string") {
98
+ return unsupported("Chart labels must be plain text.");
99
+ }
100
+ return { text, align: "left" as const };
101
+ };
102
+ const table: PlainTable = {
103
+ header: true,
104
+ columns: Array.from(
105
+ { length: series.length + 1 },
106
+ () => ({ width: 1, align: "left" }),
107
+ ),
108
+ rows: [
109
+ [cell(""), ...series.map((s) => cell(s.name))],
110
+ ...labels.map((label, index) => [
111
+ cell(label),
112
+ ...series.map((s) => {
113
+ const values = s.data;
114
+ if (
115
+ !Array.isArray(values) || values.length !== labels.length ||
116
+ typeof values[index] !== "number"
117
+ ) {
118
+ return unsupported(
119
+ "Chart data needs the simple numeric adapter.",
120
+ );
121
+ }
122
+ return cell(String(values[index]));
123
+ }),
124
+ ]),
125
+ ],
126
+ };
127
+ // This both validates final axes/legend space and prevents silently dropping
128
+ // custom native option fields while interpreting a different chart grammar.
129
+ if (canonical(chartOption(table, kind, slot)) !== canonical(option)) {
130
+ return unsupported(
131
+ "This chart has custom options; its layout needs a matching measured adapter.",
132
+ );
133
+ }
134
+ return { ...slot };
135
+ }
136
+ return unsupported(`No reusable-layout frame adapter for ${element.type}.`);
137
+ }
138
+
139
+ /** Conservative native table/chart metrics on the final saved frame, after overlays. */
140
+ export function nativeLayoutDiagnostics(
141
+ document: Pick<import("./types.ts").BentoDoc, "slides">,
142
+ ): import("./types.ts").Diagnostic[] {
143
+ const diagnostics: import("./types.ts").Diagnostic[] = [];
144
+ for (const slide of document.slides) {
145
+ for (const element of slide.elements) {
146
+ if (!["table", "chart"].includes(element.type)) continue;
147
+ try {
148
+ nativeLayoutFrame(element, {
149
+ x: Number(element.x),
150
+ y: Number(element.y),
151
+ w: Number(element.w),
152
+ h: Number(element.h),
153
+ });
154
+ } catch (error) {
155
+ const detail = String(error);
156
+ const overflow = detail.includes("UNSUPPORTED_TABLE_LAYOUT") ||
157
+ /final chart frame|Category labels exceed|Series names exceed/.test(
158
+ detail,
159
+ );
160
+ diagnostics.push({
161
+ code: overflow
162
+ ? "NATIVE_CONTENT_OVERFLOW"
163
+ : "NATIVE_CONTENT_UNMEASURED",
164
+ slideId: slide.id,
165
+ elementId: element.id,
166
+ message: overflow
167
+ ? `The current ${element.type} content no longer fits the saved frame under its conservative native metrics. Enlarge it or reapply a roomier layout; saved geometry was preserved. ${detail}`
168
+ : `The saved ${element.type} uses metrics outside the simple native layout adapter; inspect it in Bento. Saved geometry was preserved. ${detail}`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return diagnostics;
174
+ }
@@ -0,0 +1,319 @@
1
+ import { producerEnvironment } from "./producer-environment.ts";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { hashSource, scanSource } from "../source/mod.ts";
5
+ import type { Ast, Node } from "./types.ts";
6
+ export interface ProducerManifest {
7
+ version: 1;
8
+ nonce: string;
9
+ sourceHash: string;
10
+ input: string;
11
+ outputReceiptPath?: string;
12
+ chunks: {
13
+ index: number;
14
+ id: string;
15
+ slideId?: string;
16
+ label: string;
17
+ code: string;
18
+ }[];
19
+ }
20
+ function assertProducerStartup() {
21
+ const args = (Deno.env.get("QUARTO_KNITR_RSCRIPT_ARGS") ?? "").split(",").map(
22
+ (arg) => arg.trim(),
23
+ );
24
+ if (args.includes("--vanilla") || args.includes("--no-init-file")) {
25
+ throw new Error(
26
+ "UNSUPPORTED_PRODUCER_STARTUP: QUARTO_KNITR_RSCRIPT_ARGS disables the original-producer adapter with --vanilla/--no-init-file; use an R startup that loads the adapter or a separately verified execution transport (flags remain unchanged)",
27
+ );
28
+ }
29
+ }
30
+ export async function prepareProducerCapture(
31
+ source: string,
32
+ options: { environment?: boolean } = {},
33
+ ) {
34
+ const text = await Deno.readTextFile(source), document = scanSource(text);
35
+ if (document.blocks.some((block) => block.kind === "chunk")) {
36
+ assertProducerStartup();
37
+ }
38
+ if (
39
+ /(?:\bqbento-producer-proof\s*[=:]|\.qbento-execution\b)/m.test(text)
40
+ ) {
41
+ throw new Error(
42
+ "RESERVED_PRODUCER_MARKER: source cannot declare internal execution receipts",
43
+ );
44
+ }
45
+ let unnamed = 0;
46
+ const manifest: ProducerManifest = {
47
+ version: 1,
48
+ nonce: crypto.randomUUID(),
49
+ sourceHash: document.hash,
50
+ input: resolve(
51
+ dirname(source),
52
+ basename(source).replace(/\.[^.]*$/, ".rmarkdown"),
53
+ ),
54
+ chunks: document.blocks.filter((b) => b.kind === "chunk").map(
55
+ (block, index) => {
56
+ const label = block.content.match(
57
+ /^\s*#\|\s*label:\s*["']?([A-Za-z][\w:.-]*)["']?\s*$/m,
58
+ )?.[1] ??
59
+ block.raw.split(/\r?\n/)[0].match(
60
+ /\{\w+[ ,]+([A-Za-z][\w:.-]*)(?:[, }]|$)/,
61
+ )?.[1] ?? `unnamed-chunk-${++unnamed}`;
62
+ const indent = block.raw.match(/^( {0,3})(?:`{3,}|~{3,})/)?.[1] ?? "";
63
+ const code = block.content.replace(/\r\n|\r/g, "\n").split("\n").map(
64
+ (line) =>
65
+ indent && line.startsWith(indent)
66
+ ? line.slice(indent.length)
67
+ : line,
68
+ ).filter((line) => !/^\s*#\|/.test(line)).join("\n").replace(/\n$/, "");
69
+ return {
70
+ index,
71
+ id: block.id,
72
+ ...(block.slideId ? { slideId: block.slideId } : {}),
73
+ label,
74
+ code,
75
+ };
76
+ },
77
+ ),
78
+ };
79
+ const startup = options.environment === false || manifest.chunks.length === 0
80
+ ? {
81
+ effective: {} as Record<string, string>,
82
+ originalProfile: undefined,
83
+ args: [] as string[],
84
+ }
85
+ : await producerEnvironment({ cwd: dirname(source) });
86
+ const receiptDir = await Deno.makeTempDir({ prefix: "qbento-producer-" });
87
+ const path = resolve(receiptDir, "manifest.json");
88
+ manifest.outputReceiptPath = resolve(receiptDir, "emitted.jsonl");
89
+ await Deno.writeTextFile(manifest.outputReceiptPath, "");
90
+ await Deno.writeTextFile(path, JSON.stringify(manifest));
91
+ const original = startup.originalProfile;
92
+ return {
93
+ manifest,
94
+ emitted: async () =>
95
+ (await Deno.readTextFile(manifest.outputReceiptPath!)).split("\n").filter(
96
+ Boolean,
97
+ ).map((line) => JSON.parse(line) as string),
98
+ cleanup: () => Deno.remove(receiptDir, { recursive: true }),
99
+ env: manifest.chunks.length === 0 ? {} : {
100
+ ...startup.effective,
101
+ QUARTO_KNITR_RSCRIPT_ARGS: [
102
+ ...startup.args,
103
+ "--no-environ",
104
+ ].filter(Boolean).join(","),
105
+ R_PROFILE_USER: fileURLToPath(
106
+ new URL("./producer-profile.R", import.meta.url),
107
+ ),
108
+ ...(startup.args.includes("--no-site-file") ? {} : {
109
+ R_PROFILE: fileURLToPath(
110
+ new URL("./producer-site-profile.R", import.meta.url),
111
+ ),
112
+ }),
113
+ QBENTO_ORIGINAL_R_SITE_SET: startup.effective.R_PROFILE === undefined
114
+ ? "0"
115
+ : "1",
116
+ QBENTO_ORIGINAL_R_SITE: startup.effective.R_PROFILE ?? "",
117
+ QBENTO_PRODUCER_PROFILE: fileURLToPath(
118
+ new URL("./producer-profile.R", import.meta.url),
119
+ ),
120
+ QBENTO_ORIGINAL_R_PROFILE_SET: original === undefined ? "0" : "1",
121
+ QBENTO_ORIGINAL_R_PROFILE_USER: original ?? "",
122
+ QBENTO_PRODUCER_MANIFEST: path,
123
+ },
124
+ };
125
+ }
126
+ // Receipts are trusted only after the execution boundary attaches a manifest.
127
+ // A serialized AST or a filter-authored attribute cannot self-declare that trust.
128
+ const captures = new WeakMap<Ast, ProducerManifest>();
129
+ export function producerReceipt(ast: Ast, sourceHash: string, token: string) {
130
+ const manifest = captures.get(ast);
131
+ if (
132
+ !manifest || manifest.version !== 1 || manifest.sourceHash !== sourceHash ||
133
+ !token.startsWith(manifest.nonce + ":")
134
+ ) {
135
+ throw new Error(
136
+ "INVALID_PRODUCER_RECEIPT: execution receipt does not match the original source and invocation",
137
+ );
138
+ }
139
+ const chunk = manifest.chunks.find((c) =>
140
+ token === `${manifest.nonce}:${c.index}`
141
+ );
142
+ if (!chunk) {
143
+ throw new Error(
144
+ "INVALID_PRODUCER_RECEIPT: execution receipt has no original chunk",
145
+ );
146
+ }
147
+ return chunk;
148
+ }
149
+ export function attachProducerCapture(
150
+ ast: Ast,
151
+ manifest: ProducerManifest,
152
+ expectedTokens?: string[],
153
+ ): Ast {
154
+ const seen = new Set<string>();
155
+ const sequence: string[] = [];
156
+ const attached = { ...ast };
157
+ captures.set(attached, structuredClone(manifest));
158
+ function visit(value: unknown, scoped = false) {
159
+ if (Array.isArray(value)) {
160
+ value.forEach((child) => visit(child, scoped));
161
+ return;
162
+ }
163
+ if (!value || typeof value !== "object") return;
164
+ const node = value as Node;
165
+ if (node.t === "Div") {
166
+ const [attr, children] = node.c as [
167
+ [string, string[], [string, string][]],
168
+ Node[],
169
+ ];
170
+ if (
171
+ !scoped &&
172
+ attr[1].some((cls) => cls === "cell" || cls.startsWith("cell-output"))
173
+ ) {
174
+ throw new Error(
175
+ "MISSING_PRODUCER_RECEIPT: executed cell lost its verified original boundary",
176
+ );
177
+ }
178
+ const tokens = attr[2].filter(([key]) => key === "qbento-producer-proof");
179
+ if (attr[1].includes("qbento-execution") || tokens.length) {
180
+ if (
181
+ attr[0] || attr[1].length !== 1 ||
182
+ attr[1][0] !== "qbento-execution" || tokens.length !== 1 ||
183
+ attr[2].length !== 1 || seen.has(tokens[0][1])
184
+ ) {
185
+ throw new Error(
186
+ "INVALID_PRODUCER_RECEIPT: malformed or repeated original execution boundary",
187
+ );
188
+ }
189
+ producerReceipt(
190
+ attached,
191
+ manifest.sourceHash,
192
+ tokens[0][1],
193
+ );
194
+ seen.add(tokens[0][1]);
195
+ sequence.push(tokens[0][1]);
196
+ scoped = true;
197
+ }
198
+ children.forEach((child) => visit(child, scoped));
199
+ } else if ("c" in node) visit(node.c, scoped);
200
+ }
201
+ ast.blocks.forEach((node) => visit(node));
202
+ if (
203
+ expectedTokens &&
204
+ (new Set(expectedTokens).size !== expectedTokens.length ||
205
+ expectedTokens.length !== seen.size ||
206
+ expectedTokens.some((token, index) => token !== sequence[index]))
207
+ ) {
208
+ throw new Error(
209
+ "MISSING_PRODUCER_RECEIPT: executed output receipts were removed, repeated or replaced before capture",
210
+ );
211
+ }
212
+ return attached;
213
+ }
214
+
215
+ /** User transformations can detach or swap producer boundaries. Refuse them
216
+ * before execution until a transform-specific original-boundary proof exists.
217
+ * Inspect resolves QMD/project/profile/metadata inheritance; source-only YAML
218
+ * searches cannot establish this gate.
219
+ */
220
+ export async function assertCompatibleProducerFilters(
221
+ source: string,
222
+ targetFormat = "html",
223
+ ) {
224
+ const document = scanSource(await Deno.readTextFile(source));
225
+ if (
226
+ !document.blocks.some((b) => b.kind === "chunk" || b.origin === "computed")
227
+ ) return;
228
+ if (document.blocks.some((b) => b.kind === "chunk")) assertProducerStartup();
229
+ const inspected = await new Deno.Command("quarto", {
230
+ cwd: dirname(source),
231
+ args: ["inspect", source],
232
+ stdout: "piped",
233
+ stderr: "piped",
234
+ }).output();
235
+ if (!inspected.success) {
236
+ throw new Error(
237
+ `QUARTO_INSPECT_FAILED: ${new TextDecoder().decode(inspected.stderr)}`,
238
+ );
239
+ }
240
+ const inspection = JSON.parse(new TextDecoder().decode(inspected.stdout));
241
+ const filters: unknown[] = [];
242
+ const append = (value: unknown) => {
243
+ if (value === undefined || value === null) return;
244
+ if (Array.isArray(value)) filters.push(...value);
245
+ else filters.push(value);
246
+ };
247
+ const selected = inspection.formats?.[targetFormat];
248
+ // Quarto inspect has no --to override. If it omits the forced format, use
249
+ // the inspected configurations conservatively rather than assume an absent filter.
250
+ const effective = selected
251
+ ? [selected]
252
+ : Object.values(inspection.formats ?? {});
253
+ for (
254
+ const format of effective as Record<
255
+ string,
256
+ Record<string, unknown>
257
+ >[]
258
+ ) {
259
+ append(format.pandoc?.filters);
260
+ append(format.metadata?.filters);
261
+ append(format.metadata?.["lua-filter"]);
262
+ append(format.metadata?.["lua-filters"]);
263
+ }
264
+ // Bind the reviewed internal filter bytes, including in installed runtimes
265
+ // where project filters must not become their own trust authority.
266
+ const canonical = new Set([
267
+ "484dc9143665b4b61fab5e1ce24241f8133d1c5e2c59bfbb4576606e036f7f2f",
268
+ "bf2ef385783f7b750d7dc97e1f158d30d4fcd4556a44bf5f82486749d25bed97",
269
+ ]);
270
+ for (const filter of filters) {
271
+ const path = typeof filter === "string"
272
+ ? filter
273
+ : filter && typeof filter === "object"
274
+ ? (filter as { path?: unknown }).path
275
+ : undefined;
276
+ let internal = false;
277
+ if (
278
+ typeof path === "string" &&
279
+ /(?:^|[/\\])_extensions[/\\]qbento[/\\](?:guard|capture)\.lua$/.test(path)
280
+ ) {
281
+ try {
282
+ internal = canonical.has(hashSource(
283
+ await Deno.readTextFile(resolve(dirname(source), path)),
284
+ ));
285
+ } catch { /* Missing or changed filters are never trusted. */ }
286
+ }
287
+ if (!internal) {
288
+ throw new Error(
289
+ "UNSUPPORTED_COMPUTED_FILTER: effective user filters can change execution producer boundaries; remove them from computed QMD/project/profile/metadata configuration or use a separately verified transformation adapter",
290
+ );
291
+ }
292
+ }
293
+ }
294
+
295
+ /** Existing bounded rich/widget adapters may look through exactly one verified
296
+ * execution boundary, while retaining their original source producer proof.
297
+ */
298
+ export function producerOutputChildren(
299
+ ast: Ast,
300
+ nodes: Node[],
301
+ sourceHash: string,
302
+ expectedProducer: string,
303
+ ): Node[] {
304
+ if (nodes.length !== 1 || nodes[0].t !== "Div") return nodes;
305
+ const [attr, children] = nodes[0].c as [
306
+ [string, string[], [string, string][]],
307
+ Node[],
308
+ ];
309
+ if (!attr[1].includes("qbento-execution")) return nodes;
310
+ const token = attr[2].find(([key]) => key === "qbento-producer-proof")?.[1];
311
+ if (
312
+ !token || producerReceipt(ast, sourceHash, token).id !== expectedProducer
313
+ ) {
314
+ throw new Error(
315
+ "INVALID_PRODUCER_RECEIPT: output boundary disagrees with the original adapter producer",
316
+ );
317
+ }
318
+ return children;
319
+ }
@@ -0,0 +1,49 @@
1
+ # Base-only data parsing: --vanilla/NULL prevents all profiles, user packages,
2
+ # cached workspaces and QMD execution. Renviron values are never logged.
3
+ local({
4
+ args <- commandArgs(TRUE)
5
+ stopifnot(length(args) == 2L)
6
+ Sys.setenv(R_DEFAULT_PACKAGES=Sys.getenv('QBENTO_STARTUP_PACKAGES'))
7
+ Sys.unsetenv('QBENTO_STARTUP_PACKAGES')
8
+ # R's --vanilla sets these selectors to empty: restore the caller's exact
9
+ # unset/empty/value distinction before applying the native environment parser.
10
+ for (name in c('R_ENVIRON','R_ENVIRON_USER','R_PROFILE','R_PROFILE_USER')) {
11
+ marker <- paste0('QBENTO_STARTUP_', name, '_SET')
12
+ value <- paste0('QBENTO_STARTUP_', name, '_VALUE')
13
+ if (Sys.getenv(marker) == '1') {
14
+ selected <- list(Sys.getenv(value))
15
+ names(selected) <- name
16
+ do.call(Sys.setenv, selected)
17
+ } else Sys.unsetenv(name)
18
+ Sys.unsetenv(c(marker,value))
19
+ }
20
+ baseline <- Sys.getenv()
21
+ if (identical(args[[2L]], 'read')) {
22
+ read_file <- function(path) {
23
+ if (nzchar(path) && file.exists(path) && !dir.exists(path)) {
24
+ if (!readRenviron(path)) stop('Cannot read startup environment')
25
+ }
26
+ }
27
+ site <- Sys.getenv('R_ENVIRON', unset=NA_character_)
28
+ if (is.na(site)) site <- file.path(R.home('etc'), 'Renviron.site')
29
+ read_file(site)
30
+ user <- Sys.getenv('R_ENVIRON_USER', unset=NA_character_)
31
+ if (is.na(user)) {
32
+ local <- file.path(getwd(), '.Renviron')
33
+ user <- if (file.exists(local)) local else file.path(path.expand('~'), '.Renviron')
34
+ }
35
+ read_file(user)
36
+ }
37
+ current <- Sys.getenv()
38
+ keys <- names(current)[!(names(current) %in% names(baseline)) |
39
+ current != baseline[names(current)]]
40
+ keys <- unique(c(keys[!is.na(keys)], "R_DEFAULT_PACKAGES"))
41
+ connection <- file(args[[1L]], 'wb')
42
+ for (key in keys) {
43
+ writeBin(charToRaw(enc2utf8(key)), connection)
44
+ writeBin(as.raw(0L), connection)
45
+ writeBin(charToRaw(enc2utf8(current[[key]])), connection)
46
+ writeBin(as.raw(0L), connection)
47
+ }
48
+ close(connection)
49
+ })
@@ -0,0 +1,96 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /** Evaluate startup environment files with R's parser, without profiles,
5
+ * default packages or QMD. Only file-induced changes reach the real child.
6
+ */
7
+ export async function producerEnvironment(options: {
8
+ cwd: string;
9
+ env?: Record<string, string>;
10
+ command?: string;
11
+ args?: string[];
12
+ }) {
13
+ const inherited = { ...Deno.env.toObject(), ...options.env };
14
+ let command = options.command ?? "Rscript";
15
+ if (!options.command) {
16
+ const selected = inherited.QUARTO_R;
17
+ if (selected) {
18
+ try {
19
+ const info = await Deno.stat(selected);
20
+ command = resolve(
21
+ info.isDirectory ? selected : dirname(selected),
22
+ "Rscript",
23
+ );
24
+ } catch { /* Quarto also falls back for a missing QUARTO_R. */ }
25
+ } else if (inherited.R_HOME) {
26
+ const candidate = resolve(inherited.R_HOME, "bin", "Rscript");
27
+ try {
28
+ await Deno.stat(candidate);
29
+ command = candidate;
30
+ } catch { /* PATH. */ }
31
+ }
32
+ }
33
+ const args = options.args ?? (inherited.QUARTO_KNITR_RSCRIPT_ARGS ?? "")
34
+ .split(",").filter((arg) => arg.trim());
35
+ const directory = await Deno.makeTempDir({ prefix: "qbento-r-environment-" });
36
+ const output = resolve(directory, "changes.bin");
37
+ const originalPackages = inherited.R_DEFAULT_PACKAGES;
38
+ const explicitPackages = args.filter((arg) =>
39
+ arg.startsWith("--default-packages=")
40
+ ).at(-1)?.slice("--default-packages=".length);
41
+ const startupSelection: Record<string, string> = {};
42
+ for (
43
+ const name of ["R_ENVIRON", "R_ENVIRON_USER", "R_PROFILE", "R_PROFILE_USER"]
44
+ ) {
45
+ startupSelection[`QBENTO_STARTUP_${name}_SET`] =
46
+ inherited[name] === undefined ? "0" : "1";
47
+ startupSelection[`QBENTO_STARTUP_${name}_VALUE`] = inherited[name] ?? "";
48
+ }
49
+ try {
50
+ const result = await new Deno.Command(command, {
51
+ cwd: options.cwd,
52
+ args: [
53
+ "--vanilla",
54
+ "--default-packages=NULL",
55
+ fileURLToPath(new URL("./producer-environment.R", import.meta.url)),
56
+ output,
57
+ args.includes("--no-environ") ? "skip" : "read",
58
+ ],
59
+ clearEnv: true,
60
+ env: {
61
+ ...inherited,
62
+ ...startupSelection,
63
+ QBENTO_STARTUP_PACKAGES: explicitPackages ?? originalPackages ??
64
+ "datasets,utils,grDevices,graphics,stats,methods",
65
+ },
66
+ stdout: "piped",
67
+ stderr: "piped",
68
+ }).output();
69
+ if (!result.success) {
70
+ // Do not expose environment values, including in errors from malformed files.
71
+ throw new Error(
72
+ "R_ENVIRONMENT_PROBE_FAILED: cannot resolve startup environment without executing source",
73
+ );
74
+ }
75
+ const values = new TextDecoder().decode(await Deno.readFile(output)).split(
76
+ "\0",
77
+ );
78
+ values.pop();
79
+ if (values.length % 2) {
80
+ throw new Error(
81
+ "INVALID_R_ENVIRONMENT_PROBE: malformed environment changes",
82
+ );
83
+ }
84
+ const effective = { ...inherited };
85
+ for (let i = 0; i < values.length; i += 2) {
86
+ effective[values[i]] = values[i + 1];
87
+ }
88
+ return {
89
+ effective,
90
+ originalProfile: effective.R_PROFILE_USER,
91
+ args: args.filter((arg) => !arg.startsWith("--default-packages=")),
92
+ };
93
+ } finally {
94
+ await Deno.remove(directory, { recursive: true });
95
+ }
96
+ }