@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.
- package/LICENSE +21 -0
- package/README.md +86 -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 +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -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 +759 -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 +2423 -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 +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -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 +433 -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,647 @@
|
|
|
1
|
+
import {
|
|
2
|
+
conversionEdits,
|
|
3
|
+
type TextConversion,
|
|
4
|
+
verifyConversion,
|
|
5
|
+
} from "./text-conversion.ts";
|
|
6
|
+
import {
|
|
7
|
+
type ComponentInsertion,
|
|
8
|
+
generatedComponent,
|
|
9
|
+
verifyComponentInsertion,
|
|
10
|
+
} from "./component-insertion.ts";
|
|
11
|
+
/** Explicit, review-only structural operations over original QMD source sections. */
|
|
12
|
+
import { generatedNotes, verifyNotesInsertion } from "./notes.ts";
|
|
13
|
+
import { hashSource, scanSource, sliceBytes, SourceError } from "./mod.ts";
|
|
14
|
+
import type { SourceBlock, SourceDocument, SourceSlide, Span } from "./mod.ts";
|
|
15
|
+
import {
|
|
16
|
+
assertDeletionReferences,
|
|
17
|
+
sourceDeletion,
|
|
18
|
+
verifySourceDeletion,
|
|
19
|
+
} from "./deletion.ts";
|
|
20
|
+
|
|
21
|
+
export type StructureOperation =
|
|
22
|
+
| TextConversion
|
|
23
|
+
| { type: "insert-notes"; slideId: string; id: string; text: string }
|
|
24
|
+
| ComponentInsertion
|
|
25
|
+
| {
|
|
26
|
+
type: "insert-slide";
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
body: string;
|
|
30
|
+
bodyIds?: string[];
|
|
31
|
+
/** Native element boundaries, preventing adjacent lists from merging. */
|
|
32
|
+
bodyFragments?: string[];
|
|
33
|
+
/** Slide-local Bento IDs, distinct from globally unique QMD block IDs. */
|
|
34
|
+
bodyElementIds?: string[];
|
|
35
|
+
titleElementId?: string;
|
|
36
|
+
afterId?: string | null;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
type: "insert-text";
|
|
40
|
+
slideId: string;
|
|
41
|
+
id: string;
|
|
42
|
+
elementId: string;
|
|
43
|
+
markdown: string;
|
|
44
|
+
textKind?: "paragraph" | "list";
|
|
45
|
+
}
|
|
46
|
+
| { type: "reorder-slides"; ids: string[] }
|
|
47
|
+
| { type: "delete-slide"; id: string; confirmed: true }
|
|
48
|
+
| { type: "delete-block"; blockId: string; confirmed: true }
|
|
49
|
+
| { type: "pin-identities"; bindings: { blockId: string; id: string }[] };
|
|
50
|
+
export interface StructuralEdit {
|
|
51
|
+
span: Span;
|
|
52
|
+
expected: string;
|
|
53
|
+
replacement: string;
|
|
54
|
+
}
|
|
55
|
+
export interface StructureStep {
|
|
56
|
+
operation: StructureOperation;
|
|
57
|
+
beforeHash: string;
|
|
58
|
+
afterHash: string;
|
|
59
|
+
edits: StructuralEdit[];
|
|
60
|
+
}
|
|
61
|
+
export interface StructurePreview {
|
|
62
|
+
version: 1;
|
|
63
|
+
sourceHash: string;
|
|
64
|
+
resultHash: string;
|
|
65
|
+
operations: StructureOperation[];
|
|
66
|
+
steps: StructureStep[];
|
|
67
|
+
result: string;
|
|
68
|
+
}
|
|
69
|
+
const encode = new TextEncoder();
|
|
70
|
+
const decode = new TextDecoder("utf-8", { fatal: true });
|
|
71
|
+
const idPattern = /^[\p{L}_][\p{L}\p{N}\p{M}_.:-]*$/u;
|
|
72
|
+
function fail(code: string, message: string): never {
|
|
73
|
+
throw new SourceError(code, message);
|
|
74
|
+
}
|
|
75
|
+
function assertIdentity(id: string): void {
|
|
76
|
+
if (!idPattern.test(id)) {
|
|
77
|
+
fail("invalid-id", `Invalid source identity: ${id}.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function assertDocument(doc: SourceDocument): void {
|
|
81
|
+
if (doc.diagnostics.length) {
|
|
82
|
+
fail(
|
|
83
|
+
"ambiguous-structure",
|
|
84
|
+
"Resolve source diagnostics before structural changes.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const ids = doc.blocks.map((b) => b.id);
|
|
88
|
+
if (new Set(ids).size !== ids.length) {
|
|
89
|
+
fail("duplicate-id", "Source identities must be unique.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function uniqueSlide(doc: SourceDocument, id: string): SourceSlide {
|
|
93
|
+
const slides = doc.slides.filter((s) => s.id === id);
|
|
94
|
+
if (slides.length !== 1) {
|
|
95
|
+
fail("missing-slide", `Slide ${id} is missing or ambiguous.`);
|
|
96
|
+
}
|
|
97
|
+
if (!slides[0].stable) {
|
|
98
|
+
fail(
|
|
99
|
+
"unstable-slide",
|
|
100
|
+
"Pin slide identity explicitly before changing source order.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return slides[0];
|
|
104
|
+
}
|
|
105
|
+
/** A preceding technical marker travels with the heading it identifies. */
|
|
106
|
+
function sectionSpans(doc: SourceDocument): Map<string, Span> {
|
|
107
|
+
const starts = doc.slides.map((slide) => {
|
|
108
|
+
const before = sliceBytes(doc.text, { start: 0, end: slide.span.start });
|
|
109
|
+
const marker = before.match(
|
|
110
|
+
/(?:^|\n)([ \t]*<!--[ \t]*qb:([\p{L}_][\p{L}\p{N}\p{M}_.:-]*)[ \t]*-->[ \t]*(?:(?:\r\n|\n)[ \t]*)+)$/u,
|
|
111
|
+
);
|
|
112
|
+
if (marker && marker[2] === slide.id) {
|
|
113
|
+
return slide.span.start - encode.encode(marker[1]).length;
|
|
114
|
+
}
|
|
115
|
+
return slide.span.start;
|
|
116
|
+
});
|
|
117
|
+
return new Map(
|
|
118
|
+
doc.slides.map((
|
|
119
|
+
slide,
|
|
120
|
+
index,
|
|
121
|
+
) => [slide.id, {
|
|
122
|
+
start: starts[index],
|
|
123
|
+
end: starts[index + 1] ?? doc.byteLength,
|
|
124
|
+
}]),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function replace(doc: SourceDocument, edits: StructuralEdit[]): string {
|
|
128
|
+
const sorted = [...edits].sort((a, b) => a.span.start - b.span.start);
|
|
129
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
130
|
+
const edit = sorted[i];
|
|
131
|
+
if (
|
|
132
|
+
!Number.isInteger(edit.span.start) || !Number.isInteger(edit.span.end) ||
|
|
133
|
+
edit.span.start < 0 || edit.span.end < edit.span.start ||
|
|
134
|
+
edit.span.end > doc.byteLength
|
|
135
|
+
) fail("invalid-span", "Invalid structural edit span.");
|
|
136
|
+
if (i && edit.span.start <= sorted[i - 1].span.end) {
|
|
137
|
+
fail("overlapping-patches", "Structural edit spans overlap.");
|
|
138
|
+
}
|
|
139
|
+
if (sliceBytes(doc.text, edit.span) !== edit.expected) {
|
|
140
|
+
fail("stale-fragment", "Structural source fragment changed.");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let bytes = encode.encode(doc.text);
|
|
144
|
+
for (const edit of sorted.reverse()) {
|
|
145
|
+
const inserted = encode.encode(edit.replacement);
|
|
146
|
+
const next = new Uint8Array(
|
|
147
|
+
bytes.length - (edit.span.end - edit.span.start) + inserted.length,
|
|
148
|
+
);
|
|
149
|
+
next.set(bytes.subarray(0, edit.span.start));
|
|
150
|
+
next.set(inserted, edit.span.start);
|
|
151
|
+
next.set(bytes.subarray(edit.span.end), edit.span.start + inserted.length);
|
|
152
|
+
bytes = next;
|
|
153
|
+
}
|
|
154
|
+
return decode.decode(bytes);
|
|
155
|
+
}
|
|
156
|
+
function producerOrder(doc: SourceDocument, boundaryNewline = false): string[] {
|
|
157
|
+
return doc.blocks.filter((b) => b.origin === "computed").map((b) =>
|
|
158
|
+
JSON.stringify([
|
|
159
|
+
b.kind,
|
|
160
|
+
b.explicitId ?? null,
|
|
161
|
+
boundaryNewline ? b.raw.replace(/\r?\n$/, "") : b.raw,
|
|
162
|
+
])
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
function sameProducerOrder(
|
|
166
|
+
before: SourceDocument,
|
|
167
|
+
after: SourceDocument,
|
|
168
|
+
boundaryNewline = false,
|
|
169
|
+
): void {
|
|
170
|
+
if (
|
|
171
|
+
JSON.stringify(producerOrder(before, boundaryNewline)) !==
|
|
172
|
+
JSON.stringify(producerOrder(after, boundaryNewline))
|
|
173
|
+
) {
|
|
174
|
+
fail(
|
|
175
|
+
"execution-order",
|
|
176
|
+
"Structural edit would change executable or computed source order.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function generatedSlide(
|
|
181
|
+
doc: SourceDocument,
|
|
182
|
+
operation: Extract<StructureOperation, { type: "insert-slide" }>,
|
|
183
|
+
): string {
|
|
184
|
+
assertIdentity(operation.id);
|
|
185
|
+
if (!operation.title.trim() || /[\r\n]|\{[^}]*\}/.test(operation.title)) {
|
|
186
|
+
fail("invalid-slide", "A new slide requires a single-line authored title.");
|
|
187
|
+
}
|
|
188
|
+
const titleDoc = scanSource(`## ${operation.title} {#${operation.id}}\n`);
|
|
189
|
+
if (
|
|
190
|
+
titleDoc.blocks.length !== 1 || titleDoc.blocks[0].kind !== "heading" ||
|
|
191
|
+
titleDoc.blocks[0].content !== operation.title ||
|
|
192
|
+
!titleDoc.blocks[0].editable || titleDoc.blocks[0].id !== operation.id
|
|
193
|
+
) fail("invalid-slide", "Title contains structural or executable syntax.");
|
|
194
|
+
const body = operation.body.replace(/\r\n|\r|\n/g, "\n").trim();
|
|
195
|
+
const bodyDoc = scanSource(body);
|
|
196
|
+
if (operation.bodyFragments) {
|
|
197
|
+
if (operation.bodyFragments.join("\n\n") !== body) {
|
|
198
|
+
fail("invalid-slide", "Body fragments disagree with the reviewed body.");
|
|
199
|
+
}
|
|
200
|
+
bodyDoc.blocks = operation.bodyFragments.map((fragment) => {
|
|
201
|
+
const parsed = scanSource(fragment), block = parsed.blocks[0];
|
|
202
|
+
if (
|
|
203
|
+
parsed.diagnostics.length || parsed.blocks.length !== 1 ||
|
|
204
|
+
!["paragraph", "list"].includes(block.kind) || !block.editable ||
|
|
205
|
+
block.explicitId || block.parentId || block.content !== fragment
|
|
206
|
+
) {
|
|
207
|
+
fail(
|
|
208
|
+
"invalid-slide",
|
|
209
|
+
"Each native body fragment must be exactly one authored paragraph or list.",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return block;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
bodyDoc.diagnostics.length || (body && bodyDoc.blocks.length === 0) ||
|
|
217
|
+
bodyDoc.blocks.some((b) =>
|
|
218
|
+
!(operation.bodyFragments ? ["paragraph", "list"] : ["paragraph"])
|
|
219
|
+
.includes(b.kind) || !b.editable || b.explicitId
|
|
220
|
+
) || /<!--|-->|\{\{[<%]/.test(body)
|
|
221
|
+
) {
|
|
222
|
+
fail(
|
|
223
|
+
"invalid-slide",
|
|
224
|
+
"New slide body supports authored paragraphs only; structural or executable syntax must be authored explicitly in QMD.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
// Scanner ignores front matter/comments: require all non-whitespace body bytes belong to paragraphs.
|
|
228
|
+
let cursor = 0;
|
|
229
|
+
for (const block of operation.bodyFragments ? [] : bodyDoc.blocks) {
|
|
230
|
+
if (sliceBytes(body, { start: cursor, end: block.span.start }).trim()) {
|
|
231
|
+
fail("invalid-slide", "Body contains unmapped source syntax.");
|
|
232
|
+
}
|
|
233
|
+
cursor = block.span.end;
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
!operation.bodyFragments &&
|
|
237
|
+
sliceBytes(body, { start: cursor, end: bodyDoc.byteLength }).trim()
|
|
238
|
+
) {
|
|
239
|
+
fail("invalid-slide", "Body contains unmapped source syntax.");
|
|
240
|
+
}
|
|
241
|
+
if (operation.titleElementId !== undefined) {
|
|
242
|
+
assertIdentity(operation.titleElementId);
|
|
243
|
+
}
|
|
244
|
+
const bodyIds = operation.bodyIds ??
|
|
245
|
+
bodyDoc.blocks.map((_block, index) => `${operation.id}-text-${index + 1}`);
|
|
246
|
+
if (bodyIds.length !== bodyDoc.blocks.length) {
|
|
247
|
+
fail(
|
|
248
|
+
"invalid-slide",
|
|
249
|
+
"Each body paragraph requires exactly one native text identity.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
bodyIds.forEach(assertIdentity);
|
|
253
|
+
const nativeIds = operation.bodyElementIds ?? bodyIds;
|
|
254
|
+
if (nativeIds.length !== bodyIds.length) {
|
|
255
|
+
fail("invalid-slide", "Each body paragraph needs one visual identity.");
|
|
256
|
+
}
|
|
257
|
+
nativeIds.forEach(assertIdentity);
|
|
258
|
+
if (
|
|
259
|
+
new Set([operation.id, ...bodyIds]).size !== bodyIds.length + 1 ||
|
|
260
|
+
new Set(nativeIds).size !== nativeIds.length ||
|
|
261
|
+
(operation.titleElementId && nativeIds.includes(operation.titleElementId))
|
|
262
|
+
) fail("duplicate-id", "Created slide identities collide.");
|
|
263
|
+
const titleAttribute = operation.titleElementId
|
|
264
|
+
? ` bento-title-id="${operation.titleElementId}"`
|
|
265
|
+
: "";
|
|
266
|
+
const pieces = [`## ${operation.title} {#${operation.id}${titleAttribute}}`];
|
|
267
|
+
bodyDoc.blocks.forEach((block, index) =>
|
|
268
|
+
pieces.push(
|
|
269
|
+
operation.bodyElementIds
|
|
270
|
+
? `::: {#${bodyIds[index]} bento-element-id="${
|
|
271
|
+
nativeIds[index]
|
|
272
|
+
}"}\n${block.content}\n:::`
|
|
273
|
+
: `<!-- qb:${bodyIds[index]} -->\n${block.content}`,
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
const result = (pieces.join("\n\n") + "\n\n").replace(/\n/g, doc.newline);
|
|
277
|
+
const existing = new Set(doc.blocks.map((b) => b.id));
|
|
278
|
+
const generated = scanSource(result);
|
|
279
|
+
for (const block of generated.blocks) {
|
|
280
|
+
if (existing.has(block.id)) {
|
|
281
|
+
fail("duplicate-id", `Source identity ${block.id} already exists.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
/** One standalone authored paragraph, with a distinct source and native identity. */
|
|
287
|
+
function generatedText(
|
|
288
|
+
doc: SourceDocument,
|
|
289
|
+
operation: Extract<StructureOperation, { type: "insert-text" }>,
|
|
290
|
+
): string {
|
|
291
|
+
assertIdentity(operation.id);
|
|
292
|
+
assertIdentity(operation.elementId);
|
|
293
|
+
const body = operation.markdown.replace(/\r\n|\r/g, "\n");
|
|
294
|
+
const parsed = scanSource(body), block = parsed.blocks[0];
|
|
295
|
+
if (
|
|
296
|
+
parsed.diagnostics.length || parsed.blocks.length !== 1 ||
|
|
297
|
+
block.kind !== (operation.textKind ?? "paragraph") ||
|
|
298
|
+
!["paragraph", "list"].includes(block.kind) || !block.editable ||
|
|
299
|
+
block.explicitId ||
|
|
300
|
+
block.parentId || block.content !== body ||
|
|
301
|
+
/<!--|-->|\{\{[<%]/.test(body)
|
|
302
|
+
) {
|
|
303
|
+
fail(
|
|
304
|
+
"invalid-text-insertion",
|
|
305
|
+
"New text must be one complete authored paragraph; author structural or executable content explicitly in QMD.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const result =
|
|
309
|
+
`::: {#${operation.id} bento-element-id="${operation.elementId}"}\n${body}\n:::\n\n`;
|
|
310
|
+
const existing = new Set(doc.blocks.map((b) => b.id));
|
|
311
|
+
for (const created of scanSource(result).blocks) {
|
|
312
|
+
if (existing.has(created.id)) {
|
|
313
|
+
fail("duplicate-id", `Source identity ${created.id} already exists.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return result.replace(/\n/g, doc.newline);
|
|
317
|
+
}
|
|
318
|
+
function validateStep(
|
|
319
|
+
doc: SourceDocument,
|
|
320
|
+
operation: StructureOperation,
|
|
321
|
+
): StructuralEdit[] {
|
|
322
|
+
assertDocument(doc);
|
|
323
|
+
const spans = sectionSpans(doc);
|
|
324
|
+
switch (operation.type) {
|
|
325
|
+
case "convert-text":
|
|
326
|
+
return conversionEdits(doc, operation);
|
|
327
|
+
case "insert-slide": {
|
|
328
|
+
const section = generatedSlide(doc, operation);
|
|
329
|
+
let offset = doc.byteLength;
|
|
330
|
+
if (operation.afterId === null) {
|
|
331
|
+
offset = doc.slides.length
|
|
332
|
+
? spans.get(doc.slides[0].id)!.start
|
|
333
|
+
: doc.byteLength;
|
|
334
|
+
} else if (operation.afterId !== undefined) {
|
|
335
|
+
offset = spans.get(uniqueSlide(doc, operation.afterId).id)!.end;
|
|
336
|
+
}
|
|
337
|
+
const before = sliceBytes(doc.text, { start: 0, end: offset });
|
|
338
|
+
const prefix = before && !/(?:\r?\n){2}$/.test(before)
|
|
339
|
+
? (/(?:\r?\n)$/.test(before) ? doc.newline : doc.newline + doc.newline)
|
|
340
|
+
: "";
|
|
341
|
+
return [{
|
|
342
|
+
span: { start: offset, end: offset },
|
|
343
|
+
expected: "",
|
|
344
|
+
replacement: prefix + section,
|
|
345
|
+
}];
|
|
346
|
+
}
|
|
347
|
+
case "insert-notes":
|
|
348
|
+
case "insert-component":
|
|
349
|
+
case "insert-text": {
|
|
350
|
+
const slide = uniqueSlide(doc, operation.slideId);
|
|
351
|
+
const section = operation.type === "insert-notes"
|
|
352
|
+
? generatedNotes(doc, operation.slideId, operation.id, operation.text)
|
|
353
|
+
: operation.type === "insert-component"
|
|
354
|
+
? generatedComponent(doc, operation)
|
|
355
|
+
: generatedText(doc, operation);
|
|
356
|
+
const offset = spans.get(slide.id)!.end;
|
|
357
|
+
const before = sliceBytes(doc.text, { start: 0, end: offset });
|
|
358
|
+
const prefix = before && !/(?:\r?\n){2}$/.test(before)
|
|
359
|
+
? (/(?:\r?\n)$/.test(before) ? doc.newline : doc.newline + doc.newline)
|
|
360
|
+
: "";
|
|
361
|
+
return [{
|
|
362
|
+
span: { start: offset, end: offset },
|
|
363
|
+
expected: "",
|
|
364
|
+
replacement: prefix + section,
|
|
365
|
+
}];
|
|
366
|
+
}
|
|
367
|
+
case "reorder-slides": {
|
|
368
|
+
if (
|
|
369
|
+
operation.ids.length !== doc.slides.length ||
|
|
370
|
+
new Set(operation.ids).size !== operation.ids.length ||
|
|
371
|
+
operation.ids.some((id) => !spans.has(id))
|
|
372
|
+
) {
|
|
373
|
+
fail(
|
|
374
|
+
"invalid-order",
|
|
375
|
+
"Reorder must contain every current slide exactly once.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
for (const id of operation.ids) uniqueSlide(doc, id);
|
|
379
|
+
if (!doc.slides.length) return [];
|
|
380
|
+
const start = spans.get(doc.slides[0].id)!.start;
|
|
381
|
+
const replacement = operation.ids.map((id) =>
|
|
382
|
+
sliceBytes(doc.text, spans.get(id)!)
|
|
383
|
+
).join("");
|
|
384
|
+
return [{
|
|
385
|
+
span: { start, end: doc.byteLength },
|
|
386
|
+
expected: sliceBytes(doc.text, { start, end: doc.byteLength }),
|
|
387
|
+
replacement,
|
|
388
|
+
}];
|
|
389
|
+
}
|
|
390
|
+
case "delete-slide": {
|
|
391
|
+
if (operation.confirmed !== true) {
|
|
392
|
+
fail(
|
|
393
|
+
"confirmation-required",
|
|
394
|
+
"Deleting source requires an explicit confirmed operation.",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const slide = uniqueSlide(doc, operation.id), span = spans.get(slide.id)!;
|
|
398
|
+
const members = doc.blocks.filter((b) =>
|
|
399
|
+
b.span.start >= span.start && b.span.start < span.end
|
|
400
|
+
);
|
|
401
|
+
if (
|
|
402
|
+
members.some((b) => b.origin === "computed" || b.kind === "unsupported")
|
|
403
|
+
) {
|
|
404
|
+
fail(
|
|
405
|
+
"unsafe-delete",
|
|
406
|
+
"Cannot delete computed producers or source with unverified shared dependencies.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const rest = sliceBytes(doc.text, { start: 0, end: span.start }) +
|
|
410
|
+
sliceBytes(doc.text, { start: span.end, end: doc.byteLength });
|
|
411
|
+
assertDeletionReferences(
|
|
412
|
+
rest,
|
|
413
|
+
members.flatMap((block) => block.explicitId ? [block.explicitId] : []),
|
|
414
|
+
);
|
|
415
|
+
return [{ span, expected: sliceBytes(doc.text, span), replacement: "" }];
|
|
416
|
+
}
|
|
417
|
+
case "delete-block": {
|
|
418
|
+
if (operation.confirmed !== true) {
|
|
419
|
+
fail(
|
|
420
|
+
"confirmation-required",
|
|
421
|
+
"Deleting source requires an explicit confirmed operation.",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const { span, expected, replacement } = sourceDeletion(
|
|
425
|
+
doc,
|
|
426
|
+
operation.blockId,
|
|
427
|
+
);
|
|
428
|
+
return [{ span, expected, replacement }];
|
|
429
|
+
}
|
|
430
|
+
case "pin-identities": {
|
|
431
|
+
const used = new Set(doc.blocks.map((b) => b.id)),
|
|
432
|
+
seen = new Set<string>();
|
|
433
|
+
return operation.bindings.map(({ blockId, id }) => {
|
|
434
|
+
assertIdentity(id);
|
|
435
|
+
if (seen.has(blockId) || used.has(id)) {
|
|
436
|
+
fail(
|
|
437
|
+
"duplicate-id",
|
|
438
|
+
"Identity pinning repeats or collides with an existing source identity.",
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const matches = doc.blocks.filter((b) => b.id === blockId);
|
|
442
|
+
if (matches.length !== 1) {
|
|
443
|
+
fail("ambiguous-source", `Cannot uniquely identify ${blockId}.`);
|
|
444
|
+
}
|
|
445
|
+
const block = matches[0];
|
|
446
|
+
if (block.stable) {
|
|
447
|
+
fail(
|
|
448
|
+
"already-pinned",
|
|
449
|
+
`Block ${blockId} already has an explicit identity.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (block.kind === "unsupported") {
|
|
453
|
+
fail("unmapped", "Cannot pin an unsupported source construct.");
|
|
454
|
+
}
|
|
455
|
+
used.add(id);
|
|
456
|
+
seen.add(blockId);
|
|
457
|
+
return {
|
|
458
|
+
span: { start: block.span.start, end: block.span.start },
|
|
459
|
+
expected: "",
|
|
460
|
+
replacement: `<!-- qb:${id} -->${doc.newline}`,
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
default:
|
|
465
|
+
return fail("unsupported-operation", "Unknown structural operation.");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function verifyResult(
|
|
469
|
+
before: SourceDocument,
|
|
470
|
+
after: SourceDocument,
|
|
471
|
+
operation: StructureOperation,
|
|
472
|
+
): void {
|
|
473
|
+
assertDocument(after);
|
|
474
|
+
if (operation.type === "reorder-slides") {
|
|
475
|
+
if (
|
|
476
|
+
JSON.stringify(after.slides.map((s) => s.id)) !==
|
|
477
|
+
JSON.stringify(operation.ids)
|
|
478
|
+
) {
|
|
479
|
+
fail(
|
|
480
|
+
"ambiguous-boundary",
|
|
481
|
+
"Slide boundaries cannot be moved without altering original bytes; add an explicit blank-line boundary in QMD first.",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
sameProducerOrder(before, after);
|
|
485
|
+
} else if (operation.type === "insert-slide") {
|
|
486
|
+
if (
|
|
487
|
+
after.slides.length !== before.slides.length + 1 ||
|
|
488
|
+
!after.slides.some((s) => s.id === operation.id)
|
|
489
|
+
) {
|
|
490
|
+
fail(
|
|
491
|
+
"invalid-slide",
|
|
492
|
+
"Insertion did not create exactly one source slide.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
sameProducerOrder(before, after);
|
|
496
|
+
} else if (operation.type === "convert-text") {
|
|
497
|
+
verifyConversion(before, after, operation);
|
|
498
|
+
sameProducerOrder(before, after);
|
|
499
|
+
} else if (operation.type === "insert-notes") {
|
|
500
|
+
verifyNotesInsertion(
|
|
501
|
+
before,
|
|
502
|
+
after,
|
|
503
|
+
operation.slideId,
|
|
504
|
+
operation.id,
|
|
505
|
+
operation.text,
|
|
506
|
+
);
|
|
507
|
+
sameProducerOrder(before, after, true);
|
|
508
|
+
} else if (operation.type === "insert-component") {
|
|
509
|
+
verifyComponentInsertion(before, after, operation);
|
|
510
|
+
sameProducerOrder(before, after, true);
|
|
511
|
+
} else if (operation.type === "insert-text") {
|
|
512
|
+
const added = after.blocks.filter((block) =>
|
|
513
|
+
block.id === operation.id || block.parentId === operation.id
|
|
514
|
+
);
|
|
515
|
+
const wrapper = added.find((block) => block.id === operation.id);
|
|
516
|
+
const paragraph = added.find((block) =>
|
|
517
|
+
block.id === `${operation.id}/${operation.textKind ?? "paragraph"}:1`
|
|
518
|
+
);
|
|
519
|
+
const prior = before.blocks.map((
|
|
520
|
+
block,
|
|
521
|
+
) => [
|
|
522
|
+
block.kind,
|
|
523
|
+
block.raw.replace(/\r?\n$/, ""),
|
|
524
|
+
block.origin,
|
|
525
|
+
block.level,
|
|
526
|
+
block.slideId,
|
|
527
|
+
]);
|
|
528
|
+
const remaining = after.blocks.filter((block) => !added.includes(block))
|
|
529
|
+
.map((
|
|
530
|
+
block,
|
|
531
|
+
) => [
|
|
532
|
+
block.kind,
|
|
533
|
+
block.raw.replace(/\r?\n$/, ""),
|
|
534
|
+
block.origin,
|
|
535
|
+
block.level,
|
|
536
|
+
block.slideId,
|
|
537
|
+
]);
|
|
538
|
+
if (
|
|
539
|
+
added.length !== 2 || wrapper?.kind !== "div" || wrapper.parentId ||
|
|
540
|
+
!wrapper.stable || wrapper.slideId !== operation.slideId ||
|
|
541
|
+
paragraph?.kind !== (operation.textKind ?? "paragraph") ||
|
|
542
|
+
paragraph.origin !== "authored" ||
|
|
543
|
+
!paragraph.editable || paragraph.slideId !== operation.slideId ||
|
|
544
|
+
paragraph.content.replace(/\r\n/g, "\n") !==
|
|
545
|
+
operation.markdown.replace(/\r\n|\r/g, "\n") ||
|
|
546
|
+
JSON.stringify(before.slides.map((slide) => [slide.id, slide.stable])) !==
|
|
547
|
+
JSON.stringify(after.slides.map((slide) => [slide.id, slide.stable])) ||
|
|
548
|
+
JSON.stringify(prior) !== JSON.stringify(remaining)
|
|
549
|
+
) {
|
|
550
|
+
fail(
|
|
551
|
+
"invalid-text-insertion",
|
|
552
|
+
"Insertion must create exactly one standalone text block without changing existing source boundaries; close or explicitly repair the surrounding QMD first.",
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
// A separator may add the first newline after the final original fence.
|
|
556
|
+
sameProducerOrder(before, after, true);
|
|
557
|
+
} else if (operation.type === "delete-slide") {
|
|
558
|
+
if (after.slides.length !== before.slides.length - 1) {
|
|
559
|
+
fail(
|
|
560
|
+
"ambiguous-boundary",
|
|
561
|
+
"Deletion changed unrelated slide boundaries.",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
sameProducerOrder(before, after);
|
|
565
|
+
} else if (operation.type === "delete-block") {
|
|
566
|
+
verifySourceDeletion(before, after, operation.blockId);
|
|
567
|
+
sameProducerOrder(before, after);
|
|
568
|
+
} else {
|
|
569
|
+
// Pinning changes identity metadata, never block content, execution or order.
|
|
570
|
+
const content = (blocks: SourceBlock[]) =>
|
|
571
|
+
blocks.map((b) => [
|
|
572
|
+
b.kind,
|
|
573
|
+
b.kind === "div"
|
|
574
|
+
? b.content.replace(
|
|
575
|
+
/^[ \t]*<!--[ \t]*qb:[\p{L}_][\p{L}\p{N}\p{M}_.:-]*[ \t]*-->[ \t]*(?:\r\n|\n)/gmu,
|
|
576
|
+
"",
|
|
577
|
+
)
|
|
578
|
+
: b.content,
|
|
579
|
+
b.origin,
|
|
580
|
+
b.level,
|
|
581
|
+
]);
|
|
582
|
+
if (
|
|
583
|
+
JSON.stringify(content(before.blocks)) !==
|
|
584
|
+
JSON.stringify(content(after.blocks))
|
|
585
|
+
) fail("unsafe-pin", "Marker insertion changed parsed source semantics.");
|
|
586
|
+
for (const binding of operation.bindings) {
|
|
587
|
+
if (!after.blocks.some((b) => b.id === binding.id && b.stable)) {
|
|
588
|
+
fail(
|
|
589
|
+
"unsafe-pin",
|
|
590
|
+
"Inserted identity did not survive a fresh source scan.",
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/** Preview sequential explicit structural changes. No source or sidecar files are written. */
|
|
597
|
+
export function previewStructure(
|
|
598
|
+
document: SourceDocument,
|
|
599
|
+
operations: StructureOperation[],
|
|
600
|
+
): StructurePreview {
|
|
601
|
+
if (hashSource(document.text) !== document.hash) {
|
|
602
|
+
fail("stale-source", "Source snapshot changed.");
|
|
603
|
+
}
|
|
604
|
+
let current = scanSource(document.text);
|
|
605
|
+
const steps: StructureStep[] = [];
|
|
606
|
+
for (const supplied of operations) {
|
|
607
|
+
const operation = structuredClone(supplied);
|
|
608
|
+
const edits = validateStep(current, operation);
|
|
609
|
+
const result = replace(current, edits), after = scanSource(result);
|
|
610
|
+
verifyResult(current, after, operation);
|
|
611
|
+
steps.push({
|
|
612
|
+
operation,
|
|
613
|
+
beforeHash: current.hash,
|
|
614
|
+
afterHash: after.hash,
|
|
615
|
+
edits,
|
|
616
|
+
});
|
|
617
|
+
current = after;
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
version: 1,
|
|
621
|
+
sourceHash: document.hash,
|
|
622
|
+
resultHash: current.hash,
|
|
623
|
+
operations: structuredClone(operations),
|
|
624
|
+
steps,
|
|
625
|
+
result: current.text,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** Replays and verifies an approved preview against the unchanged original snapshot. */
|
|
629
|
+
export function applyStructure(
|
|
630
|
+
currentText: string,
|
|
631
|
+
preview: StructurePreview,
|
|
632
|
+
): string {
|
|
633
|
+
if (preview.version !== 1 || hashSource(currentText) !== preview.sourceHash) {
|
|
634
|
+
fail(
|
|
635
|
+
"stale-source",
|
|
636
|
+
"Source changed after structural preview; review again.",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
const verified = previewStructure(
|
|
640
|
+
scanSource(currentText),
|
|
641
|
+
preview.operations,
|
|
642
|
+
);
|
|
643
|
+
if (JSON.stringify(verified) !== JSON.stringify(preview)) {
|
|
644
|
+
fail("invalid-preview", "Structural preview was modified after review.");
|
|
645
|
+
}
|
|
646
|
+
return verified.result;
|
|
647
|
+
}
|