@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,433 @@
1
+ import { loadBentoShell } from "../render/shell.ts";
2
+ import { applyLayout, previewLayout, readLayouts } from "../core/layouts.ts";
3
+ import {
4
+ applyComponentAction,
5
+ type ComponentRequest,
6
+ } from "../core/component-actions.ts";
7
+ import {
8
+ readProjectRegistry,
9
+ saveProjectRegistry,
10
+ } from "../core/design-registry.ts";
11
+ import { MAX_DESIGN_REGISTRY_BYTES } from "../render/registry.ts";
12
+ import {
13
+ readGraphRelations,
14
+ saveGraphRelation,
15
+ } from "../core/graph-relations.ts";
16
+ import {
17
+ readProject,
18
+ readProjectDiagnostics,
19
+ readProjectPresenter,
20
+ renderProject,
21
+ saveProject,
22
+ } from "../core/project.ts";
23
+ import { applySync, previewSync } from "../core/sync.ts";
24
+ import { applyReconcile, previewReconcile } from "../core/reconcile.ts";
25
+ import {
26
+ applySlideAction,
27
+ type SlideActionRequest,
28
+ } from "../core/slide-actions.ts";
29
+ import { spliceDocument } from "../render/html.ts";
30
+ import { ISOLATION_BOOTSTRAP, OFFLINE_CSP } from "../bridge/isolation.ts";
31
+ import { type BentoDoc, StateError } from "../state/mod.ts";
32
+ import type { BentoDoc as RenderDoc } from "../render/types.ts";
33
+
34
+ export const MAX_STUDIO_BODY_BYTES = 25_000_000;
35
+
36
+ // A missing or understated Content-Length must not bypass the byte limit.
37
+ // Return null before retaining an excess chunk, and stop reading its stream.
38
+ export async function readStudioBody(
39
+ req: Request,
40
+ maximumBytes = MAX_STUDIO_BODY_BYTES,
41
+ ): Promise<string | null> {
42
+ const declaredLength = req.headers.get("content-length");
43
+ if (
44
+ declaredLength !== null && /^\d+$/.test(declaredLength) &&
45
+ Number(declaredLength) > maximumBytes
46
+ ) {
47
+ await req.body?.cancel().catch(() => {});
48
+ return null;
49
+ }
50
+ if (!req.body) return "";
51
+ const reader = req.body.getReader();
52
+ let body = new Uint8Array(0);
53
+ let bytes = 0;
54
+ try {
55
+ while (true) {
56
+ const { value, done } = await reader.read();
57
+ if (done) break;
58
+ const nextBytes = bytes + value.byteLength;
59
+ if (nextBytes > maximumBytes) {
60
+ await reader.cancel().catch(() => {});
61
+ return null;
62
+ }
63
+ if (nextBytes > body.byteLength) {
64
+ const expanded = new Uint8Array(
65
+ Math.min(
66
+ maximumBytes,
67
+ Math.max(nextBytes, body.byteLength * 2, 65_536),
68
+ ),
69
+ );
70
+ expanded.set(body.subarray(0, bytes));
71
+ body = expanded;
72
+ }
73
+ body.set(value, bytes);
74
+ bytes = nextBytes;
75
+ }
76
+ return new TextDecoder().decode(body.subarray(0, bytes));
77
+ } finally {
78
+ reader.releaseLock();
79
+ }
80
+ }
81
+
82
+ export async function startStudio(source: string, port = 0) {
83
+ const token = crypto.randomUUID() + crypto.randomUUID();
84
+ const shell = await loadBentoShell();
85
+ const bridge = await Deno.readTextFile(
86
+ new URL("../bridge/browser.js", import.meta.url),
87
+ );
88
+ const client = await Deno.readTextFile(
89
+ new URL("./client.js", import.meta.url),
90
+ );
91
+ const registryClient = await Deno.readTextFile(
92
+ new URL("./registry.js", import.meta.url),
93
+ );
94
+ const layoutsClient = await Deno.readTextFile(
95
+ new URL("./layouts.js", import.meta.url),
96
+ );
97
+ const relationsClient = await Deno.readTextFile(
98
+ new URL("./relations.js", import.meta.url),
99
+ );
100
+ const componentClient = await Deno.readTextFile(
101
+ new URL("./components.js", import.meta.url),
102
+ );
103
+ const template = await Deno.readTextFile(
104
+ new URL("./index.html", import.meta.url),
105
+ );
106
+ const managedBoot = ISOLATION_BOOTSTRAP +
107
+ "\ndocument.addEventListener('bento:apply-layout', e => { e.stopImmediatePropagation(); e.preventDefault(); const s=globalThis.__qbentoSnapshot; if(s) parent.postMessage({type:'qbento:apply-layout',docId:s.docId,revision:s.revision}, location.origin); }, true);" +
108
+ "\naddEventListener('beforeunload', e => { if (globalThis.__qbentoManagedReload === true) e.stopImmediatePropagation(); }, true);";
109
+ let origin = "";
110
+ const presenters = new Map<string, string>();
111
+ const respond = (
112
+ body: string,
113
+ status = 200,
114
+ type = "application/json",
115
+ csp?: string,
116
+ ) =>
117
+ new Response(body, {
118
+ status,
119
+ headers: {
120
+ "Content-Type": type,
121
+ "Cache-Control": "no-store",
122
+ "X-Content-Type-Options": "nosniff",
123
+ "X-Frame-Options": "SAMEORIGIN",
124
+ ...(csp ? { "Content-Security-Policy": csp } : {}),
125
+ },
126
+ });
127
+ const server = Deno.serve(
128
+ { hostname: "127.0.0.1", port, onListen: () => {} },
129
+ async (req) => {
130
+ const url = new URL(req.url);
131
+ if (
132
+ url.origin !== origin ||
133
+ req.headers.get("host") !== new URL(origin).host
134
+ ) return respond('{"error":"Invalid host"}', 403);
135
+ if (
136
+ req.headers.get("origin") && req.headers.get("origin") !== origin
137
+ ) return respond('{"error":"Cross-origin request refused"}', 403);
138
+ try {
139
+ if (req.method === "GET" && url.pathname === "/") {
140
+ const page = template.replace(
141
+ "__QBENTO_TOKEN__",
142
+ JSON.stringify(token),
143
+ );
144
+ return respond(
145
+ page,
146
+ 200,
147
+ "text/html; charset=utf-8",
148
+ OFFLINE_CSP.replace("frame-src 'none'", "frame-src 'self'"),
149
+ );
150
+ }
151
+ if (req.method === "GET" && url.pathname === "/deck") {
152
+ const state = await readProject(source);
153
+ return respond(
154
+ spliceDocument(
155
+ shell,
156
+ state.emitted as unknown as RenderDoc,
157
+ managedBoot +
158
+ "\nObject.defineProperty(globalThis, '__qbentoSnapshot', { value: Object.freeze(" +
159
+ JSON.stringify({
160
+ docId: state.docId,
161
+ revision: state.revision,
162
+ pending: state.pending.length,
163
+ }).replaceAll("<", "\\u003c") +
164
+ ") });",
165
+ OFFLINE_CSP,
166
+ ),
167
+ 200,
168
+ "text/html; charset=utf-8",
169
+ OFFLINE_CSP,
170
+ );
171
+ }
172
+ if (
173
+ req.method === "GET" && url.pathname === "/bridge.js"
174
+ ) return respond(bridge, 200, "text/javascript");
175
+ if (
176
+ req.method === "GET" && url.pathname === "/client.js"
177
+ ) return respond(client, 200, "text/javascript");
178
+ if (req.method === "GET" && url.pathname === "/components.js") {
179
+ return respond(componentClient, 200, "text/javascript");
180
+ }
181
+ if (req.method === "GET" && url.pathname === "/registry.js") {
182
+ return respond(registryClient, 200, "text/javascript");
183
+ }
184
+ if (req.method === "GET" && url.pathname === "/layouts.js") {
185
+ return respond(layoutsClient, 200, "text/javascript");
186
+ }
187
+ if (req.method === "GET" && url.pathname === "/relations.js") {
188
+ return respond(relationsClient, 200, "text/javascript");
189
+ }
190
+ if (req.method === "GET" && url.pathname.startsWith("/presenter/")) {
191
+ const page = presenters.get(url.pathname);
192
+ return page
193
+ ? respond(page, 200, "text/html; charset=utf-8")
194
+ : respond('{"error":"Presenter snapshot expired"}', 404);
195
+ }
196
+ if (req.headers.get("x-qbento-token") !== token) {
197
+ return respond(
198
+ '{"error":"Invalid session token"}',
199
+ 403,
200
+ );
201
+ }
202
+ if (req.method === "GET" && url.pathname === "/api/diagnostics") {
203
+ return respond(JSON.stringify(await readProjectDiagnostics(source)));
204
+ }
205
+ if (req.method === "GET" && url.pathname === "/api/layouts") {
206
+ return respond(JSON.stringify(await readLayouts(source)));
207
+ }
208
+ if (
209
+ req.method === "POST" &&
210
+ ["/api/layouts/preview", "/api/layouts/apply"].includes(url.pathname)
211
+ ) {
212
+ if (req.headers.get("content-type") !== "application/json") {
213
+ return respond('{"error":"JSON required"}', 415);
214
+ }
215
+ const text = await readStudioBody(req, 32768);
216
+ if (text === null) {
217
+ return respond('{"error":"Layout request too large"}', 413);
218
+ }
219
+ return respond(
220
+ JSON.stringify(
221
+ await (url.pathname.endsWith("/apply")
222
+ ? applyLayout
223
+ : previewLayout)(source, JSON.parse(text)),
224
+ ),
225
+ );
226
+ }
227
+ if (req.method === "GET" && url.pathname === "/api/graphs/relations") {
228
+ return respond(JSON.stringify(await readGraphRelations(source)));
229
+ }
230
+ if (req.method === "POST" && url.pathname === "/api/graphs/relations") {
231
+ if (req.headers.get("content-type") !== "application/json") {
232
+ return respond('{"error":"JSON required"}', 415);
233
+ }
234
+ const text = await readStudioBody(req, 8192);
235
+ if (text === null) {
236
+ return respond('{"error":"Relation request too large"}', 413);
237
+ }
238
+ const state = await saveGraphRelation(source, JSON.parse(text));
239
+ return respond(
240
+ JSON.stringify({
241
+ docId: state.docId,
242
+ revision: state.revision,
243
+ pending: state.pending.length,
244
+ }),
245
+ );
246
+ }
247
+ if (req.method === "GET" && url.pathname === "/api/design-registry") {
248
+ return respond(JSON.stringify(await readProjectRegistry(source)));
249
+ }
250
+ if (req.method === "POST" && url.pathname === "/api/design-registry") {
251
+ if (req.headers.get("content-type") !== "application/json") {
252
+ return respond('{"error":"JSON required"}', 415);
253
+ }
254
+ const text = await readStudioBody(
255
+ req,
256
+ MAX_DESIGN_REGISTRY_BYTES + 4096,
257
+ );
258
+ if (text === null) {
259
+ return respond('{"error":"Registry too large"}', 413);
260
+ }
261
+ return respond(
262
+ JSON.stringify(await saveProjectRegistry(source, JSON.parse(text))),
263
+ );
264
+ }
265
+ if (
266
+ req.method === "GET" && url.pathname === "/api/state"
267
+ ) return respond(JSON.stringify(await readProject(source)));
268
+ if (req.method === "POST" && url.pathname === "/api/presenter") {
269
+ if (req.headers.get("content-type") !== "application/json") {
270
+ return respond('{"error":"JSON required"}', 415);
271
+ }
272
+ const text = await readStudioBody(req);
273
+ if (text === null) {
274
+ return respond('{"error":"Request too large"}', 413);
275
+ }
276
+ const { revision } = JSON.parse(text),
277
+ html = await readProjectPresenter(source, revision);
278
+ const path = "/presenter/" + crypto.randomUUID();
279
+ presenters.set(path, html);
280
+ while (presenters.size > 2) {
281
+ presenters.delete(presenters.keys().next().value!);
282
+ }
283
+ return respond(JSON.stringify({ url: path }));
284
+ }
285
+ if (req.method === "POST" && url.pathname === "/api/save") {
286
+ if (
287
+ req.headers.get("content-type") !== "application/json"
288
+ ) return respond('{"error":"JSON required"}', 415);
289
+ const text = await readStudioBody(req);
290
+ if (text === null) {
291
+ return respond(
292
+ '{"error":"Document too large"}',
293
+ 413,
294
+ );
295
+ }
296
+ const payload = JSON.parse(text) as {
297
+ doc: BentoDoc;
298
+ revision: number;
299
+ };
300
+ const state = await saveProject(
301
+ source,
302
+ payload.doc,
303
+ payload.revision,
304
+ );
305
+ return respond(
306
+ JSON.stringify({
307
+ revision: state.revision,
308
+ pending: state.pending.length,
309
+ }),
310
+ );
311
+ }
312
+ if (req.method === "POST" && url.pathname === "/api/sync/preview") {
313
+ if (req.headers.get("content-type") !== "application/json") {
314
+ return respond('{"error":"JSON required"}', 415);
315
+ }
316
+ const text = await readStudioBody(req);
317
+ if (text === null) return respond('{"error":"Plan too large"}', 413);
318
+ const { confirmedDeleteIds } = JSON.parse(text);
319
+ return respond(JSON.stringify(
320
+ await previewSync(source, undefined, {
321
+ confirmedDeleteIds,
322
+ }),
323
+ ));
324
+ }
325
+ if (
326
+ req.method === "POST" && url.pathname === "/api/components/action"
327
+ ) {
328
+ if (req.headers.get("content-type") !== "application/json") {
329
+ return respond('{"error":"JSON required"}', 415);
330
+ }
331
+ const text = await readStudioBody(req, 65536);
332
+ if (text === null) {
333
+ return respond('{"error":"Component too large"}', 413);
334
+ }
335
+ const state = await applyComponentAction(
336
+ source,
337
+ JSON.parse(text) as ComponentRequest,
338
+ );
339
+ return respond(
340
+ JSON.stringify({
341
+ revision: state.revision,
342
+ pending: state.pending.length,
343
+ }),
344
+ );
345
+ }
346
+ if (req.method === "POST" && url.pathname === "/api/slides/action") {
347
+ if (req.headers.get("content-type") !== "application/json") {
348
+ return respond('{"error":"JSON required"}', 415);
349
+ }
350
+ const text = await readStudioBody(req);
351
+ if (text === null) return respond('{"error":"Plan too large"}', 413);
352
+ const payload = JSON.parse(text) as SlideActionRequest;
353
+ if (
354
+ !payload || typeof payload.expectedDocId !== "string" ||
355
+ !payload.expectedDocId
356
+ ) {
357
+ throw new StateError(
358
+ "INVALID_SLIDE_ACTION",
359
+ "Slide actions require the saved document identity.",
360
+ );
361
+ }
362
+ const state = await applySlideAction(source, payload);
363
+ return respond(JSON.stringify({
364
+ revision: state.revision,
365
+ pending: state.pending.length,
366
+ }));
367
+ }
368
+ if (req.method === "POST" && url.pathname === "/api/sync/apply") {
369
+ if (req.headers.get("content-type") !== "application/json") {
370
+ return respond('{"error":"JSON required"}', 415);
371
+ }
372
+ const text = await readStudioBody(req);
373
+ if (text === null) {
374
+ return respond('{"error":"Plan too large"}', 413);
375
+ }
376
+ return respond(
377
+ JSON.stringify(await applySync(source, JSON.parse(text))),
378
+ );
379
+ }
380
+ if (req.method === "POST" && url.pathname === "/api/render") {
381
+ const r = await renderProject(source);
382
+ return respond(
383
+ JSON.stringify({
384
+ revision: r.state.revision,
385
+ pending: r.state.pending.length,
386
+ diagnostics: r.diagnostics,
387
+ }),
388
+ );
389
+ }
390
+ if (
391
+ req.method === "POST" && url.pathname === "/api/reconcile/preview"
392
+ ) {
393
+ return respond(JSON.stringify(await previewReconcile(source)));
394
+ }
395
+ if (req.method === "POST" && url.pathname === "/api/reconcile/apply") {
396
+ if (req.headers.get("content-type") !== "application/json") {
397
+ return respond('{"error":"JSON required"}', 415);
398
+ }
399
+ const text = await readStudioBody(req);
400
+ if (text === null) {
401
+ return respond('{"error":"Plan too large"}', 413);
402
+ }
403
+ const { plan, choices } = JSON.parse(text);
404
+ const result = await applyReconcile(source, plan, choices);
405
+ return respond(
406
+ JSON.stringify({
407
+ revision: result.state.revision,
408
+ remaining: result.remaining,
409
+ sourceUnchanged: true,
410
+ }),
411
+ );
412
+ }
413
+ return respond('{"error":"Not found"}', 404);
414
+ } catch (error) {
415
+ const e = error as Error & { code?: string; details?: unknown };
416
+ return respond(
417
+ JSON.stringify({
418
+ error: e.code || "QBENTO_ERROR",
419
+ message: e.message,
420
+ details: e.details,
421
+ }),
422
+ 409,
423
+ );
424
+ }
425
+ },
426
+ );
427
+ origin = `http://127.0.0.1:${server.addr.port}`;
428
+ return {
429
+ url: origin,
430
+ finished: server.finished,
431
+ shutdown: () => server.shutdown(),
432
+ };
433
+ }
@@ -0,0 +1,39 @@
1
+ /** Full native static deck with explicitly selected isolated interactive widget views. */
2
+ import { loadBentoShell } from "../render/shell.ts";
3
+ import type { BentoDoc } from "../state/types.ts";
4
+ import type { BentoDoc as RenderDoc } from "../render/types.ts";
5
+ import { spliceDocument } from "../render/html.ts";
6
+ import { ISOLATION_BOOTSTRAP, OFFLINE_CSP } from "../bridge/isolation.ts";
7
+ const html = (s: string) =>
8
+ s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
9
+ export async function createDeckPresenter(
10
+ document: BentoDoc,
11
+ widgets: { slideId: string; elementId: string; html: string }[],
12
+ ): Promise<string> {
13
+ const doc = structuredClone(document);
14
+ doc.readonly = true;
15
+ const shell = await loadBentoShell();
16
+ const deck = spliceDocument(
17
+ shell,
18
+ doc as unknown as RenderDoc,
19
+ ISOLATION_BOOTSTRAP,
20
+ OFFLINE_CSP.replace("connect-src 'self'", "connect-src 'none'"),
21
+ );
22
+ const payload = JSON.stringify({ deck, widgets }).replaceAll("<", "\\u003c");
23
+ const csp =
24
+ "default-src 'none'; script-src 'unsafe-inline' data: blob:; style-src 'unsafe-inline'; img-src data: blob:; font-src data: blob:; media-src data: blob:; frame-src 'self' data: blob:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; worker-src blob:";
25
+ return `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="${
26
+ html(csp)
27
+ }"><title>${
28
+ html(String(doc.title ?? "Bento"))
29
+ } · Presenter</title><style>html,body{margin:0;height:100%;font:15px system-ui;background:#172a35;color:white}header{display:flex;gap:12px;align-items:center;height:52px;padding:0 16px}button,select{font:inherit;padding:7px 10px}iframe{width:100%;height:calc(100vh - 52px);border:0;background:white}iframe[hidden]{display:none}#back[hidden]{display:none}@media print{header,#interactive{display:none!important}#deck{display:block!important}}</style></head><body><header><strong>Presentación</strong><select id="widgets" aria-label="Gráficos interactivos"><option value="">Elegir gráfico interactivo…</option>${
30
+ widgets.map((w, i) =>
31
+ `<option value="${i}">${html(w.slideId)} · ${html(w.elementId)}</option>`
32
+ ).join("")
33
+ }</select><button id="open" ${
34
+ widgets.length ? "" : "disabled"
35
+ }>Abrir gráfico</button><button id="back" hidden>Volver a diapositivas</button></header><iframe id="deck" title="Presentación Bento" sandbox="allow-scripts allow-same-origin"></iframe><iframe id="interactive" title="Gráfico interactivo aislado" sandbox="allow-scripts" hidden></iframe><script id="qb-presenter-data" type="application/json">${payload}</script><script>
36
+ const data=JSON.parse(document.getElementById('qb-presenter-data').textContent),deck=document.getElementById('deck'),frame=document.getElementById('interactive'),back=document.getElementById('back');deck.srcdoc=data.deck;
37
+ document.getElementById('open').onclick=()=>{const selected=document.getElementById('widgets').value;if(selected==='')return;frame.srcdoc=data.widgets[Number(selected)].html;frame.hidden=false;deck.hidden=true;back.hidden=false;};back.onclick=()=>{frame.hidden=true;frame.srcdoc='';deck.hidden=false;back.hidden=true;deck.focus();};
38
+ </script></body></html>`;
39
+ }