@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,367 @@
1
+ import { canonical } from "../state/json.ts";
2
+ import type { Element, Origin } from "../render/types.ts";
3
+ import { scanSource, type SourceDocument } from "../source/mod.ts";
4
+ import { hashSource } from "../source/mod.ts";
5
+ import { fail, parsePlotlyPayload, type PlotlyCandidate } from "./plotly.ts";
6
+
7
+ /** Exact local runtime proved by this adapter, never fetched from a CDN. */
8
+ export const PLOTLY_RUNTIME = {
9
+ version: "2.25.2",
10
+ sha256: "419deebe105f4c993feb7e6d1fe94fb1c2f7f98716db5d9b8fe3e37773838898",
11
+ licenseSha256:
12
+ "67a26cf80f03ff388f26945dfc1f6caed1a0746ff43871190339b3aee49b94bb",
13
+ } as const;
14
+ export interface PlotlyRuntime {
15
+ javascript: string;
16
+ license: string;
17
+ }
18
+ export interface PlotlyBundle {
19
+ version: 1;
20
+ candidate: PlotlyCandidate;
21
+ runtime: typeof PLOTLY_RUNTIME;
22
+ fallback: { mime: "image/svg+xml"; svg: string; sha256: string };
23
+ }
24
+ /** Store separately in the trusted generation receipt, never load from the bundle. */
25
+ export interface PlotlyReceipt {
26
+ version: 1;
27
+ bundleHash: string;
28
+ source: PlotlyCandidate["source"];
29
+ specHash: string;
30
+ runtimeSha256: string;
31
+ }
32
+ export interface PlotlyContext {
33
+ source: SourceDocument;
34
+ expectedReceipt: PlotlyReceipt;
35
+ }
36
+ const encode = (value: string) => {
37
+ let binary = "";
38
+ for (const byte of new TextEncoder().encode(value)) {
39
+ binary += String.fromCharCode(byte);
40
+ }
41
+ return btoa(binary);
42
+ };
43
+ const html = (value: string) =>
44
+ value.replaceAll("&", "&").replaceAll('"', """).replaceAll(
45
+ "<",
46
+ "&lt;",
47
+ ).replaceAll(">", "&gt;");
48
+ const json = (value: unknown) =>
49
+ JSON.stringify(value).replaceAll("<", "\\u003c");
50
+ async function sha256(value: string): Promise<string> {
51
+ return [
52
+ ...new Uint8Array(
53
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
54
+ ),
55
+ ].map((b) => b.toString(16).padStart(2, "0")).join("");
56
+ }
57
+ export async function verifyPlotlyRuntime(
58
+ runtime: PlotlyRuntime,
59
+ ): Promise<void> {
60
+ if (
61
+ await sha256(runtime.javascript) !== PLOTLY_RUNTIME.sha256 ||
62
+ await sha256(runtime.license) !== PLOTLY_RUNTIME.licenseSha256
63
+ ) fail("Plotly runtime or license differs from the verified local pin");
64
+ }
65
+ /** Deliberately rejects active elements, hyperlinks, images and nonlocal CSS refs. */
66
+ export function verifyStaticSvg(svg: string): void {
67
+ if (
68
+ svg.length > 5_000_000 || !/^<svg\s/.test(svg) || !/<\/svg>\s*$/.test(svg)
69
+ ) fail("invalid or oversized SVG snapshot");
70
+ if (
71
+ /<!|<\?|\bon[a-z]+\s*=|(?:href|src|xml:base)\s*=|@import|\\|&(?!(?:amp|lt|gt|quot|apos);)/i
72
+ .test(svg)
73
+ ) fail("active or external SVG content");
74
+ const tags = new Set(
75
+ "svg g defs clipPath rect path line polyline polygon circle ellipse text tspan title desc"
76
+ .split(" "),
77
+ );
78
+ for (const matched of svg.matchAll(/<\/?([A-Za-z][\w:-]*)\b/g)) {
79
+ if (!tags.has(matched[1])) {
80
+ fail(`SVG element ${matched[1]} is not supported`);
81
+ }
82
+ }
83
+ const attributes = new Set(
84
+ "xmlns xmlns:xlink class id d x y x1 y1 x2 y2 cx cy r rx ry dx dy height width viewBox transform style clip-path text-anchor dominant-baseline fill fill-opacity stroke stroke-width stroke-opacity stroke-linecap stroke-linejoin stroke-miterlimit stroke-dasharray stroke-dashoffset opacity font-family font-size font-weight font-style vector-effect points"
85
+ .split(" "),
86
+ );
87
+ const properties = new Set(
88
+ "font-family font-size font-weight font-style fill fill-opacity stroke stroke-width stroke-opacity stroke-linecap stroke-linejoin stroke-miterlimit stroke-dasharray stroke-dashoffset opacity white-space vector-effect display visibility paint-order"
89
+ .split(" "),
90
+ );
91
+ for (const tag of svg.matchAll(/<([A-Za-z][\w:-]*)([^>]*)>/g)) {
92
+ let rest = tag[2].replace(/\/\s*$/, "");
93
+ while (rest.trim()) {
94
+ const attribute = rest.match(/^\s+([\w:-]+)\s*=\s*("[^"]*"|'[^']*')/);
95
+ if (!attribute || !attributes.has(attribute[1])) {
96
+ fail("unsupported SVG attribute syntax");
97
+ }
98
+ const name = attribute[1], value = attribute[2].slice(1, -1);
99
+ if (name === "style") {
100
+ for (
101
+ const declaration of value.split(";").map((v) => v.trim()).filter(
102
+ Boolean,
103
+ )
104
+ ) {
105
+ const colon = declaration.indexOf(":");
106
+ if (
107
+ colon < 0 || !properties.has(declaration.slice(0, colon).trim())
108
+ ) fail("unsupported SVG style property");
109
+ const css = declaration.slice(colon + 1);
110
+ if (
111
+ /[{}@&]/.test(css) ||
112
+ [...css.matchAll(/([\w-]+)\s*\(/g)].some((m) =>
113
+ !["rgb", "rgba", "url"].includes(m[1])
114
+ )
115
+ ) fail("unsupported SVG style function");
116
+ }
117
+ } else if (["fill", "stroke", "clip-path"].includes(name)) {
118
+ if (
119
+ /[{}@&]/.test(value) ||
120
+ [...value.matchAll(/([\w-]+)\s*\(/g)].some((m) =>
121
+ !["rgb", "rgba", "url"].includes(m[1])
122
+ )
123
+ ) fail("unsupported SVG presentation function");
124
+ }
125
+ rest = rest.slice(attribute[0].length);
126
+ }
127
+ }
128
+ for (const matched of svg.matchAll(/url\s*\(([^)]*)\)/gi)) {
129
+ if (!/^#[A-Za-z0-9_.:-]+$/.test(matched[1])) {
130
+ fail("SVG CSS reference is not an internal fragment");
131
+ }
132
+ }
133
+ }
134
+ function verifyCandidate(candidate: PlotlyCandidate) {
135
+ if (
136
+ candidate.version !== 1 || candidate.kind !== "plotly" ||
137
+ candidate.specHash !== hashSource(canonical(candidate.spec))
138
+ ) fail("widget candidate changed after projection");
139
+ const config = structuredClone(candidate.spec.config);
140
+ if (
141
+ config.responsive !== false || config.displaylogo !== false ||
142
+ config.plotGlPixelRatio !== 1 ||
143
+ JSON.stringify(config.modeBarButtonsToRemove) !==
144
+ JSON.stringify(["toImage", "sendDataToCloud"])
145
+ ) fail("widget config changed after projection");
146
+ delete config.responsive;
147
+ delete config.plotGlPixelRatio;
148
+ delete config.modeBarButtonsToRemove;
149
+ const checked = parsePlotlyPayload(
150
+ JSON.stringify({
151
+ x: { data: candidate.spec.data, layout: candidate.spec.layout, config },
152
+ evals: [],
153
+ jsHooks: [],
154
+ }),
155
+ );
156
+ if (canonical(checked) !== canonical(candidate.spec)) {
157
+ fail("widget spec changed after projection");
158
+ }
159
+ if (
160
+ !Number.isInteger(candidate.width) || !Number.isInteger(candidate.height) ||
161
+ candidate.width < 100 || candidate.width > 1600 || candidate.height < 100 ||
162
+ candidate.height > 1600
163
+ ) fail("invalid widget dimensions");
164
+ }
165
+ export function createPlotlyBundle(
166
+ candidate: PlotlyCandidate,
167
+ svg: string,
168
+ ): PlotlyBundle {
169
+ verifyCandidate(candidate);
170
+ verifyStaticSvg(svg);
171
+ return {
172
+ version: 1,
173
+ candidate: structuredClone(candidate),
174
+ runtime: PLOTLY_RUNTIME,
175
+ fallback: { mime: "image/svg+xml", svg, sha256: hashSource(svg) },
176
+ };
177
+ }
178
+ function innerPage(
179
+ candidate: PlotlyCandidate,
180
+ runtime: PlotlyRuntime,
181
+ nonce: string,
182
+ exportSvg: boolean,
183
+ ) {
184
+ const csp =
185
+ `default-src 'none'; script-src 'nonce-${nonce}' data:; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'`;
186
+ const script = `"use strict";
187
+ const spec=${
188
+ json(candidate.spec)
189
+ }, width=${candidate.width}, height=${candidate.height};
190
+ window.PlotlyConfig={MathJaxConfig:'local'};
191
+ const chart=document.getElementById('chart');
192
+ const report=value=>parent.postMessage({qbentoWidget:${
193
+ json(nonce)
194
+ },...value},'*');
195
+ const layout={...spec.layout,width,height};
196
+ Plotly.newPlot(chart,spec.data,layout,spec.config).then(async()=>{
197
+ if(${exportSvg}){
198
+ const uri=await Plotly.toImage(chart,{format:'svg',width,height});
199
+ if(!uri.startsWith('data:image/svg+xml,'))throw new Error('Expected an SVG data URI');
200
+ report({svg:decodeURIComponent(uri.slice(uri.indexOf(',')+1)),ready:true});
201
+ }else report({ready:true});
202
+ }).catch(error=>report({error:String(error)}));`;
203
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${
204
+ html(csp)
205
+ }"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}#chart{width:${candidate.width}px;height:${candidate.height}px}</style></head><body><div id="chart"></div><script nonce="${nonce}" src="data:text/javascript;base64,${
206
+ encode(runtime.javascript)
207
+ }"></script><script nonce="${nonce}">${script}</script></body></html>`;
208
+ }
209
+ /** Isolated opaque-origin child; no same-origin, forms, downloads, popups or navigation grants. */
210
+ async function page(
211
+ candidate: PlotlyCandidate,
212
+ runtime: PlotlyRuntime,
213
+ bundle?: PlotlyBundle,
214
+ ): Promise<string> {
215
+ verifyCandidate(candidate);
216
+ await verifyPlotlyRuntime(runtime);
217
+ const nonce = crypto.randomUUID().replaceAll("-", "");
218
+ const csp =
219
+ `default-src 'none'; script-src 'nonce-${nonce}' data:; style-src 'unsafe-inline'; img-src data:; frame-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; worker-src 'none'`;
220
+ const child = innerPage(candidate, runtime, nonce, !bundle);
221
+ const fallback = bundle
222
+ ? `<img id="fallback" alt="Gráfico calculado; versión estática" width="${candidate.width}" height="${candidate.height}" src="data:image/svg+xml;base64,${
223
+ encode(bundle.fallback.svg)
224
+ }">`
225
+ : "";
226
+ const controls = bundle
227
+ ? '<button id="activate" disabled>Activar gráfico interactivo</button><p id="status" role="status">Cargando recursos locales…</p>'
228
+ : '<p id="status">Preparando SVG…</p>';
229
+ const script = `"use strict";const iframe=document.getElementById('widget');
230
+ const result=document.getElementById('qb-widget-result'),status=document.getElementById('status');
231
+ window.addEventListener('message',event=>{
232
+ if(event.source!==iframe.contentWindow||event.origin!=='null'||event.data?.qbentoWidget!==${
233
+ json(nonce)
234
+ })return;
235
+ if(event.data.error){status.textContent='No se pudo representar el widget: '+event.data.error;result.textContent=JSON.stringify({error:event.data.error});return;}
236
+ if(event.data.ready){status.textContent='Listo · recursos incluidos · sin conexión';result.textContent=JSON.stringify({ready:true,svg:event.data.svg});const button=document.getElementById('activate');if(button)button.disabled=false;}
237
+ });
238
+ const button=document.getElementById('activate');if(button)button.addEventListener('click',()=>{const active=iframe.hidden;iframe.hidden=!active;document.getElementById('fallback').hidden=active;button.textContent=active?'Mostrar versión estática':'Activar gráfico interactivo';});
239
+ iframe.srcdoc=${json(child)};`;
240
+ return `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="${
241
+ html(csp)
242
+ }"><title>Plotly · Presenter aislado</title><style>body{margin:24px;font:16px system-ui;background:#f4f6f7;color:#172a35}main{max-width:${candidate.width}px;margin:auto}iframe{border:0;background:white}button{padding:10px 16px}img{display:block}img[hidden],iframe[hidden]{display:none}@media print{body{margin:0}button,#status,details,iframe{display:none!important}#fallback{display:block!important}}details{margin-top:20px}pre{white-space:pre-wrap;font-size:12px}</style></head><body><main>${controls}${fallback}<iframe id="widget" title="Gráfico Plotly aislado" sandbox="allow-scripts" referrerpolicy="no-referrer" width="${candidate.width}" height="${candidate.height}" ${
243
+ bundle ? "hidden" : ""
244
+ }></iframe><script id="qb-widget-result" type="application/json">{}</script><details><summary>Plotly ${PLOTLY_RUNTIME.version} · licencia</summary><pre>${
245
+ html(runtime.license)
246
+ }</pre></details></main><script nonce="${nonce}">${script}</script></body></html>`;
247
+ }
248
+ export const createPlotlySnapshotPage = (
249
+ candidate: PlotlyCandidate,
250
+ runtime: PlotlyRuntime,
251
+ ): Promise<string> => page(candidate, runtime);
252
+ /** Call only for a newly captured bundle, before its generation is published. */
253
+ export function createPlotlyReceipt(bundle: PlotlyBundle): PlotlyReceipt {
254
+ verifyCandidate(bundle.candidate);
255
+ verifyStaticSvg(bundle.fallback.svg);
256
+ if (
257
+ bundle.version !== 1 ||
258
+ bundle.fallback.sha256 !== hashSource(bundle.fallback.svg) ||
259
+ canonical(bundle.runtime) !== canonical(PLOTLY_RUNTIME)
260
+ ) fail("invalid freshly captured widget bundle");
261
+ return {
262
+ version: 1,
263
+ bundleHash: hashSource(canonical(bundle)),
264
+ source: structuredClone(bundle.candidate.source),
265
+ specHash: bundle.candidate.specHash,
266
+ runtimeSha256: PLOTLY_RUNTIME.sha256,
267
+ };
268
+ }
269
+ /** Validate against current QMD and a receipt obtained from the trusted core generation. */
270
+ function verifyBundleContext(
271
+ bundle: PlotlyBundle,
272
+ context: PlotlyContext,
273
+ ): void {
274
+ const receipt = createPlotlyReceipt(bundle);
275
+ if (canonical(receipt) !== canonical(context.expectedReceipt)) {
276
+ fail("bundle does not match the expected generation receipt");
277
+ }
278
+ const original = context.source;
279
+ if (
280
+ hashSource(original.text) !== original.hash ||
281
+ original.hash !== receipt.source.sourceHash
282
+ ) fail("widget source snapshot is stale");
283
+ const source = scanSource(original.text);
284
+ if (source.diagnostics.length) {
285
+ fail("source mapping diagnostics must be resolved first");
286
+ }
287
+ const wrapper = source.blocks.find((b) =>
288
+ b.id === receipt.source.sourceId && b.kind === "div" && b.stable
289
+ );
290
+ const children = wrapper
291
+ ? source.blocks.filter((b) => b.parentId === wrapper.id)
292
+ : [];
293
+ if (
294
+ children.length !== 1 || children[0].kind !== "chunk" ||
295
+ !children[0].stable || children[0].id !== receipt.source.producerId ||
296
+ children[0].slideId !== receipt.source.slideId ||
297
+ hashSource(children[0].raw) !== receipt.source.chunkHash
298
+ ) fail("widget producer no longer matches original source");
299
+ if (/results\s*[:=]\s*["']?asis|asis_output\s*\(/i.test(children[0].raw)) {
300
+ fail("unsupported asis producer");
301
+ }
302
+ }
303
+ /** Loading untrusted persisted JSON always requires both fresh QMD and an external receipt. */
304
+ export async function loadPlotlyBundle(
305
+ serialized: string,
306
+ options: PlotlyContext & { runtime: PlotlyRuntime },
307
+ ): Promise<PlotlyBundle> {
308
+ if (serialized.length > 8_000_000) fail("widget bundle exceeds 8 MB");
309
+ let bundle: PlotlyBundle;
310
+ try {
311
+ bundle = JSON.parse(serialized);
312
+ verifyBundleContext(bundle, options);
313
+ } catch (error) {
314
+ if (
315
+ error instanceof Error && error.message.startsWith("UNSUPPORTED_WIDGET:")
316
+ ) throw error;
317
+ fail("malformed widget bundle or context");
318
+ }
319
+ await verifyPlotlyRuntime(options.runtime);
320
+ return bundle!;
321
+ }
322
+ export function createPlotlyPresenter(
323
+ bundle: PlotlyBundle,
324
+ runtime: PlotlyRuntime,
325
+ context: PlotlyContext,
326
+ ): Promise<string> {
327
+ verifyBundleContext(bundle, context);
328
+ if (
329
+ bundle.version !== 1 || bundle.runtime.sha256 !== PLOTLY_RUNTIME.sha256 ||
330
+ bundle.fallback.sha256 !== hashSource(bundle.fallback.svg)
331
+ ) fail("widget bundle integrity mismatch");
332
+ verifyStaticSvg(bundle.fallback.svg);
333
+ return page(bundle.candidate, runtime, bundle);
334
+ }
335
+
336
+ /** Integration boundary: only the checked static image enters pure-data BentoDoc. */
337
+ export function plotlyStaticElement(
338
+ bundle: PlotlyBundle,
339
+ context: PlotlyContext,
340
+ ): { element: Element; origin: Origin } {
341
+ verifyBundleContext(bundle, context);
342
+ if (bundle.fallback.sha256 !== hashSource(bundle.fallback.svg)) {
343
+ fail("widget fallback changed");
344
+ }
345
+ verifyCandidate(bundle.candidate);
346
+ verifyStaticSvg(bundle.fallback.svg);
347
+ const c = bundle.candidate;
348
+ return {
349
+ element: {
350
+ id: c.source.sourceId,
351
+ type: "image",
352
+ x: 160,
353
+ y: 130,
354
+ w: 880,
355
+ h: Math.min(480, c.height),
356
+ rotation: 0,
357
+ opacity: 1,
358
+ src: `data:image/svg+xml;base64,${encode(bundle.fallback.svg)}`,
359
+ fit: "contain",
360
+ },
361
+ origin: {
362
+ origin: "computed",
363
+ sourceId: c.source.sourceId,
364
+ producer: c.source.producerId,
365
+ },
366
+ };
367
+ }
@@ -0,0 +1,116 @@
1
+ /** Optional local Chromium exporter; requires a compatible browser already installed. */
2
+ import { pathToFileURL } from "node:url";
3
+ import {
4
+ createPlotlyBundle,
5
+ createPlotlySnapshotPage,
6
+ type PlotlyBundle,
7
+ type PlotlyRuntime,
8
+ verifyStaticSvg,
9
+ } from "./presenter.ts";
10
+ import { fail, type PlotlyCandidate } from "./plotly.ts";
11
+ export async function snapshotPlotly(
12
+ candidate: PlotlyCandidate,
13
+ runtime: PlotlyRuntime,
14
+ options: { chromium?: string; timeoutMs?: number } = {},
15
+ ): Promise<PlotlyBundle> {
16
+ const page = await createPlotlySnapshotPage(candidate, runtime);
17
+ const dir = await Deno.makeTempDir({ prefix: "qbento-plotly-snapshot-" });
18
+ let child: Deno.ChildProcess | undefined;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ try {
21
+ await Deno.writeTextFile(`${dir}/snapshot.html`, page);
22
+ child = new Deno.Command(options.chromium ?? "chromium", {
23
+ args: [
24
+ "--headless",
25
+ "--no-first-run",
26
+ "--no-default-browser-check",
27
+ "--disable-background-networking",
28
+ "--disable-extensions",
29
+ "--disable-sync",
30
+ "--no-pings",
31
+ `--user-data-dir=${dir}/profile`,
32
+ "--dump-dom",
33
+ "--virtual-time-budget=5000",
34
+ pathToFileURL(`${dir}/snapshot.html`).href,
35
+ ],
36
+ stdout: "piped",
37
+ stderr: "piped",
38
+ }).spawn();
39
+ const timeout = new Promise<never>((_, reject) => {
40
+ timer = setTimeout(() => {
41
+ try {
42
+ child?.kill("SIGKILL");
43
+ } catch { /* Already exited. */ }
44
+ reject(
45
+ new Error(
46
+ "WIDGET_SNAPSHOT_TIMEOUT: local Chromium did not produce a snapshot",
47
+ ),
48
+ );
49
+ }, options.timeoutMs ?? 30_000);
50
+ });
51
+ const output = await Promise.race([child.output(), timeout]);
52
+ if (!output.success) {
53
+ fail(
54
+ `local Chromium export failed: ${
55
+ new TextDecoder().decode(output.stderr).slice(-2000)
56
+ }`,
57
+ );
58
+ }
59
+ const dom = new TextDecoder().decode(output.stdout);
60
+ const matched = dom.match(
61
+ /<script id="qb-widget-result" type="application\/json">([\s\S]*?)<\/script>/,
62
+ );
63
+ if (!matched) fail("Chromium did not emit an export result");
64
+ const result = JSON.parse(matched![1]);
65
+ if (result.ready !== true || typeof result.svg !== "string") {
66
+ fail(
67
+ `Chromium did not finish SVG export${
68
+ result.error ? `: ${result.error}` : ""
69
+ }`,
70
+ );
71
+ }
72
+ return createPlotlyBundle(
73
+ candidate,
74
+ canonicalPlotlySvg(result.svg, candidate.specHash),
75
+ );
76
+ } finally {
77
+ if (timer !== undefined) clearTimeout(timer);
78
+ await Deno.remove(dir, { recursive: true });
79
+ }
80
+ }
81
+
82
+ /** Plotly assigns random SVG IDs/trace class tokens; never rewrite user label text. */
83
+ export function canonicalPlotlySvg(svg: string, specHash: string): string {
84
+ verifyStaticSvg(svg);
85
+ const ids = new Map<string, string>();
86
+ const tags = (
87
+ input: string,
88
+ update: (name: string, value: string) => string,
89
+ ) =>
90
+ input.replace(
91
+ /<[A-Za-z][^<>]*>/g,
92
+ (tag) =>
93
+ tag.replace(/\s([\w:-]+)=(["'])(.*?)\2/g, (_, name, quote, value) =>
94
+ ` ${name}=${quote}${update(name, value)}${quote}`),
95
+ );
96
+ tags(svg, (name, value) => {
97
+ if (name === "id") {
98
+ if (ids.has(value)) fail("duplicate SVG identity");
99
+ ids.set(value, `qb-plotly-${specHash.slice(0, 16)}-${ids.size}`);
100
+ }
101
+ return value;
102
+ });
103
+ svg = tags(svg, (name, value) => {
104
+ if (name === "id") return ids.get(value)!;
105
+ if (name === "class") {
106
+ return value.split(/\s+/).filter((c) => !/^trace[0-9a-f]{6}$/.test(c))
107
+ .join(" ");
108
+ }
109
+ return value.replace(/url\(#([^)]+)\)/g, (_, id) => {
110
+ if (!ids.has(id)) fail("unbound SVG fragment");
111
+ return `url(#${ids.get(id)})`;
112
+ });
113
+ });
114
+ verifyStaticSvg(svg);
115
+ return svg;
116
+ }
@@ -0,0 +1,49 @@
1
+ # Embedded font provenance and notices
2
+
3
+ This note supplements the pinned Bento files. It records the embedded font
4
+ assets and the exact official OFL text sources used by this package. The
5
+ upstream `THIRD_PARTY_NOTICES.md` and `shell.html` remain byte-for-byte
6
+ unchanged.
7
+
8
+ ## Embedded assets
9
+
10
+ | Font asset in Bento | Subset and size | Asset SHA-256 | Release version |
11
+ | --- | --- | --- | --- |
12
+ | Fraunces `FRAUNCES_900` | Latin, 34,632-byte WOFF2 | `628051c1bd196ee82e88cb02015e46b8fc30f505215b424d29627ec613395a0d` | Unknown |
13
+ | Instrument Sans `INSTRUMENT_VAR` | Latin, 29,904-byte WOFF2 | `6219bc4bfdfc5d9b2201dcdf046218b122a758f932e25ed5f168f929b7ca2311` | Unknown |
14
+
15
+ The byte counts and asset hashes come from the base64 WOFF2 data in Bento's
16
+ pinned `slides/src/fontdata.ts` at
17
+ `3d6f7f1fcdf2308e875345ce4f1e0ec0820c914b`. Its source comments identify the
18
+ families and Latin subsets. No upstream font release identifier is present in
19
+ that pinned source; the package records the release versions as unknown and
20
+ does not infer them from the font binary metadata.
21
+
22
+ ## License text sources
23
+
24
+ The complete upstream license texts are included at the paths below and
25
+ integrity-pinned in `pin.json`. Each source URL includes an immutable upstream
26
+ Git commit, and the SHA-256 covers the exact vendored file bytes.
27
+
28
+ | Font | License file | Official source at immutable commit | SHA-256 |
29
+ | --- | --- | --- | --- |
30
+ | Fraunces | `font-licenses/fraunces-OFL.txt` | https://raw.githubusercontent.com/undercasetype/Fraunces/284bc5ea73b1233a05d6ec644e641b9404cba8a0/OFL.txt | `bdf4c22802eaf804f998195871c6b8938aac2ac14b2d78a8bd66a6f1eced833b` |
31
+ | Instrument Sans | `font-licenses/instrument-sans-OFL.txt` | https://raw.githubusercontent.com/Instrument/instrument-sans/24690a4ab2f92d45aa23246abeba515712145d75/OFL.txt | `9e27a72ed30eb49a08678f6a5d6ed98ec7ba5368f541637ee0683ec9134ef966` |
32
+
33
+ ## Attribution evidence
34
+
35
+ Bento's pinned `THIRD_PARTY_NOTICES.md` attributes Fraunces to
36
+ `Copyright 2020 The Fraunces Project Authors`. The official Fraunces OFL text
37
+ included here begins `Copyright 2018 The Fraunces Project Authors`. The
38
+ upstream Fraunces commit at
39
+ https://github.com/undercasetype/Fraunces/commit/284bc5ea73b1233a05d6ec644e641b9404cba8a0
40
+ records the project-author attribution change; the license header still says
41
+ 2018. This package records both observations without editing or replacing
42
+ Bento's supplied attribution.
43
+
44
+ For Instrument Sans, Bento's notice and the included official OFL both state
45
+ `Copyright 2022 The Instrument Sans Project Authors`.
46
+
47
+ This is provenance and notice coverage for these two embedded fonts only. It
48
+ does not constitute a complete legal review of Bento, its runtime components,
49
+ the font binaries' exact upstream releases, or Quarto-Bento's own licensing.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Bento authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ # Third-party notices
2
+
3
+ bento/slides is MIT-licensed (© 2026 The Bento authors; see `LICENSE`).
4
+ The shippable single-file shell (`Bento_Slides.bento.html`) bundles the
5
+ following third-party open-source components. Their license terms require that
6
+ these notices accompany copies, so the same text is embedded as a `NOTICE`
7
+ comment near the top of every built shell and every saved Bento document.
8
+
9
+ ## Bundled runtime (ships inside the shell)
10
+
11
+ ### reveal.js
12
+ - License: MIT
13
+ - Copyright (C) 2011-2024 Hakim El Hattab, http://hakim.se, and reveal.js contributors
14
+ - Project: https://revealjs.com / https://github.com/hakimel/reveal.js
15
+ - Use: powers the present-mode fullscreen slideshow overlay.
16
+
17
+ ### Moveable
18
+ - License: MIT
19
+ - Copyright (c) 2019 Daybrush (Younkue Choi)
20
+ - Project: https://github.com/daybrush/moveable
21
+ - Use: on-canvas element manipulation (drag / resize / rotate handles). Pulls in
22
+ the author's supporting modules (`@daybrush/*`, `@scena/*`, `@egjs/*`,
23
+ `@cfcs/*`), all MIT, © Daybrush.
24
+
25
+ ### Selecto
26
+ - License: MIT
27
+ - Copyright (c) 2020 Daybrush (Younkue Choi)
28
+ - Project: https://github.com/daybrush/selecto
29
+ - Use: marquee / rubber-band selection on the editing canvas.
30
+
31
+ ## Bundled fonts (embedded as document assets in decks that use them)
32
+
33
+ ### Fraunces
34
+ - License: SIL Open Font License 1.1
35
+ - Copyright 2020 The Fraunces Project Authors
36
+ - Project: https://github.com/undercasetype/Fraunces
37
+
38
+ ### Instrument Sans
39
+ - License: SIL Open Font License 1.1
40
+ - Copyright 2022 The Instrument Sans Project Authors
41
+ - Project: https://github.com/Instrument/instrument-sans
42
+
43
+ ---
44
+
45
+ Full MIT license text is reproduced in the shell's `NOTICE` comment and in the
46
+ `LICENSE` file. The SIL Open Font License 1.1 text is available at
47
+ https://openfontlicense.org.
48
+
49
+ Dev-only tooling (Vite, TypeScript, `vite-plugin-singlefile`, `qrcode`,
50
+ `@types/*`) is used to build Bento but is **not** bundled into the shipped shell
51
+ and is therefore not listed here.