@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.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +232 -0
- package/distribution/runtime/src/core/agent.ts +546 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +851 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2557 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +892 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +283 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +461 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/** Generation-scoped widget resources. The checksummed state, never these files, owns receipts. */
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
canonical,
|
|
5
|
+
type JsonObject,
|
|
6
|
+
type ProjectState,
|
|
7
|
+
StateError,
|
|
8
|
+
targetKey,
|
|
9
|
+
type WidgetPublication,
|
|
10
|
+
} from "../state/mod.ts";
|
|
11
|
+
import { hashSource, scanSource, type SourceDocument } from "../source/mod.ts";
|
|
12
|
+
import { type Ast } from "../render/types.ts";
|
|
13
|
+
import { collectPlotlyCandidates } from "../widgets/plotly.ts";
|
|
14
|
+
import {
|
|
15
|
+
createPlotlyPresenter,
|
|
16
|
+
createPlotlyReceipt,
|
|
17
|
+
loadPlotlyBundle,
|
|
18
|
+
PLOTLY_RUNTIME,
|
|
19
|
+
type PlotlyBundle,
|
|
20
|
+
type PlotlyReceipt,
|
|
21
|
+
type PlotlyRuntime,
|
|
22
|
+
plotlyStaticElement,
|
|
23
|
+
verifyPlotlyRuntime,
|
|
24
|
+
} from "../widgets/presenter.ts";
|
|
25
|
+
import { snapshotPlotly } from "../widgets/snapshot.ts";
|
|
26
|
+
import type { WidgetProjection } from "../render/project.ts";
|
|
27
|
+
import { syncDirectory, writeDurable } from "./io.ts";
|
|
28
|
+
import { createDeckPresenter } from "../widgets/deck.ts";
|
|
29
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
30
|
+
function fail(message: string): never {
|
|
31
|
+
throw new StateError("WIDGET_REBUILD_REQUIRED", message);
|
|
32
|
+
}
|
|
33
|
+
async function regular(path: string, directory = false): Promise<void> {
|
|
34
|
+
for (let p = resolve(path);; p = dirname(p)) {
|
|
35
|
+
const stat = await Deno.lstat(p);
|
|
36
|
+
if (
|
|
37
|
+
stat.isSymlink ||
|
|
38
|
+
(p === resolve(path) && !directory ? !stat.isFile : !stat.isDirectory)
|
|
39
|
+
) {
|
|
40
|
+
fail(
|
|
41
|
+
`Widget resource must be a real ${
|
|
42
|
+
directory ? "directory" : "file"
|
|
43
|
+
}: ${p}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (dirname(p) === p) break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function makeDirectory(path: string) {
|
|
50
|
+
await Deno.mkdir(path, { recursive: true });
|
|
51
|
+
await regular(path, true);
|
|
52
|
+
}
|
|
53
|
+
async function installedRuntime(): Promise<PlotlyRuntime> {
|
|
54
|
+
const r = await new Deno.Command("Rscript", {
|
|
55
|
+
args: [
|
|
56
|
+
"--vanilla",
|
|
57
|
+
"-e",
|
|
58
|
+
'cat(system.file("htmlwidgets/lib/plotlyjs", package="plotly"))',
|
|
59
|
+
],
|
|
60
|
+
stdout: "piped",
|
|
61
|
+
stderr: "piped",
|
|
62
|
+
}).output();
|
|
63
|
+
const path = new TextDecoder().decode(r.stdout).trim();
|
|
64
|
+
if (!r.success || !path) {
|
|
65
|
+
fail(
|
|
66
|
+
"Install a compatible R plotly package and Chromium to render this widget; qbento does not manage environments.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
await regular(resolve(path, "plotly-latest.min.js"));
|
|
70
|
+
await regular(resolve(path, "LICENSE"));
|
|
71
|
+
const runtime = {
|
|
72
|
+
javascript: await Deno.readTextFile(resolve(path, "plotly-latest.min.js")),
|
|
73
|
+
license: await Deno.readTextFile(resolve(path, "LICENSE")),
|
|
74
|
+
};
|
|
75
|
+
await verifyPlotlyRuntime(runtime);
|
|
76
|
+
return runtime;
|
|
77
|
+
}
|
|
78
|
+
export interface PreparedWidgets {
|
|
79
|
+
bundles: PlotlyBundle[];
|
|
80
|
+
runtime?: PlotlyRuntime;
|
|
81
|
+
projections: Map<string, WidgetProjection>;
|
|
82
|
+
}
|
|
83
|
+
export async function prepareWidgets(
|
|
84
|
+
ast: Ast,
|
|
85
|
+
source: SourceDocument,
|
|
86
|
+
): Promise<PreparedWidgets> {
|
|
87
|
+
const candidates = collectPlotlyCandidates(ast, source),
|
|
88
|
+
projections = new Map<string, WidgetProjection>();
|
|
89
|
+
if (!candidates.length) return { bundles: [], projections };
|
|
90
|
+
if (candidates.length > 8) {
|
|
91
|
+
fail(
|
|
92
|
+
"This bounded Presenter supports up to eight Plotly widgets per deck.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const runtime = await installedRuntime(), bundles: PlotlyBundle[] = [];
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
const bundle = await snapshotPlotly(candidate, runtime),
|
|
98
|
+
expectedReceipt = createPlotlyReceipt(bundle);
|
|
99
|
+
const projected = plotlyStaticElement(bundle, { source, expectedReceipt });
|
|
100
|
+
projections.set(candidate.source.sourceId, {
|
|
101
|
+
...projected,
|
|
102
|
+
slideId: candidate.source.slideId,
|
|
103
|
+
outputHash: candidate.source.outputHash,
|
|
104
|
+
});
|
|
105
|
+
bundles.push(bundle);
|
|
106
|
+
}
|
|
107
|
+
return { bundles, runtime, projections };
|
|
108
|
+
}
|
|
109
|
+
/** Write immutable generation first; only publication of state activates its external receipt. */
|
|
110
|
+
export async function stageWidgets(
|
|
111
|
+
stateDir: string,
|
|
112
|
+
generation: string,
|
|
113
|
+
docId: string,
|
|
114
|
+
source: SourceDocument,
|
|
115
|
+
prepared: PreparedWidgets,
|
|
116
|
+
document: ProjectState["emitted"],
|
|
117
|
+
detachedTargets: ReadonlySet<string> = new Set(),
|
|
118
|
+
): Promise<WidgetPublication | undefined> {
|
|
119
|
+
if (!prepared.bundles.length) return undefined;
|
|
120
|
+
if (!uuid.test(generation) || !prepared.runtime) {
|
|
121
|
+
fail("Invalid widget publication generation");
|
|
122
|
+
}
|
|
123
|
+
const base = resolve(stateDir, "widgets");
|
|
124
|
+
await makeDirectory(base);
|
|
125
|
+
const staging = resolve(base, `.staging-${crypto.randomUUID()}`),
|
|
126
|
+
destination = resolve(base, generation);
|
|
127
|
+
await Deno.mkdir(staging);
|
|
128
|
+
await regular(staging, true);
|
|
129
|
+
const publication: WidgetPublication = {
|
|
130
|
+
version: 1,
|
|
131
|
+
generation,
|
|
132
|
+
docId,
|
|
133
|
+
sourceHash: source.hash,
|
|
134
|
+
runtimeSha256: PLOTLY_RUNTIME.sha256,
|
|
135
|
+
licenseSha256: PLOTLY_RUNTIME.licenseSha256,
|
|
136
|
+
presenterSha256: "",
|
|
137
|
+
entries: [],
|
|
138
|
+
};
|
|
139
|
+
const widgetPages: { slideId: string; elementId: string; html: string }[] =
|
|
140
|
+
[];
|
|
141
|
+
await writeDurable(
|
|
142
|
+
resolve(staging, "plotly.js"),
|
|
143
|
+
prepared.runtime.javascript,
|
|
144
|
+
);
|
|
145
|
+
await writeDurable(resolve(staging, "LICENSE"), prepared.runtime.license);
|
|
146
|
+
for (const [index, bundle] of prepared.bundles.entries()) {
|
|
147
|
+
const receipt = createPlotlyReceipt(bundle),
|
|
148
|
+
html = await createPlotlyPresenter(bundle, prepared.runtime, {
|
|
149
|
+
source,
|
|
150
|
+
expectedReceipt: receipt,
|
|
151
|
+
});
|
|
152
|
+
const file = `widget-${index}.json`, presenter = `widget-${index}.html`;
|
|
153
|
+
await writeDurable(resolve(staging, file), canonical(bundle));
|
|
154
|
+
await writeDurable(resolve(staging, presenter), html);
|
|
155
|
+
publication.entries.push({
|
|
156
|
+
slideId: bundle.candidate.source.slideId,
|
|
157
|
+
elementId: bundle.candidate.source.sourceId,
|
|
158
|
+
file,
|
|
159
|
+
presenter,
|
|
160
|
+
presenterSha256: hashSource(html),
|
|
161
|
+
receipt: receipt as unknown as JsonObject,
|
|
162
|
+
});
|
|
163
|
+
const slide = document.slides.find((s) =>
|
|
164
|
+
s.id === bundle.candidate.source.slideId
|
|
165
|
+
),
|
|
166
|
+
element = slide?.elements.find((e) =>
|
|
167
|
+
e.id === bundle.candidate.source.sourceId
|
|
168
|
+
);
|
|
169
|
+
const expected =
|
|
170
|
+
plotlyStaticElement(bundle, { source, expectedReceipt: receipt }).element;
|
|
171
|
+
if (
|
|
172
|
+
!detachedTargets.has(
|
|
173
|
+
targetKey({
|
|
174
|
+
slideId: bundle.candidate.source.slideId,
|
|
175
|
+
elementId: bundle.candidate.source.sourceId,
|
|
176
|
+
}),
|
|
177
|
+
) &&
|
|
178
|
+
slide?.hidden !== true && element?.type === "image" &&
|
|
179
|
+
element.src === expected.src
|
|
180
|
+
) {
|
|
181
|
+
widgetPages.push({
|
|
182
|
+
slideId: bundle.candidate.source.slideId,
|
|
183
|
+
elementId: bundle.candidate.source.sourceId,
|
|
184
|
+
html,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const host = await createDeckPresenter(document, widgetPages);
|
|
189
|
+
publication.presenterSha256 = hashSource(host);
|
|
190
|
+
await writeDurable(resolve(staging, "presenter.html"), host);
|
|
191
|
+
await syncDirectory(staging);
|
|
192
|
+
await Deno.rename(staging, destination);
|
|
193
|
+
await syncDirectory(base);
|
|
194
|
+
return publication;
|
|
195
|
+
}
|
|
196
|
+
function generationDirectory(stateDir: string, state: ProjectState): string {
|
|
197
|
+
const p = state.widgets;
|
|
198
|
+
if (
|
|
199
|
+
!p || !uuid.test(p.generation) || p.docId !== state.docId ||
|
|
200
|
+
p.generation !== state.output?.generation
|
|
201
|
+
) fail("Render the project before opening its widget Presenter.");
|
|
202
|
+
return resolve(stateDir, "widgets", p.generation);
|
|
203
|
+
}
|
|
204
|
+
/** Recovery checks resources without assuming current QMD still equals the interrupted source. */
|
|
205
|
+
export async function assertWidgetFiles(
|
|
206
|
+
stateDir: string,
|
|
207
|
+
state: ProjectState,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (!state.widgets) return;
|
|
210
|
+
const dir = generationDirectory(stateDir, state);
|
|
211
|
+
await regular(dir, true);
|
|
212
|
+
const runtime = {
|
|
213
|
+
javascript: await checkedRead(dir, "plotly.js"),
|
|
214
|
+
license: await checkedRead(dir, "LICENSE"),
|
|
215
|
+
};
|
|
216
|
+
await verifyPlotlyRuntime(runtime);
|
|
217
|
+
const host = await checkedRead(dir, "presenter.html");
|
|
218
|
+
if (hashSource(host) !== state.widgets.presenterSha256) {
|
|
219
|
+
fail("Published widget Presenter differs from its receipt.");
|
|
220
|
+
}
|
|
221
|
+
for (const entry of state.widgets.entries) {
|
|
222
|
+
const bundle = JSON.parse(await checkedRead(dir, entry.file));
|
|
223
|
+
if (hashSource(canonical(bundle)) !== entry.receipt.bundleHash) {
|
|
224
|
+
fail("Widget bundle differs from the trusted state receipt.");
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
hashSource(await checkedRead(dir, entry.presenter)) !==
|
|
228
|
+
entry.presenterSha256
|
|
229
|
+
) fail("Widget Presenter differs from its trusted receipt.");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function checkedRead(dir: string, file: string) {
|
|
233
|
+
if (
|
|
234
|
+
!/^(?:plotly\.js|LICENSE|presenter\.html|widget-\d+\.(?:json|html))$/.test(
|
|
235
|
+
file,
|
|
236
|
+
)
|
|
237
|
+
) fail("Invalid widget resource filename");
|
|
238
|
+
const path = resolve(dir, file);
|
|
239
|
+
await regular(path);
|
|
240
|
+
return await Deno.readTextFile(path);
|
|
241
|
+
}
|
|
242
|
+
/** Build from saved design and verified generation resources, refusing stale/computed edits. */
|
|
243
|
+
export async function projectPresenter(
|
|
244
|
+
sourcePath: string,
|
|
245
|
+
stateDir: string,
|
|
246
|
+
state: ProjectState,
|
|
247
|
+
): Promise<string> {
|
|
248
|
+
if (!state.widgets?.entries.length) {
|
|
249
|
+
fail(
|
|
250
|
+
"No supported interactive widgets. Use Bento's ordinary presentation controls.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
await assertWidgetFiles(stateDir, state);
|
|
254
|
+
const source = scanSource(await Deno.readTextFile(sourcePath)),
|
|
255
|
+
dir = generationDirectory(stateDir, state);
|
|
256
|
+
const runtime = {
|
|
257
|
+
javascript: await checkedRead(dir, "plotly.js"),
|
|
258
|
+
license: await checkedRead(dir, "LICENSE"),
|
|
259
|
+
};
|
|
260
|
+
const pages: { slideId: string; elementId: string; html: string }[] = [];
|
|
261
|
+
for (const entry of state.widgets.entries) {
|
|
262
|
+
const slide = state.emitted.slides.find((s) => s.id === entry.slideId),
|
|
263
|
+
element = slide?.elements.find((e) => e.id === entry.elementId);
|
|
264
|
+
if (!slide || slide.hidden === true || !element) continue;
|
|
265
|
+
if (
|
|
266
|
+
state
|
|
267
|
+
.provenance[
|
|
268
|
+
targetKey({ slideId: entry.slideId, elementId: entry.elementId })
|
|
269
|
+
]?.origin === "detached" ||
|
|
270
|
+
state.pending.some((change) =>
|
|
271
|
+
change.origin === "detached" &&
|
|
272
|
+
change.target.slideId === entry.slideId &&
|
|
273
|
+
change.target.elementId === entry.elementId
|
|
274
|
+
)
|
|
275
|
+
) continue;
|
|
276
|
+
const context = {
|
|
277
|
+
source,
|
|
278
|
+
expectedReceipt: entry.receipt as unknown as PlotlyReceipt,
|
|
279
|
+
};
|
|
280
|
+
const bundle = await loadPlotlyBundle(await checkedRead(dir, entry.file), {
|
|
281
|
+
...context,
|
|
282
|
+
runtime,
|
|
283
|
+
});
|
|
284
|
+
const expected = plotlyStaticElement(bundle, context).element;
|
|
285
|
+
if (element.type !== "image" || element.src !== expected.src) {
|
|
286
|
+
fail(
|
|
287
|
+
"A computed widget image was edited or reconciled. Render or resolve it before opening the interactive Presenter.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const html = await checkedRead(dir, entry.presenter);
|
|
291
|
+
if (hashSource(html) !== entry.presenterSha256) {
|
|
292
|
+
fail("Widget Presenter changed while preparing the snapshot.");
|
|
293
|
+
}
|
|
294
|
+
pages.push({ slideId: entry.slideId, elementId: entry.elementId, html });
|
|
295
|
+
}
|
|
296
|
+
if (hashSource(await Deno.readTextFile(sourcePath)) !== source.hash) {
|
|
297
|
+
fail("QMD changed while preparing Presenter; retry after rendering.");
|
|
298
|
+
}
|
|
299
|
+
return await createDeckPresenter(state.emitted, pages);
|
|
300
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { basename, dirname, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
async function exists(path: string): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
await Deno.lstat(path);
|
|
7
|
+
return true;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
if (e instanceof Deno.errors.NotFound) return false;
|
|
10
|
+
throw e;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function copyTree(source: string, destination: string): Promise<void> {
|
|
14
|
+
const info = await Deno.lstat(source);
|
|
15
|
+
if (info.isSymlink) {
|
|
16
|
+
throw new Error(`UNSAFE_INSTALL_SOURCE: symbolic link ${source}`);
|
|
17
|
+
}
|
|
18
|
+
if (info.isDirectory) {
|
|
19
|
+
await Deno.mkdir(destination, { recursive: true });
|
|
20
|
+
for await (const entry of Deno.readDir(source)) {
|
|
21
|
+
await copyTree(
|
|
22
|
+
resolve(source, entry.name),
|
|
23
|
+
resolve(destination, entry.name),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
} else if (info.isFile) {
|
|
27
|
+
await Deno.copyFile(source, destination);
|
|
28
|
+
} else throw new Error(`UNSAFE_INSTALL_SOURCE: unsupported file ${source}`);
|
|
29
|
+
}
|
|
30
|
+
/** Install a self-contained, offline extension without rewriting existing config. */
|
|
31
|
+
export async function installQuartoProject(
|
|
32
|
+
source: string,
|
|
33
|
+
options: { configure?: boolean } = {},
|
|
34
|
+
) {
|
|
35
|
+
source = resolve(source);
|
|
36
|
+
if (!source.endsWith(".qmd") || !(await Deno.stat(source)).isFile) {
|
|
37
|
+
throw new Error("SOURCE_TYPE: expected an existing single QMD");
|
|
38
|
+
}
|
|
39
|
+
const directory = dirname(source);
|
|
40
|
+
const config = resolve(directory, "_quarto.yml");
|
|
41
|
+
const extension = resolve(directory, "_extensions", "qbento");
|
|
42
|
+
const configure = options.configure ?? true;
|
|
43
|
+
if (
|
|
44
|
+
configure &&
|
|
45
|
+
(await exists(config) || await exists(resolve(directory, "_quarto.yaml")))
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"QUARTO_CONFIG_EXISTS: preserve your configuration; install with configure:false and merge project type qbento explicitly",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (await exists(extension)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"QUARTO_EXTENSION_EXISTS: existing extension retained; review it before upgrading",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const extensions = dirname(extension);
|
|
57
|
+
await Deno.mkdir(extensions, { recursive: true });
|
|
58
|
+
const info = await Deno.lstat(extensions);
|
|
59
|
+
if (info.isSymlink || !info.isDirectory) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"UNSAFE_INSTALL_PATH: _extensions must be a real directory",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const root = fileURLToPath(new URL("../../", import.meta.url));
|
|
65
|
+
const stage = resolve(extensions, `.qbento-install-${crypto.randomUUID()}`);
|
|
66
|
+
try {
|
|
67
|
+
await copyTree(resolve(root, "_extensions", "qbento"), stage);
|
|
68
|
+
await copyTree(resolve(root, "src"), resolve(stage, "runtime", "src"));
|
|
69
|
+
await copyTree(
|
|
70
|
+
resolve(root, "LICENSE"),
|
|
71
|
+
resolve(stage, "runtime", "LICENSE"),
|
|
72
|
+
);
|
|
73
|
+
await copyTree(
|
|
74
|
+
resolve(root, "vendor"),
|
|
75
|
+
resolve(stage, "runtime", "vendor"),
|
|
76
|
+
);
|
|
77
|
+
// Refuse races with another installer rather than replacing existing content.
|
|
78
|
+
await Deno.mkdir(extension);
|
|
79
|
+
for await (const entry of Deno.readDir(stage)) {
|
|
80
|
+
await Deno.rename(
|
|
81
|
+
resolve(stage, entry.name),
|
|
82
|
+
resolve(extension, entry.name),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (configure) {
|
|
86
|
+
await Deno.writeTextFile(
|
|
87
|
+
config,
|
|
88
|
+
`# Quarto-Bento: publication uses the project's guarded post-render hook.\nproject:\n type: qbento\n render:\n - ${
|
|
89
|
+
JSON.stringify(basename(source))
|
|
90
|
+
}\n`,
|
|
91
|
+
{ createNew: true },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
if (await exists(stage)) await Deno.remove(stage, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
return { extension, config: configure ? config : null, source };
|
|
98
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { ppid } from "node:process";
|
|
3
|
+
|
|
4
|
+
export async function processStartTime(pid: number): Promise<string | null> {
|
|
5
|
+
const result = await new Deno.Command("ps", {
|
|
6
|
+
args: ["-p", String(pid), "-o", "lstart="],
|
|
7
|
+
stdout: "piped",
|
|
8
|
+
stderr: "piped",
|
|
9
|
+
env: { LC_ALL: "C" },
|
|
10
|
+
}).output();
|
|
11
|
+
const value = new TextDecoder().decode(result.stdout).trim();
|
|
12
|
+
if (result.success && value) return value;
|
|
13
|
+
if (result.code === 1 && !value && result.stderr.length === 0) return null;
|
|
14
|
+
throw new Error(
|
|
15
|
+
"QUARTO_PROCESS_UNVERIFIED: cannot verify whether the original render is still active",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Quarto 1.10 does not expose effective output CLI options to project hooks. */
|
|
20
|
+
export function assertSafeQuartoArguments(
|
|
21
|
+
args: string[],
|
|
22
|
+
source: string,
|
|
23
|
+
cwd: string,
|
|
24
|
+
): void {
|
|
25
|
+
if (args[0] !== "render") {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"UNVERIFIED_QUARTO_INVOCATION: supported invocation is quarto render [source.qmd] [--to qbento-html] [--quiet]",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
let sawSource = false;
|
|
31
|
+
for (let i = 1; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
if (arg === "--quiet" || arg === "--to=qbento-html") continue;
|
|
34
|
+
if (arg === "--to" && args[i + 1] === "qbento-html") {
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
!arg.startsWith("-") && !sawSource &&
|
|
40
|
+
(resolve(cwd, arg) === source || arg === ".")
|
|
41
|
+
) {
|
|
42
|
+
sawSource = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(
|
|
46
|
+
`UNSAFE_QUARTO_ARGUMENT: ${arg}; only render [${
|
|
47
|
+
basename(source)
|
|
48
|
+
}] [--to qbento-html] [--quiet] is verified; use qbento render for the guarded CLI pipeline`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bounded Linux ancestry inspection; fail closed on unverified platforms/launchers. */
|
|
54
|
+
export async function assertSafeQuartoInvocation(
|
|
55
|
+
source: string,
|
|
56
|
+
): Promise<{ pid: number; startTime: string }> {
|
|
57
|
+
if (Deno.build.os !== "linux") {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"QUARTO_EXTENSION_PLATFORM: ordinary Quarto integration is currently verified on Linux only; use qbento render on other platforms pending native validation",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const bin = Deno.env.get("QUARTO_BIN_PATH");
|
|
63
|
+
if (!bin) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"UNVERIFIED_QUARTO_INVOCATION: missing Quarto installation identity",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const script = await Deno.realPath(resolve(bin, "quarto.js"));
|
|
69
|
+
let pid = ppid;
|
|
70
|
+
for (let depth = 0; depth < 8 && pid > 1; depth++) {
|
|
71
|
+
let args: string[];
|
|
72
|
+
let status: string;
|
|
73
|
+
try {
|
|
74
|
+
args = (await Deno.readTextFile(`/proc/${pid}/cmdline`)).split("\0")
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
status = await Deno.readTextFile(`/proc/${pid}/status`);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`UNVERIFIED_QUARTO_INVOCATION: cannot inspect Quarto ancestry (${
|
|
80
|
+
String(e)
|
|
81
|
+
})`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const at = args.indexOf(script);
|
|
85
|
+
if (at !== -1) {
|
|
86
|
+
assertSafeQuartoArguments(args.slice(at + 1), source, Deno.cwd());
|
|
87
|
+
const startTime = await processStartTime(pid);
|
|
88
|
+
if (!startTime) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"UNVERIFIED_QUARTO_INVOCATION: render process ended during preflight",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return { pid, startTime };
|
|
94
|
+
}
|
|
95
|
+
pid = Number(status.match(/^PPid:\s+(\d+)$/m)?.[1]);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(
|
|
98
|
+
"UNVERIFIED_QUARTO_INVOCATION: trusted Quarto render ancestor was not found; refusing output writes",
|
|
99
|
+
);
|
|
100
|
+
}
|