@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,550 @@
|
|
|
1
|
+
/** Explicit authored Studio components, saved atomically without source execution. */
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { componentFrame } from "./component-frames.ts";
|
|
4
|
+
import { withProject } from "./project.ts";
|
|
5
|
+
import {
|
|
6
|
+
assertOutputUnchanged,
|
|
7
|
+
type BentoDoc,
|
|
8
|
+
type BentoElement,
|
|
9
|
+
canonical,
|
|
10
|
+
captureEdits,
|
|
11
|
+
clone,
|
|
12
|
+
type ComponentCreation,
|
|
13
|
+
componentIdentities,
|
|
14
|
+
componentProblems,
|
|
15
|
+
type ProjectState,
|
|
16
|
+
StateError,
|
|
17
|
+
} from "../state/mod.ts";
|
|
18
|
+
import { hashSource, scanSource, type SourceDocument } from "../source/mod.ts";
|
|
19
|
+
import {
|
|
20
|
+
type ComponentInsertion,
|
|
21
|
+
componentMarkdown,
|
|
22
|
+
} from "../source/component-insertion.ts";
|
|
23
|
+
import { previewStructure } from "../source/structure.ts";
|
|
24
|
+
import { canonicalInlineHtml, inlineHtmlToMarkdown } from "../render/inline.ts";
|
|
25
|
+
import { escapeHtml } from "../render/html.ts";
|
|
26
|
+
import { projectAst, validateProjectionOrigins } from "../render/project.ts";
|
|
27
|
+
import { type Ast } from "../render/types.ts";
|
|
28
|
+
import { type DesignDefaults } from "../render/design.ts";
|
|
29
|
+
import {
|
|
30
|
+
assertDesignRegistryUnchanged,
|
|
31
|
+
loadDesignRegistry,
|
|
32
|
+
} from "../render/registry.ts";
|
|
33
|
+
import { assertProjectDocument } from "../render/validate.ts";
|
|
34
|
+
export type ComponentRequest = {
|
|
35
|
+
action: "create";
|
|
36
|
+
kind: "card" | "kpi";
|
|
37
|
+
slideId: string;
|
|
38
|
+
fields: { title: string; body: string } | {
|
|
39
|
+
label: string;
|
|
40
|
+
value: string;
|
|
41
|
+
detail?: string;
|
|
42
|
+
};
|
|
43
|
+
revision: number;
|
|
44
|
+
expectedDocId: string;
|
|
45
|
+
} | {
|
|
46
|
+
action: "restore" | "discard";
|
|
47
|
+
id: string;
|
|
48
|
+
revision: number;
|
|
49
|
+
expectedDocId: string;
|
|
50
|
+
};
|
|
51
|
+
function fail(code: string, message: string): never {
|
|
52
|
+
throw new StateError(code, message);
|
|
53
|
+
}
|
|
54
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
function keys(value: object, expected: string[]) {
|
|
58
|
+
return Object.keys(value).length === expected.length &&
|
|
59
|
+
expected.every((key) => Object.hasOwn(value, key));
|
|
60
|
+
}
|
|
61
|
+
function validateRequest(value: unknown): asserts value is ComponentRequest {
|
|
62
|
+
if (
|
|
63
|
+
!object(value) ||
|
|
64
|
+
!["create", "restore", "discard"].includes(value.action as string) ||
|
|
65
|
+
!Number.isSafeInteger(value.revision) || (value.revision as number) < 0 ||
|
|
66
|
+
typeof value.expectedDocId !== "string" || !value.expectedDocId
|
|
67
|
+
) {
|
|
68
|
+
fail(
|
|
69
|
+
"INVALID_COMPONENT_ACTION",
|
|
70
|
+
"Use an exact component action with the saved docId and revision.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (value.action !== "create") {
|
|
74
|
+
if (
|
|
75
|
+
!keys(value, ["action", "id", "revision", "expectedDocId"]) ||
|
|
76
|
+
typeof value.id !== "string" || !value.id
|
|
77
|
+
) {
|
|
78
|
+
fail(
|
|
79
|
+
"INVALID_COMPONENT_ACTION",
|
|
80
|
+
"Restore/discard needs one saved component identity.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
!keys(value, [
|
|
87
|
+
"action",
|
|
88
|
+
"kind",
|
|
89
|
+
"slideId",
|
|
90
|
+
"fields",
|
|
91
|
+
"revision",
|
|
92
|
+
"expectedDocId",
|
|
93
|
+
]) || !["card", "kpi"].includes(value.kind as string) ||
|
|
94
|
+
typeof value.slideId !== "string" || !value.slideId || !object(value.fields)
|
|
95
|
+
) {
|
|
96
|
+
fail(
|
|
97
|
+
"INVALID_COMPONENT_ACTION",
|
|
98
|
+
"Choose a card or indicator with explicit authored fields.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const roles = value.kind === "card" ? ["title", "body"] : [
|
|
102
|
+
"label",
|
|
103
|
+
"value",
|
|
104
|
+
...(Object.hasOwn(value.fields, "detail") ? ["detail"] : []),
|
|
105
|
+
];
|
|
106
|
+
const requestedFields = value.fields as Record<string, unknown>;
|
|
107
|
+
if (
|
|
108
|
+
!keys(value.fields, roles) ||
|
|
109
|
+
roles.some((role) =>
|
|
110
|
+
typeof requestedFields[role] !== "string" ||
|
|
111
|
+
!(requestedFields[role] as string).trim() ||
|
|
112
|
+
(requestedFields[role] as string).length > 6000
|
|
113
|
+
)
|
|
114
|
+
) {
|
|
115
|
+
fail(
|
|
116
|
+
"INVALID_COMPONENT_ACTION",
|
|
117
|
+
"Fill every named authored field (at most 6000 characters each); omit an empty optional detail.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (value.kind === "card" && /[\r\n]/.test(value.fields.title as string)) {
|
|
121
|
+
fail("INVALID_COMPONENT_ACTION", "Use a single-line card title.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function assertCurrentComponentSource(
|
|
125
|
+
state: ProjectState,
|
|
126
|
+
source: SourceDocument,
|
|
127
|
+
): void {
|
|
128
|
+
const hashes = new Set(
|
|
129
|
+
Object.values(state.provenance).map((proof) => proof.source?.hash).filter((
|
|
130
|
+
hash,
|
|
131
|
+
) => typeof hash === "string"),
|
|
132
|
+
);
|
|
133
|
+
if (source.diagnostics.length) {
|
|
134
|
+
fail(
|
|
135
|
+
"AMBIGUOUS_IDENTITY",
|
|
136
|
+
"Resolve source identity/structure diagnostics first. Pending components are retained.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (hashes.size !== 1 || !hashes.has(source.hash)) {
|
|
140
|
+
fail(
|
|
141
|
+
"STALE_SOURCE",
|
|
142
|
+
"QMD differs from the executed baseline. Recalculate and review before creating/syncing a component; pending content is retained.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function componentOperation(
|
|
147
|
+
intent: ComponentCreation,
|
|
148
|
+
document: BentoDoc,
|
|
149
|
+
): ComponentInsertion {
|
|
150
|
+
const slide = document.slides.find((slide) => slide.id === intent.slideId);
|
|
151
|
+
if (!slide) {
|
|
152
|
+
fail(
|
|
153
|
+
"COMPONENT_INCOMPLETE",
|
|
154
|
+
"Restore the containing slide before reviewing this component.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
type: "insert-component",
|
|
159
|
+
kind: intent.kind,
|
|
160
|
+
slideId: intent.slideId,
|
|
161
|
+
id: intent.id,
|
|
162
|
+
fields: intent.members.map((member) => {
|
|
163
|
+
const element = slide.elements.find((element) =>
|
|
164
|
+
element.id === member.nativeId
|
|
165
|
+
);
|
|
166
|
+
if (element?.type !== "text" || typeof element.html !== "string") {
|
|
167
|
+
fail(
|
|
168
|
+
"COMPONENT_INCOMPLETE",
|
|
169
|
+
"Restore the incomplete component or discard it from Add component before synchronizing.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
role: member.role,
|
|
174
|
+
sourceId: member.sourceId,
|
|
175
|
+
markdown: inlineHtmlToMarkdown(element.html),
|
|
176
|
+
};
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Pandoc is a fragment reader here, not Quarto render or a computation engine. */
|
|
181
|
+
export async function projectAuthoredComponent(
|
|
182
|
+
operation: ComponentInsertion,
|
|
183
|
+
docId: string,
|
|
184
|
+
defaults: DesignDefaults = {},
|
|
185
|
+
frames: ReadonlyMap<string, import("../render/design.ts").Frame> = new Map(),
|
|
186
|
+
appearance?: import("../render/kpi.ts").KpiAppearance,
|
|
187
|
+
) {
|
|
188
|
+
const source = `## Component {#${operation.slideId}}\n\n${
|
|
189
|
+
componentMarkdown(operation)
|
|
190
|
+
}`;
|
|
191
|
+
const process = new Deno.Command("quarto", {
|
|
192
|
+
args: ["pandoc", "--from", "markdown", "--to", "json"],
|
|
193
|
+
stdin: "piped",
|
|
194
|
+
stdout: "piped",
|
|
195
|
+
stderr: "piped",
|
|
196
|
+
}).spawn();
|
|
197
|
+
const output = process.output(), writer = process.stdin.getWriter();
|
|
198
|
+
await writer.write(new TextEncoder().encode(source));
|
|
199
|
+
await writer.close();
|
|
200
|
+
const result = await output;
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
fail(
|
|
203
|
+
"COMPONENT_FRAGMENT_UNSUPPORTED",
|
|
204
|
+
"Pandoc could not verify this authored component fragment.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const projection = validateProjectionOrigins(
|
|
208
|
+
scanSource(source),
|
|
209
|
+
await projectAst(
|
|
210
|
+
JSON.parse(new TextDecoder().decode(result.stdout)) as Ast,
|
|
211
|
+
docId,
|
|
212
|
+
[],
|
|
213
|
+
scanSource(source),
|
|
214
|
+
new Map(),
|
|
215
|
+
defaults,
|
|
216
|
+
new Map(),
|
|
217
|
+
frames,
|
|
218
|
+
appearance ? new Map([[operation.slideId, appearance]]) : new Map(),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
const ids = [
|
|
222
|
+
`${operation.id}/qb-${operation.kind}-background`,
|
|
223
|
+
...operation.fields.map((field) =>
|
|
224
|
+
field.role === "title" ? field.sourceId : `${field.sourceId}/paragraph:1`
|
|
225
|
+
),
|
|
226
|
+
];
|
|
227
|
+
const elements = projection.document.slides.find((slide) =>
|
|
228
|
+
slide.id === operation.slideId
|
|
229
|
+
)?.elements.filter((element) => ids.includes(element.id));
|
|
230
|
+
if (!elements || elements.length !== ids.length) {
|
|
231
|
+
fail(
|
|
232
|
+
"COMPONENT_FRAGMENT_UNSUPPORTED",
|
|
233
|
+
"The component did not project exactly its declared native member IDs.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return elements as BentoElement[];
|
|
237
|
+
}
|
|
238
|
+
function freshId(
|
|
239
|
+
state: ProjectState,
|
|
240
|
+
source: SourceDocument,
|
|
241
|
+
kind: string,
|
|
242
|
+
): string {
|
|
243
|
+
const used = new Set([
|
|
244
|
+
...source.blocks.map((block) => block.id),
|
|
245
|
+
...[state.generatedBaseline, state.emitted].flatMap((doc) =>
|
|
246
|
+
doc.slides.flatMap((
|
|
247
|
+
slide,
|
|
248
|
+
) => [slide.id, ...slide.elements.map((element) => element.id)])
|
|
249
|
+
),
|
|
250
|
+
...state.componentCreations?.flatMap((
|
|
251
|
+
intent,
|
|
252
|
+
) => [
|
|
253
|
+
intent.id,
|
|
254
|
+
intent.backgroundId,
|
|
255
|
+
...intent.members.flatMap((member) => [member.sourceId, member.nativeId]),
|
|
256
|
+
]) ?? [],
|
|
257
|
+
...state.bentoOnly.map((item) => item.element.id),
|
|
258
|
+
...state.componentProjections?.flatMap((record) =>
|
|
259
|
+
componentIdentities(record.id, record.kind)
|
|
260
|
+
) ?? [],
|
|
261
|
+
...[...state.pending, ...state.overrides].flatMap((item) => [
|
|
262
|
+
item.target.slideId ?? "",
|
|
263
|
+
item.target.elementId ?? "",
|
|
264
|
+
]),
|
|
265
|
+
...state.elementOrders.flatMap((order) => [order.slideId, ...order.ids]),
|
|
266
|
+
]);
|
|
267
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
268
|
+
const id = `qb-${kind}-${
|
|
269
|
+
crypto.randomUUID().replaceAll("-", "").slice(0, 20)
|
|
270
|
+
}`;
|
|
271
|
+
const prospective = [
|
|
272
|
+
id,
|
|
273
|
+
`${id}/qb-${kind}-background`,
|
|
274
|
+
...["title", "body", "label", "value", "detail"].flatMap((
|
|
275
|
+
role,
|
|
276
|
+
) => [`${id}-${role}`, `${id}-${role}/paragraph:1`]),
|
|
277
|
+
];
|
|
278
|
+
if (prospective.every((id) => !used.has(id))) return id;
|
|
279
|
+
}
|
|
280
|
+
return fail(
|
|
281
|
+
"COMPONENT_ID_COLLISION",
|
|
282
|
+
"Could not reserve globally fresh source/member identities.",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
export async function applyComponentAction(
|
|
286
|
+
source: string,
|
|
287
|
+
request: ComponentRequest,
|
|
288
|
+
): Promise<ProjectState> {
|
|
289
|
+
validateRequest(request);
|
|
290
|
+
return await withProject(source, async (paths, store) => {
|
|
291
|
+
const state = await store.load();
|
|
292
|
+
if (!state) fail("NO_BASELINE", "Render the project first.");
|
|
293
|
+
if (state.docId !== request.expectedDocId) {
|
|
294
|
+
fail("UNRELATED_DOCUMENT", "Reload this project's saved document.");
|
|
295
|
+
}
|
|
296
|
+
if (state.revision !== request.revision) {
|
|
297
|
+
fail("STALE_REVISION", "Reload before changing components.");
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
await Deno.lstat(resolve(paths.stateDir, "sync-approved.json"));
|
|
301
|
+
fail(
|
|
302
|
+
"APPROVAL_PENDING",
|
|
303
|
+
"Render or recover the approved source synchronization before changing components.",
|
|
304
|
+
);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
|
307
|
+
}
|
|
308
|
+
await assertOutputUnchanged(state, paths.output);
|
|
309
|
+
const text = await Deno.readTextFile(paths.source),
|
|
310
|
+
mapping = scanSource(text),
|
|
311
|
+
doc = clone(state.emitted);
|
|
312
|
+
let next: ProjectState;
|
|
313
|
+
if (request.action === "create") {
|
|
314
|
+
assertCurrentComponentSource(state, mapping);
|
|
315
|
+
if (
|
|
316
|
+
(state.componentCreations?.length ?? 0) >= 128 ||
|
|
317
|
+
(state.componentCreations?.length ?? 0) +
|
|
318
|
+
(state.componentProjections?.length ?? 0) >= 5000
|
|
319
|
+
) {
|
|
320
|
+
fail(
|
|
321
|
+
"COMPONENT_LIMIT",
|
|
322
|
+
"Review pending components before adding more; the saved component collection has reached its supported bound.",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const slide = doc.slides.find((slide) => slide.id === request.slideId);
|
|
326
|
+
if (
|
|
327
|
+
!slide || !state.generatedBaseline.slides.some((slide) =>
|
|
328
|
+
slide.id === request.slideId
|
|
329
|
+
) || !mapping.slides.some((sourceSlide) =>
|
|
330
|
+
sourceSlide.id === request.slideId ||
|
|
331
|
+
Object.entries(state.provenance).some(([key, proof]) =>
|
|
332
|
+
JSON.parse(key)[0] === request.slideId &&
|
|
333
|
+
proof.origin === "authored" &&
|
|
334
|
+
proof.source?.blockId === sourceSlide.headingId &&
|
|
335
|
+
proof.source?.kind === "heading" &&
|
|
336
|
+
proof.source?.hash === mapping.hash
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
) {
|
|
340
|
+
fail(
|
|
341
|
+
"COMPONENT_SOURCE_SLIDE_REQUIRED",
|
|
342
|
+
"Choose an existing QMD slide; restore pending slide deletions first.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const id = freshId(state, mapping, request.kind);
|
|
346
|
+
const requestedFields = request.fields as Record<string, string>;
|
|
347
|
+
const roleOrder = request.kind === "card" ? ["title", "body"] : [
|
|
348
|
+
"label",
|
|
349
|
+
"value",
|
|
350
|
+
...(Object.hasOwn(requestedFields, "detail") ? ["detail"] : []),
|
|
351
|
+
];
|
|
352
|
+
const fields = roleOrder.map((role) => ({
|
|
353
|
+
role: role as ComponentInsertion["fields"][number]["role"],
|
|
354
|
+
sourceId: `${id}-${role}`,
|
|
355
|
+
markdown: inlineHtmlToMarkdown(
|
|
356
|
+
escapeHtml(requestedFields[role]).replace(/\r\n|\r|\n/g, "<br>"),
|
|
357
|
+
),
|
|
358
|
+
}));
|
|
359
|
+
const operation: ComponentInsertion = {
|
|
360
|
+
type: "insert-component",
|
|
361
|
+
kind: request.kind,
|
|
362
|
+
id,
|
|
363
|
+
slideId: slide.id,
|
|
364
|
+
fields,
|
|
365
|
+
};
|
|
366
|
+
if (
|
|
367
|
+
mapping.slides.some((slide) =>
|
|
368
|
+
slide.id === request.slideId && slide.stable
|
|
369
|
+
)
|
|
370
|
+
) {
|
|
371
|
+
previewStructure(mapping, [operation]);
|
|
372
|
+
}
|
|
373
|
+
const registry = await loadDesignRegistry(paths.directory);
|
|
374
|
+
const elements = await projectAuthoredComponent(
|
|
375
|
+
operation,
|
|
376
|
+
state.docId,
|
|
377
|
+
registry.defaults,
|
|
378
|
+
);
|
|
379
|
+
for (const field of fields) {
|
|
380
|
+
const current = elements.find((element) =>
|
|
381
|
+
element.id ===
|
|
382
|
+
(field.role === "title"
|
|
383
|
+
? field.sourceId
|
|
384
|
+
: `${field.sourceId}/paragraph:1`)
|
|
385
|
+
);
|
|
386
|
+
const requestedHtml = canonicalInlineHtml(
|
|
387
|
+
escapeHtml(requestedFields[field.role]).replace(
|
|
388
|
+
/\r\n|\r|\n/g,
|
|
389
|
+
"<br>",
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
if (current?.html !== requestedHtml) {
|
|
393
|
+
fail(
|
|
394
|
+
"COMPONENT_FRAGMENT_UNSUPPORTED",
|
|
395
|
+
"The authored text changes when parsed; edit unsupported text explicitly in QMD.",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
await assertDesignRegistryUnchanged(paths.directory, registry.hash);
|
|
400
|
+
slide.elements.push(...elements);
|
|
401
|
+
assertProjectDocument(doc);
|
|
402
|
+
next = captureEdits(state, doc);
|
|
403
|
+
const backgroundId = `${id}/qb-${request.kind}-background`;
|
|
404
|
+
(next.componentCreations ??= []).push({
|
|
405
|
+
version: 1,
|
|
406
|
+
id,
|
|
407
|
+
kind: request.kind,
|
|
408
|
+
slideId: slide.id,
|
|
409
|
+
backgroundId,
|
|
410
|
+
projectionFrame: componentFrame(
|
|
411
|
+
elements.find((element) =>
|
|
412
|
+
element.id === backgroundId
|
|
413
|
+
)!,
|
|
414
|
+
),
|
|
415
|
+
restoreOrder: slide.elements.map((element) => element.id),
|
|
416
|
+
backgroundSnapshot: clone(
|
|
417
|
+
elements.find((element) => element.id === backgroundId)!,
|
|
418
|
+
),
|
|
419
|
+
members: fields.map((field) => {
|
|
420
|
+
const nativeId = field.role === "title"
|
|
421
|
+
? field.sourceId
|
|
422
|
+
: `${field.sourceId}/paragraph:1`;
|
|
423
|
+
return {
|
|
424
|
+
role: field.role,
|
|
425
|
+
sourceId: field.sourceId,
|
|
426
|
+
nativeId,
|
|
427
|
+
snapshot: clone(
|
|
428
|
+
elements.find((element) => element.id === nativeId)!,
|
|
429
|
+
),
|
|
430
|
+
};
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
} else {
|
|
434
|
+
const intent = state.componentCreations?.find((intent) =>
|
|
435
|
+
intent.id === request.id
|
|
436
|
+
);
|
|
437
|
+
if (!intent) {
|
|
438
|
+
fail(
|
|
439
|
+
"COMPONENT_NOT_FOUND",
|
|
440
|
+
"The pending component is unavailable; reload the project.",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const slide = doc.slides.find((slide) => slide.id === intent.slideId);
|
|
444
|
+
const ids = [
|
|
445
|
+
intent.backgroundId,
|
|
446
|
+
...intent.members.map((member) => member.nativeId),
|
|
447
|
+
];
|
|
448
|
+
if (request.action === "restore") {
|
|
449
|
+
if (!slide) {
|
|
450
|
+
fail(
|
|
451
|
+
"COMPONENT_PARENT_MISSING",
|
|
452
|
+
"Restore the containing slide from Slides before restoring its component.",
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const missing = [
|
|
456
|
+
intent.backgroundSnapshot,
|
|
457
|
+
...intent.members.map((member) => member.snapshot),
|
|
458
|
+
].filter((snapshot) =>
|
|
459
|
+
!slide.elements.some((element) => element.id === snapshot.id)
|
|
460
|
+
);
|
|
461
|
+
if (!missing.length) {
|
|
462
|
+
fail(
|
|
463
|
+
"COMPONENT_NOT_INCOMPLETE",
|
|
464
|
+
"No original member is missing. Resolve changed member types/extra group objects natively or discard the pending component.",
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
missing.sort((a, b) =>
|
|
468
|
+
intent.restoreOrder.indexOf(a.id) - intent.restoreOrder.indexOf(b.id)
|
|
469
|
+
);
|
|
470
|
+
for (const snapshot of missing) {
|
|
471
|
+
const position = intent.restoreOrder.indexOf(snapshot.id);
|
|
472
|
+
const successors = intent.restoreOrder.slice(position + 1);
|
|
473
|
+
const predecessors = intent.restoreOrder.slice(0, position);
|
|
474
|
+
const following = slide.elements.findIndex((element) =>
|
|
475
|
+
successors.includes(element.id)
|
|
476
|
+
);
|
|
477
|
+
const preceding = slide.elements.findLastIndex((element) =>
|
|
478
|
+
predecessors.includes(element.id)
|
|
479
|
+
);
|
|
480
|
+
const index = following >= 0
|
|
481
|
+
? following
|
|
482
|
+
: preceding >= 0
|
|
483
|
+
? preceding + 1
|
|
484
|
+
: slide.elements.length;
|
|
485
|
+
slide.elements.splice(index, 0, clone(snapshot));
|
|
486
|
+
}
|
|
487
|
+
assertProjectDocument(doc);
|
|
488
|
+
next = captureEdits(state, doc);
|
|
489
|
+
} else {
|
|
490
|
+
// Explicit discard releases only this intent's identities, preserving unrelated objects.
|
|
491
|
+
const discarded = clone(state);
|
|
492
|
+
discarded.componentCreations = discarded.componentCreations!.filter((
|
|
493
|
+
item,
|
|
494
|
+
) => item.id !== intent.id);
|
|
495
|
+
if (!discarded.componentCreations.length) {
|
|
496
|
+
delete discarded.componentCreations;
|
|
497
|
+
}
|
|
498
|
+
if (slide) {
|
|
499
|
+
slide.elements = slide.elements.filter((element) =>
|
|
500
|
+
!ids.includes(element.id)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
next = captureEdits(discarded, doc);
|
|
504
|
+
next.pending = next.pending.filter((change) =>
|
|
505
|
+
!(change.target.slideId === intent.slideId &&
|
|
506
|
+
ids.includes(change.target.elementId!))
|
|
507
|
+
);
|
|
508
|
+
next.bentoOnly = next.bentoOnly.filter((item) =>
|
|
509
|
+
!(item.slideId === intent.slideId && ids.includes(item.element.id))
|
|
510
|
+
);
|
|
511
|
+
next.overrides = next.overrides.filter((item) =>
|
|
512
|
+
!(item.target.slideId === intent.slideId &&
|
|
513
|
+
ids.includes(item.target.elementId!))
|
|
514
|
+
);
|
|
515
|
+
for (
|
|
516
|
+
const order of next.elementOrders.filter((order) =>
|
|
517
|
+
order.slideId === intent.slideId
|
|
518
|
+
)
|
|
519
|
+
) order.ids = order.ids.filter((id) => !ids.includes(id));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (hashSource(await Deno.readTextFile(paths.source)) !== mapping.hash) {
|
|
523
|
+
fail(
|
|
524
|
+
"STALE_SOURCE",
|
|
525
|
+
"QMD changed during this component action; retry from the current source.",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (canonical(await store.load()) !== canonical(state)) {
|
|
529
|
+
fail(
|
|
530
|
+
"STALE_REVISION",
|
|
531
|
+
"Saved state changed during this component action; reload.",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return await store.save(next, request.revision);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
export function pendingComponentDiagnostics(state: ProjectState) {
|
|
538
|
+
return (state.componentCreations ?? []).flatMap((intent) => {
|
|
539
|
+
const problems = componentProblems(state, intent);
|
|
540
|
+
return problems.length
|
|
541
|
+
? [{
|
|
542
|
+
code: "COMPONENT_INCOMPLETE",
|
|
543
|
+
slideId: intent.slideId,
|
|
544
|
+
elementId: intent.id,
|
|
545
|
+
message: problems.join(" ") +
|
|
546
|
+
" Abre Añadir componente para restaurar miembros ausentes o descartar esta creación pendiente.",
|
|
547
|
+
}]
|
|
548
|
+
: [];
|
|
549
|
+
});
|
|
550
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Approved bootstrap frames are independent from mutable native decoration. */
|
|
2
|
+
import { type BentoElement, type ProjectState } from "../state/mod.ts";
|
|
3
|
+
import type { Frame } from "../render/design.ts";
|
|
4
|
+
import type { SourceDocument } from "../source/mod.ts";
|
|
5
|
+
import { kpiSource } from "../source/kpi.ts";
|
|
6
|
+
import {
|
|
7
|
+
originalAttributeTokens,
|
|
8
|
+
originalPandocAttributes,
|
|
9
|
+
} from "../source/attributes.ts";
|
|
10
|
+
export function componentFrame(element: BentoElement): Frame {
|
|
11
|
+
return {
|
|
12
|
+
x: Number(element.x),
|
|
13
|
+
y: Number(element.y),
|
|
14
|
+
w: Number(element.w),
|
|
15
|
+
h: Number(element.h),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function retainedComponentFrames(
|
|
19
|
+
state?: ProjectState | null,
|
|
20
|
+
source?: SourceDocument,
|
|
21
|
+
): ReadonlyMap<string, Frame> {
|
|
22
|
+
const frames = new Map<string, Frame>();
|
|
23
|
+
if (!state) return frames;
|
|
24
|
+
const records = [
|
|
25
|
+
...(state.componentCreations ?? []).map((intent) => ({
|
|
26
|
+
...intent,
|
|
27
|
+
frame: intent.projectionFrame,
|
|
28
|
+
})),
|
|
29
|
+
...(state.componentProjections ?? []),
|
|
30
|
+
];
|
|
31
|
+
for (const record of records) {
|
|
32
|
+
if (source) {
|
|
33
|
+
const blocks = source.blocks.filter((block) => block.id === record.id);
|
|
34
|
+
const block = blocks[0];
|
|
35
|
+
if (
|
|
36
|
+
blocks.length !== 1 || !block.stable || block.kind !== "div" ||
|
|
37
|
+
block.slideId !== record.slideId
|
|
38
|
+
) continue;
|
|
39
|
+
const declaration = /^ {0,3}:{3,}\s*\{([^{}]*)\}\s*$/.exec(
|
|
40
|
+
block.raw.split(/\r?\n/)[0],
|
|
41
|
+
)?.[1];
|
|
42
|
+
if (
|
|
43
|
+
declaration === undefined ||
|
|
44
|
+
!originalPandocAttributes(
|
|
45
|
+
declaration,
|
|
46
|
+
originalAttributeTokens(declaration),
|
|
47
|
+
)[1].includes(`bento-${record.kind}`)
|
|
48
|
+
) continue;
|
|
49
|
+
if (block.origin !== "authored") {
|
|
50
|
+
if (record.kind !== "kpi") continue;
|
|
51
|
+
// A literal source KPI wrapper becomes aggregate-computed when its value
|
|
52
|
+
// is authored as inline code. Source role proof remains the authority.
|
|
53
|
+
kpiSource(source, record.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
frames.set(record.id, { ...record.frame });
|
|
57
|
+
}
|
|
58
|
+
return frames;
|
|
59
|
+
}
|