@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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Norman Peña Calero
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,24 @@
1
+ title: Quarto Bento
2
+ version: 0.1.0
3
+ quarto-required: ">=1.10.19"
4
+ contributes:
5
+ engines:
6
+ - path: producer-activation.js
7
+ project:
8
+ project:
9
+ type: default
10
+ # Quarto 1.10 resolves bare extension hooks to absolute, unquoted commands.
11
+ # Keep these fixed project-relative paths so spaces in the project are safe.
12
+ pre-render: _extensions/qbento/pre.ts
13
+ post-render: _extensions/qbento/post.ts
14
+ filters:
15
+ - guard.lua
16
+ format: qbento-html
17
+ formats:
18
+ html:
19
+ theme: none
20
+ output-ext: qbento-stage.html
21
+ fig-format: svg
22
+ filters:
23
+ - at: post-quarto
24
+ path: capture.lua
@@ -0,0 +1,21 @@
1
+ -- Quarto writes a disposable stage; the post hook safely publishes the final deck.
2
+ function Pandoc(doc)
3
+ local input = quarto.doc.input_file
4
+ local stage = pandoc.path.filename(input):gsub('%.qmd$', '.qbento-stage.html')
5
+ if pandoc.path.filename(PANDOC_STATE.output_file) ~= stage then
6
+ error('UNSAFE_QUARTO_OUTPUT: qbento-html must write ' .. stage .. '; remove output-file/--output overrides')
7
+ end
8
+ local dir = pandoc.path.directory(input)
9
+ local receiptFile = assert(io.open(pandoc.path.join({dir, '.qbento', 'quarto-run.json'}), 'r'),
10
+ 'QUARTO_PROJECT_REQUIRED: use project type qbento and its pre/post hooks')
11
+ local receipt = quarto.json.decode(receiptFile:read('*a'))
12
+ receiptFile:close()
13
+ if receipt.source ~= input then error('QUARTO_SOURCE_MISMATCH: receipt belongs to a different source') end
14
+ local capture = {
15
+ token = receipt.token,
16
+ ast = quarto.json.decode(pandoc.write(pandoc.Pandoc(doc.blocks, { title = doc.meta.title }), 'json'))
17
+ }
18
+ local out = assert(io.open(pandoc.path.join({dir, '.qbento', 'quarto-stage', 'executed.json'}), 'w'))
19
+ out:write(quarto.json.encode(capture))
20
+ out:close()
21
+ end
@@ -0,0 +1,7 @@
1
+ -- Project-wide: also protects the final deck when --to selects a different format.
2
+ function Pandoc(doc)
3
+ local output = tostring(PANDOC_STATE.output_file)
4
+ if output:match('%.bento%.html$') then
5
+ error('UNSAFE_QUARTO_OUTPUT: Quarto may not write a .bento.html directly; use qbento-html staging and guarded publication')
6
+ end
7
+ end
@@ -0,0 +1,2 @@
1
+ import { afterQuartoRender } from "./runtime/src/integration/quarto.ts";
2
+ await afterQuartoRender();
@@ -0,0 +1,2 @@
1
+ import { beforeQuartoRender } from "./runtime/src/integration/quarto.ts";
2
+ await beforeQuartoRender();
@@ -0,0 +1,129 @@
1
+ import { producerEnvironment } from "./runtime/src/render/producer-environment.ts";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Quarto 1.10.19's public engine initializer runs in its parent process. This
6
+ // narrowly scoped compatibility adapter leaves the selected engine as knitr.
7
+ const directory = fileURLToPath(new URL("../../", import.meta.url));
8
+ const profile = fileURLToPath(
9
+ new URL("./runtime/src/render/producer-profile.R", import.meta.url),
10
+ );
11
+ const manifestPath = resolve(
12
+ directory,
13
+ ".qbento/quarto-stage/producer-manifest.json",
14
+ );
15
+ const receiptPath = resolve(directory, ".qbento/quarto-run.json");
16
+ const activation = Symbol.for("quarto-bento.producer-activation");
17
+
18
+ export default {
19
+ name: "qbento-producer-activation",
20
+ // The R launcher adapter is accepted against this exact installed revision.
21
+ quartoRequired: "=1.10.19",
22
+ defaultExt: ".qmd",
23
+ defaultYaml: () => [],
24
+ defaultContent: () => [],
25
+ validExtensions: () => [],
26
+ claimsFile: () => false,
27
+ claimsLanguage: () => false,
28
+ canFreeze: false,
29
+ generatesFigures: false,
30
+ init(quarto) {
31
+ if (Deno.env.get("QBENTO_INTERNAL_RENDER") === "1") return;
32
+ const original = quarto.system?.execProcess;
33
+ if (
34
+ typeof original !== "function" ||
35
+ typeof quarto.path?.resource !== "function"
36
+ ) {
37
+ throw new Error(
38
+ "UNSUPPORTED_QUARTO_PRODUCER_ADAPTER: expected Quarto's shared R launcher API",
39
+ );
40
+ }
41
+ if (original[activation] === directory) return;
42
+ if (original[activation]) {
43
+ throw new Error(
44
+ "UNSUPPORTED_QUARTO_PRODUCER_ADAPTER: another project already activated the R launcher",
45
+ );
46
+ }
47
+ const rmdScript = quarto.path.resource("rmd/rmd.R");
48
+ const adapter = async function (options, stdin, ...rest) {
49
+ if (
50
+ !(options.args ?? []).includes(rmdScript) || typeof stdin !== "string"
51
+ ) return original.call(this, options, stdin, ...rest);
52
+
53
+ const request = JSON.parse(stdin);
54
+ if (request.action !== "execute") {
55
+ return original.call(this, options, stdin, ...rest);
56
+ }
57
+ const input = request.params?.input;
58
+ if (
59
+ typeof input !== "string" || !input.endsWith(".qmd") ||
60
+ dirname(resolve(input)) !== resolve(directory)
61
+ ) return original.call(this, options, stdin, ...rest);
62
+
63
+ const run = JSON.parse(Deno.readTextFileSync(receiptPath));
64
+ if (
65
+ run.version !== 1 || run.source !== resolve(input) ||
66
+ run.process?.pid !== Deno.pid ||
67
+ typeof run.producerHash !== "string" ||
68
+ !/^[a-f0-9]{64}$/.test(run.producerHash)
69
+ ) {
70
+ throw new Error(
71
+ "INVALID_PRODUCER_RECEIPT: the R execution has no current guarded source receipt",
72
+ );
73
+ }
74
+ const manifest = JSON.parse(Deno.readTextFileSync(manifestPath));
75
+ if (manifest.chunks.length === 0) {
76
+ return original.call(this, options, stdin, ...rest);
77
+ }
78
+ const startup = await producerEnvironment({
79
+ cwd: options.cwd ?? directory,
80
+ command: options.cmd,
81
+ args: options.args,
82
+ env: options.env,
83
+ });
84
+ const selected = startup.originalProfile;
85
+ // These values affect this R subprocess only. Inspection, user scripts,
86
+ // other engines and the user's shell keep their original environment.
87
+ return original.call(
88
+ this,
89
+ {
90
+ ...options,
91
+ args: ["--no-environ", ...startup.args],
92
+ env: {
93
+ ...startup.effective,
94
+ R_PROFILE_USER: profile,
95
+ ...(startup.args.includes("--no-site-file") ? {} : {
96
+ R_PROFILE: fileURLToPath(
97
+ new URL(
98
+ "./runtime/src/render/producer-site-profile.R",
99
+ import.meta.url,
100
+ ),
101
+ ),
102
+ }),
103
+ QBENTO_ORIGINAL_R_SITE_SET:
104
+ startup.effective.R_PROFILE === undefined ? "0" : "1",
105
+ QBENTO_ORIGINAL_R_SITE: startup.effective.R_PROFILE ?? "",
106
+ QBENTO_PRODUCER_PROFILE: profile,
107
+ QBENTO_ORIGINAL_R_PROFILE_SET: selected === undefined ? "0" : "1",
108
+ QBENTO_ORIGINAL_R_PROFILE_USER: selected ?? "",
109
+ QBENTO_PRODUCER_MANIFEST: manifestPath,
110
+ },
111
+ },
112
+ stdin,
113
+ ...rest,
114
+ );
115
+ };
116
+ adapter[activation] = directory;
117
+ quarto.system.execProcess = adapter;
118
+ if (quarto.system.execProcess !== adapter) {
119
+ throw new Error(
120
+ "UNSUPPORTED_QUARTO_PRODUCER_ADAPTER: Quarto's R launcher cannot be activated",
121
+ );
122
+ }
123
+ },
124
+ launch() {
125
+ throw new Error(
126
+ "UNSUPPORTED_QUARTO_PRODUCER_ADAPTER: producer activation must not replace an execution engine",
127
+ );
128
+ },
129
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Version-bound public-API bridge for Bento 3d6f7f1fcdf2308e875345ce4f1e0ec0820c914b.
3
+ * No Store hooks exist on window.bento. Polling observes snapshots, NOT every
4
+ * intermediate operation. Save always reads the live document synchronously.
5
+ */
6
+ export function isDisabledCollaboration(value) {
7
+ return !!value && Object.keys(value).sort().join(",") === "key,on,room" &&
8
+ value.room === "" && value.key === "" && value.on === false;
9
+ }
10
+
11
+ export const BENTO_REVISION = "3d6f7f1fcdf2308e875345ce4f1e0ec0820c914b";
12
+
13
+ /**
14
+ * @template {{docId:string, format:string, version:number, slides:Array<{id:string}>}} D
15
+ * @param {{doc:D, selection:string[], loadDoc:(json:string)=>any, undo:()=>void, redo:()=>void}} api
16
+ * @param {{docId:string,persist:(snapshot:{doc:D,docId:string,revision:number})=>Promise<unknown>,onEvent?:(event:any)=>void,pollMs?:number}} options
17
+ */
18
+ export function createBentoBridge(api, options) {
19
+ if (
20
+ !api ||
21
+ !["loadDoc", "undo", "redo"].every((k) => typeof api[k] === "function") ||
22
+ !Array.isArray(api.selection)
23
+ ) {
24
+ throw new Error(
25
+ "BENTO_BRIDGE_INCOMPATIBLE: full editor public API required",
26
+ );
27
+ }
28
+ const emit = options.onEvent ?? (() => {});
29
+ let disposed = false;
30
+ let revision = 0;
31
+ let savedRevision = 0;
32
+ let queue = Promise.resolve();
33
+ const read = () => {
34
+ const doc = JSON.parse(JSON.stringify(api.doc));
35
+ if (doc.docId !== options.docId) {
36
+ throw new Error("BENTO_IDENTITY_CHANGED: open deck differs from project");
37
+ }
38
+ if (
39
+ doc.format !== "bento/slides" || doc.version !== 1 ||
40
+ !Array.isArray(doc.slides)
41
+ ) throw new Error("BENTO_BRIDGE_INCOMPATIBLE: document format");
42
+ if (doc.collab && !isDisabledCollaboration(doc.collab)) {
43
+ throw new Error(
44
+ "BENTO_COLLAB_DISABLED: collaborative documents cannot enter Studio",
45
+ );
46
+ }
47
+ return doc;
48
+ };
49
+ let current = read();
50
+ let fingerprint = JSON.stringify(current);
51
+ let selection = JSON.stringify(api.selection);
52
+ const active = () => {
53
+ if (disposed) throw new Error("BENTO_BRIDGE_DISPOSED");
54
+ };
55
+ const poll = (reason = "poll") => {
56
+ active();
57
+ const doc = read();
58
+ const next = JSON.stringify(doc);
59
+ if (next !== fingerprint) {
60
+ const previousOrder = current.slides.map((s) => s.id);
61
+ current = doc;
62
+ fingerprint = next;
63
+ revision++;
64
+ emit({
65
+ type: "document",
66
+ reason,
67
+ revision,
68
+ doc: structuredClone(doc),
69
+ dirty: true,
70
+ });
71
+ const order = doc.slides.map((s) => s.id);
72
+ if (JSON.stringify(order) !== JSON.stringify(previousOrder)) {
73
+ emit({ type: "slides", reason, revision, order, previousOrder });
74
+ }
75
+ }
76
+ const selected = JSON.stringify(api.selection);
77
+ if (selected !== selection) {
78
+ selection = selected;
79
+ emit({ type: "selection", selection: JSON.parse(selected) });
80
+ }
81
+ return { doc: structuredClone(current), docId: options.docId, revision };
82
+ };
83
+ const timer = options.pollMs === 0 ? undefined : setInterval(() => {
84
+ try {
85
+ poll();
86
+ } catch (error) {
87
+ emit({ type: "error", error });
88
+ }
89
+ }, Math.max(options.pollMs ?? 200, 50));
90
+ return {
91
+ poll,
92
+ snapshot: () => poll("snapshot"),
93
+ get dirty() {
94
+ poll();
95
+ return revision !== savedRevision;
96
+ },
97
+ get revision() {
98
+ return revision;
99
+ },
100
+ save() {
101
+ const snapshot = poll("save");
102
+ // Sequential persistence prevents a slower earlier request overwriting a
103
+ // later save. A failed write does not poison the next retry or clear dirty.
104
+ const task = queue.then(async () => {
105
+ emit({ type: "saving", revision: snapshot.revision });
106
+ try {
107
+ const receipt = await options.persist(snapshot);
108
+ savedRevision = snapshot.revision;
109
+ if (!disposed) poll("save-complete");
110
+ emit({
111
+ type: "saved",
112
+ revision: snapshot.revision,
113
+ dirty: revision !== savedRevision,
114
+ receipt,
115
+ });
116
+ return receipt;
117
+ } catch (error) {
118
+ emit({ type: "save-error", revision: snapshot.revision, error });
119
+ throw error;
120
+ }
121
+ });
122
+ queue = task.then(() => {}, () => {});
123
+ return task;
124
+ },
125
+ /** Project restoration, not importing a different document. Undoable. */
126
+ load(doc) {
127
+ active();
128
+ if (doc.docId !== options.docId) {
129
+ throw new Error("BENTO_IDENTITY_CHANGED: refusing foreign document");
130
+ }
131
+ if (doc.collab && !isDisabledCollaboration(doc.collab)) {
132
+ throw new Error("BENTO_COLLAB_DISABLED");
133
+ }
134
+ const before = JSON.stringify(api.doc);
135
+ const report = api.loadDoc(JSON.stringify(doc));
136
+ if (!report) {
137
+ throw new Error("BENTO_LOAD_REJECTED: document remains unchanged");
138
+ }
139
+ if (Array.isArray(report.dropped) && report.dropped.length) {
140
+ api.undo();
141
+ if (JSON.stringify(api.doc) !== before) {
142
+ throw new Error("BENTO_LOAD_ROLLBACK_FAILED");
143
+ }
144
+ emit({ type: "load-rejected", report });
145
+ throw new Error(
146
+ "BENTO_LOAD_LOSSY: unsupported fields; original document restored",
147
+ );
148
+ }
149
+ poll("load");
150
+ emit({ type: "loaded", report });
151
+ return report;
152
+ },
153
+ undo() {
154
+ active();
155
+ api.undo();
156
+ return poll("undo");
157
+ },
158
+ redo() {
159
+ active();
160
+ api.redo();
161
+ return poll("redo");
162
+ },
163
+ dispose() {
164
+ if (timer !== undefined) clearInterval(timer);
165
+ disposed = true;
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,55 @@
1
+ import { isDisabledCollaboration } from "./browser.js";
2
+
3
+ /** Non-secret off sentinel prevents upstream's unconditional credential mint. */
4
+ export function prepareIsolatedDocument<T extends object>(
5
+ doc: T,
6
+ ): T & { collab: { room: string; key: string; on: false } } {
7
+ const current = (doc as { collab?: unknown }).collab;
8
+ if (current && !isDisabledCollaboration(current)) {
9
+ throw new Error("BENTO_COLLAB_DISABLED: refuse a collaborative input");
10
+ }
11
+ return { ...structuredClone(doc), collab: { room: "", key: "", on: false } };
12
+ }
13
+
14
+ /** Insert before all upstream executable scripts, including in standalone HTML.
15
+ * The upstream pristine-shell capture therefore retains this adapter on Save.
16
+ * This changes capabilities in this document's realm, never the browser profile.
17
+ */
18
+ export const ISOLATION_MARKER = "qbento-isolated-host-v1";
19
+ export const ISOLATION_BOOTSTRAP = String.raw`(() => {
20
+ 'use strict';
21
+ if (globalThis.__qbentoIsolatedHost) return;
22
+ Object.defineProperty(globalThis, '__qbentoIsolatedHost', {value: 'qbento-isolated-host-v1'});
23
+ // Upstream has no collaboration-off hook and always constructs this channel.
24
+ class IsolatedChannel extends EventTarget {
25
+ constructor(name) { super(); this.name = String(name); this.onmessage = null; this.onmessageerror = null; }
26
+ postMessage() {} close() {}
27
+ }
28
+ Object.defineProperty(globalThis, 'BroadcastChannel', {value:IsolatedChannel, configurable:false, writable:false});
29
+ // The project journal, not stale same-docId browser recovery, owns Studio state.
30
+ Object.defineProperty(globalThis, 'indexedDB', {value:undefined, configurable:false, writable:false});
31
+ class DisabledSocket { constructor() { throw new Error('QBENTO_COLLAB_DISABLED: live sharing unavailable'); } }
32
+ Object.defineProperty(globalThis, 'WebSocket', {value:DisabledSocket, configurable:false, writable:false});
33
+ // The UI must not offer a disabled transport. Native local HTML Save still works.
34
+ const disableShare = () => {
35
+ document.querySelectorAll('.ed-btn-share').forEach(button => {
36
+ button.disabled = true;
37
+ button.title = 'Live sharing is unavailable in Quarto-Bento';
38
+ button.setAttribute('aria-disabled', 'true');
39
+ });
40
+ };
41
+ new MutationObserver(disableShare).observe(document.documentElement, {childList:true, subtree:true});
42
+ document.addEventListener('click', event => {
43
+ if (event.target instanceof Element && event.target.closest('.ed-btn-share,.ed-share-pop')) {
44
+ event.preventDefault(); event.stopImmediatePropagation();
45
+ }
46
+ }, true);
47
+ disableShare();
48
+ })();`;
49
+
50
+ /** Offline shell policy. Host Studio persistence is same-origin HTTP only.
51
+ * Place this meta CSP before upstream styles/scripts, and preferably also send
52
+ * as an HTTP response header. No remote fonts/assets/widgets or relay sockets.
53
+ */
54
+ export const OFFLINE_CSP =
55
+ "default-src 'none'; script-src 'unsafe-inline' 'self' blob:; style-src 'unsafe-inline' 'self'; img-src data: blob: 'self'; font-src data: blob: 'self'; media-src data: blob: 'self'; connect-src 'self'; worker-src blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
@@ -0,0 +1,218 @@
1
+ /** Optional installed Chromium, private headless profile, bounded local CDP lifetime. */
2
+ export interface ChromiumClient {
3
+ send<T = Record<string, unknown>>(
4
+ method: string,
5
+ params?: Record<string, unknown>,
6
+ ): Promise<T>;
7
+ events: { method: string; params: Record<string, unknown> }[];
8
+ version: string;
9
+ }
10
+ /** A missing result means a valid prefix of Chromium's two-line endpoint file. */
11
+ export function chromiumEndpoint(contents: string): string | undefined {
12
+ const invalid = () => {
13
+ throw new Error("INVALID_CHROMIUM_ENDPOINT");
14
+ };
15
+ const newline = contents.indexOf("\n");
16
+ if (newline < 0) {
17
+ const port = contents.endsWith("\r") ? contents.slice(0, -1) : contents;
18
+ if (!/^\d{0,5}$/.test(port) || Number(port) > 65535) invalid();
19
+ // A zero-only five-digit port cannot become a valid port by appending.
20
+ if (port.length === 5 && Number(port) === 0) invalid();
21
+ if (contents.endsWith("\r") && (!port || Number(port) === 0)) invalid();
22
+ return undefined;
23
+ }
24
+ const port = contents.slice(0, newline).replace(/\r$/, "");
25
+ if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
26
+ invalid();
27
+ }
28
+ const rawPath = contents.slice(newline + 1);
29
+ const path = rawPath.replace(/(?:\r?\n|\r)$/, "");
30
+ const shape = "/devtools/browser/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
31
+ if (path.length > shape.length) invalid();
32
+ for (let i = 0; i < path.length; i++) {
33
+ if (shape[i] === "x" ? !/[a-f0-9]/.test(path[i]) : path[i] !== shape[i]) {
34
+ invalid();
35
+ }
36
+ }
37
+ if (path.length < shape.length) {
38
+ // A newline commits the path; a short committed path is malformed.
39
+ if (rawPath.endsWith("\n") || rawPath.endsWith("\r")) invalid();
40
+ return undefined;
41
+ }
42
+ if (rawPath.endsWith("\r")) return undefined;
43
+ return `ws://127.0.0.1:${port}${path}`;
44
+ }
45
+ export async function withHeadlessChromium<T>(
46
+ options: { chromium?: string; timeoutMs?: number },
47
+ action: (client: ChromiumClient) => Promise<T>,
48
+ ): Promise<T> {
49
+ const dir = await Deno.makeTempDir({ prefix: "qbento-chromium-" });
50
+ let child: Deno.ChildProcess | undefined, socket: WebSocket | undefined;
51
+ let timer: ReturnType<typeof setTimeout> | undefined;
52
+ let stopped = false;
53
+ const pending = new Map<
54
+ number,
55
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
56
+ >();
57
+ const stop = () => {
58
+ stopped = true;
59
+ socket?.close();
60
+ try {
61
+ child?.kill("SIGKILL");
62
+ } catch { /* already exited */ }
63
+ for (const call of pending.values()) {
64
+ call.reject(new Error("CHROMIUM_CLOSED: capture ended"));
65
+ }
66
+ pending.clear();
67
+ };
68
+ const removeProfile = async () => {
69
+ for (let attempt = 0; attempt < 6; attempt++) {
70
+ try {
71
+ await Deno.remove(dir, { recursive: true });
72
+ break;
73
+ } catch (error) {
74
+ if (error instanceof Deno.errors.NotFound) break;
75
+ if (attempt === 5) throw error;
76
+ await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
77
+ }
78
+ }
79
+ };
80
+ try {
81
+ try {
82
+ child = new Deno.Command(options.chromium ?? "chromium", {
83
+ args: [
84
+ "--headless",
85
+ "--no-first-run",
86
+ "--no-default-browser-check",
87
+ "--disable-background-networking",
88
+ "--disable-extensions",
89
+ "--disable-sync",
90
+ "--no-pings",
91
+ "--remote-debugging-address=127.0.0.1",
92
+ "--remote-debugging-port=0",
93
+ `--user-data-dir=${dir}`,
94
+ "about:blank",
95
+ ],
96
+ stdout: "null",
97
+ stderr: "null",
98
+ }).spawn();
99
+ } catch (error) {
100
+ throw new Error(
101
+ `CHROMIUM_REQUIRED: install/configure a compatible local Chromium for this optional static fallback; ${
102
+ String(error)
103
+ }`,
104
+ );
105
+ }
106
+ const run = async () => {
107
+ let endpoint = "";
108
+ for (let i = 0; i < 100 && !stopped; i++) {
109
+ try {
110
+ const ready = chromiumEndpoint(
111
+ await Deno.readTextFile(`${dir}/DevToolsActivePort`),
112
+ );
113
+ if (ready) {
114
+ endpoint = ready;
115
+ break;
116
+ }
117
+ } catch (error) {
118
+ if (!(error instanceof Deno.errors.NotFound)) throw error;
119
+ }
120
+ await new Promise((resolve) => setTimeout(resolve, 50));
121
+ }
122
+ if (!endpoint || stopped) {
123
+ throw new Error(
124
+ "CHROMIUM_START_FAILED: local debugging endpoint unavailable",
125
+ );
126
+ }
127
+ socket = new WebSocket(endpoint);
128
+ await new Promise<void>((resolve, reject) => {
129
+ socket!.onopen = () => resolve();
130
+ socket!.onerror = () => reject(new Error("CHROMIUM_CONNECTION_FAILED"));
131
+ });
132
+ let id = 0;
133
+ const events: ChromiumClient["events"] = [];
134
+ socket.onmessage = (event) => {
135
+ const message = JSON.parse(String(event.data));
136
+ if (message.id) {
137
+ const call = pending.get(message.id);
138
+ pending.delete(message.id);
139
+ if (message.error) {
140
+ call?.reject(
141
+ new Error(`CHROMIUM_PROTOCOL: ${message.error.message}`),
142
+ );
143
+ } else call?.resolve(message.result);
144
+ } else if (events.length < 10_000) {
145
+ events.push({ method: message.method, params: message.params ?? {} });
146
+ }
147
+ };
148
+ const send = <R>(
149
+ method: string,
150
+ params: Record<string, unknown> = {},
151
+ sessionId?: string,
152
+ ): Promise<R> =>
153
+ new Promise((resolve, reject) => {
154
+ if (stopped) {
155
+ reject(new Error("CHROMIUM_CLOSED"));
156
+ return;
157
+ }
158
+ const callId = ++id;
159
+ pending.set(callId, {
160
+ resolve: resolve as (v: unknown) => void,
161
+ reject,
162
+ });
163
+ socket!.send(
164
+ JSON.stringify({
165
+ id: callId,
166
+ method,
167
+ params,
168
+ ...(sessionId ? { sessionId } : {}),
169
+ }),
170
+ );
171
+ });
172
+ const version = await send<{ product: string }>("Browser.getVersion");
173
+ const target = await send<{ targetId: string }>("Target.createTarget", {
174
+ url: "about:blank",
175
+ });
176
+ const session = await send<{ sessionId: string }>(
177
+ "Target.attachToTarget",
178
+ { targetId: target.targetId, flatten: true },
179
+ );
180
+ return await action({
181
+ send: <R>(method: string, params?: Record<string, unknown>) =>
182
+ send<R>(method, params, session.sessionId),
183
+ events,
184
+ version: version.product,
185
+ });
186
+ };
187
+ const timeout = new Promise<never>((_, reject) => {
188
+ timer = setTimeout(() => {
189
+ stop();
190
+ reject(
191
+ new Error(
192
+ "CHROMIUM_TIMEOUT: headless capture exceeded its bounded deadline",
193
+ ),
194
+ );
195
+ }, options.timeoutMs ?? 30_000);
196
+ });
197
+ return await Promise.race([run(), timeout]);
198
+ } finally {
199
+ if (timer !== undefined) clearTimeout(timer);
200
+ if (socket?.readyState === WebSocket.OPEN) {
201
+ // Graceful Browser.close lets renderer/utility children finish profile IO.
202
+ socket.send(JSON.stringify({ id: 2147483647, method: "Browser.close" }));
203
+ }
204
+ if (child) {
205
+ let grace: ReturnType<typeof setTimeout> | undefined;
206
+ await Promise.race([
207
+ child.status,
208
+ new Promise<void>((resolve) => {
209
+ grace = setTimeout(resolve, 1000);
210
+ }),
211
+ ]);
212
+ if (grace !== undefined) clearTimeout(grace);
213
+ }
214
+ stop();
215
+ if (child) await child.status;
216
+ await removeProfile();
217
+ }
218
+ }