@brianmsm/quarto-bento 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/bin/qbento-npm.mjs +62 -0
  4. package/distribution/LICENSE +21 -0
  5. package/distribution/PACKAGING.md +59 -0
  6. package/distribution/bin/qbento +5 -0
  7. package/distribution/bin/qbento.cmd +4 -0
  8. package/distribution/manifest.json +720 -0
  9. package/distribution/runtime/LICENSE +21 -0
  10. package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
  11. package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
  12. package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
  13. package/distribution/runtime/_extensions/qbento/post.ts +2 -0
  14. package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
  15. package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
  16. package/distribution/runtime/src/bridge/browser.js +168 -0
  17. package/distribution/runtime/src/bridge/isolation.ts +55 -0
  18. package/distribution/runtime/src/browser/chromium.ts +218 -0
  19. package/distribution/runtime/src/cli.ts +219 -0
  20. package/distribution/runtime/src/core/agent.ts +565 -0
  21. package/distribution/runtime/src/core/annotations.ts +384 -0
  22. package/distribution/runtime/src/core/capture.ts +203 -0
  23. package/distribution/runtime/src/core/component-actions.ts +550 -0
  24. package/distribution/runtime/src/core/component-frames.ts +59 -0
  25. package/distribution/runtime/src/core/component-sync.ts +290 -0
  26. package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
  27. package/distribution/runtime/src/core/design-registry.ts +287 -0
  28. package/distribution/runtime/src/core/graph-relations.ts +354 -0
  29. package/distribution/runtime/src/core/graph-scope.ts +63 -0
  30. package/distribution/runtime/src/core/graph-sync.ts +228 -0
  31. package/distribution/runtime/src/core/io.ts +27 -0
  32. package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
  33. package/distribution/runtime/src/core/layouts.ts +245 -0
  34. package/distribution/runtime/src/core/project.ts +759 -0
  35. package/distribution/runtime/src/core/reconcile.ts +384 -0
  36. package/distribution/runtime/src/core/recovery.ts +1255 -0
  37. package/distribution/runtime/src/core/slide-actions.ts +158 -0
  38. package/distribution/runtime/src/core/source-scope.ts +50 -0
  39. package/distribution/runtime/src/core/sync.ts +2423 -0
  40. package/distribution/runtime/src/core/widgets.ts +300 -0
  41. package/distribution/runtime/src/integration/install.ts +98 -0
  42. package/distribution/runtime/src/integration/invocation.ts +100 -0
  43. package/distribution/runtime/src/integration/quarto.ts +457 -0
  44. package/distribution/runtime/src/render/assets.ts +66 -0
  45. package/distribution/runtime/src/render/capture.lua +8 -0
  46. package/distribution/runtime/src/render/chart.ts +165 -0
  47. package/distribution/runtime/src/render/components.ts +199 -0
  48. package/distribution/runtime/src/render/design.ts +820 -0
  49. package/distribution/runtime/src/render/diagnostics.ts +36 -0
  50. package/distribution/runtime/src/render/graphs.ts +747 -0
  51. package/distribution/runtime/src/render/html.ts +82 -0
  52. package/distribution/runtime/src/render/images.ts +268 -0
  53. package/distribution/runtime/src/render/inline.ts +259 -0
  54. package/distribution/runtime/src/render/kpi.ts +385 -0
  55. package/distribution/runtime/src/render/lists.ts +183 -0
  56. package/distribution/runtime/src/render/math.ts +413 -0
  57. package/distribution/runtime/src/render/measure.ts +185 -0
  58. package/distribution/runtime/src/render/native-layout.ts +174 -0
  59. package/distribution/runtime/src/render/producer-capture.ts +319 -0
  60. package/distribution/runtime/src/render/producer-environment.R +49 -0
  61. package/distribution/runtime/src/render/producer-environment.ts +96 -0
  62. package/distribution/runtime/src/render/producer-profile.R +91 -0
  63. package/distribution/runtime/src/render/producer-site-profile.R +18 -0
  64. package/distribution/runtime/src/render/project.ts +1806 -0
  65. package/distribution/runtime/src/render/quarto.ts +127 -0
  66. package/distribution/runtime/src/render/registry.ts +233 -0
  67. package/distribution/runtime/src/render/resources.ts +292 -0
  68. package/distribution/runtime/src/render/shell.ts +110 -0
  69. package/distribution/runtime/src/render/source-inline.ts +169 -0
  70. package/distribution/runtime/src/render/table.ts +220 -0
  71. package/distribution/runtime/src/render/text-overflow.ts +208 -0
  72. package/distribution/runtime/src/render/types.ts +71 -0
  73. package/distribution/runtime/src/render/validate.ts +572 -0
  74. package/distribution/runtime/src/rich/project.ts +117 -0
  75. package/distribution/runtime/src/rich/table.ts +374 -0
  76. package/distribution/runtime/src/source/annotations.ts +490 -0
  77. package/distribution/runtime/src/source/attributes.ts +415 -0
  78. package/distribution/runtime/src/source/chart.ts +165 -0
  79. package/distribution/runtime/src/source/code-language.ts +277 -0
  80. package/distribution/runtime/src/source/component-insertion.ts +160 -0
  81. package/distribution/runtime/src/source/deletion.ts +278 -0
  82. package/distribution/runtime/src/source/graphs.ts +517 -0
  83. package/distribution/runtime/src/source/identities.ts +256 -0
  84. package/distribution/runtime/src/source/identity-id.ts +6 -0
  85. package/distribution/runtime/src/source/image.ts +50 -0
  86. package/distribution/runtime/src/source/kpi.ts +196 -0
  87. package/distribution/runtime/src/source/mod.ts +876 -0
  88. package/distribution/runtime/src/source/notes.ts +308 -0
  89. package/distribution/runtime/src/source/structure.ts +647 -0
  90. package/distribution/runtime/src/source/table.ts +290 -0
  91. package/distribution/runtime/src/source/text-conversion.ts +146 -0
  92. package/distribution/runtime/src/state/components.ts +281 -0
  93. package/distribution/runtime/src/state/graphs.ts +200 -0
  94. package/distribution/runtime/src/state/json.ts +162 -0
  95. package/distribution/runtime/src/state/merge.ts +1034 -0
  96. package/distribution/runtime/src/state/mod.ts +5 -0
  97. package/distribution/runtime/src/state/store.ts +226 -0
  98. package/distribution/runtime/src/state/types.ts +151 -0
  99. package/distribution/runtime/src/studio/client.js +790 -0
  100. package/distribution/runtime/src/studio/components.js +188 -0
  101. package/distribution/runtime/src/studio/index.html +278 -0
  102. package/distribution/runtime/src/studio/layouts.js +170 -0
  103. package/distribution/runtime/src/studio/registry.js +283 -0
  104. package/distribution/runtime/src/studio/relations.js +169 -0
  105. package/distribution/runtime/src/studio/server.ts +433 -0
  106. package/distribution/runtime/src/widgets/deck.ts +39 -0
  107. package/distribution/runtime/src/widgets/plotly.ts +354 -0
  108. package/distribution/runtime/src/widgets/presenter.ts +367 -0
  109. package/distribution/runtime/src/widgets/snapshot.ts +116 -0
  110. package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
  111. package/distribution/runtime/vendor/bento/LICENSE +21 -0
  112. package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
  113. package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
  114. package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
  115. package/distribution/runtime/vendor/bento/pin.json +69 -0
  116. package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
  117. package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
  118. package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
  119. package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
  120. package/distribution/runtime/vendor/bento/shell.html +269 -0
  121. package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
  122. package/package.json +40 -4
@@ -0,0 +1,290 @@
1
+ /** Exact component approvals supplement ordinary pending-text promotion. */
2
+ import {
3
+ type BentoDoc,
4
+ canonical,
5
+ clone,
6
+ type ComponentCreation,
7
+ componentProblems,
8
+ type ComponentProjection,
9
+ type ProjectState,
10
+ type ProvenanceMap,
11
+ StateError,
12
+ targetKey,
13
+ validateComponentCreations,
14
+ validateComponentProjections,
15
+ } from "../state/mod.ts";
16
+ import { componentFrame } from "./component-frames.ts";
17
+ import { fields } from "../state/json.ts";
18
+ import {
19
+ type ComponentInsertion,
20
+ componentMarkdown,
21
+ } from "../source/component-insertion.ts";
22
+ export interface ComponentApproval {
23
+ version: 1;
24
+ intent: ComponentCreation;
25
+ operation: ComponentInsertion;
26
+ }
27
+ function fail(code: string, message: string): never {
28
+ throw new StateError(code, message);
29
+ }
30
+ export function validateComponentApprovals(
31
+ value: unknown,
32
+ retirements: unknown,
33
+ docId: string,
34
+ projectionRetirements?: ComponentProjection[],
35
+ ) {
36
+ if (value !== undefined) {
37
+ if (!Array.isArray(value) || !value.length) {
38
+ fail(
39
+ "INVALID_APPROVAL",
40
+ "Component approvals must be a nonempty exact collection.",
41
+ );
42
+ }
43
+ validateComponentCreations(
44
+ value.map((approval) => approval?.intent),
45
+ docId,
46
+ );
47
+ for (const approval of value as ComponentApproval[]) {
48
+ if (
49
+ !approval ||
50
+ Object.keys(approval).sort().join(",") !== "intent,operation,version" ||
51
+ approval.version !== 1 ||
52
+ approval.operation?.id !== approval.intent.id ||
53
+ approval.operation?.kind !== approval.intent.kind ||
54
+ approval.operation?.slideId !== approval.intent.slideId ||
55
+ canonical(
56
+ approval.operation.fields.map((
57
+ field,
58
+ ) => [field.role, field.sourceId]),
59
+ ) !==
60
+ canonical(
61
+ approval.intent.members.map((
62
+ member,
63
+ ) => [member.role, member.sourceId]),
64
+ )
65
+ ) {
66
+ fail(
67
+ "INVALID_APPROVAL",
68
+ "Component approval does not identify its exact authored roles.",
69
+ );
70
+ }
71
+ componentMarkdown(approval.operation);
72
+ }
73
+ }
74
+ if (retirements !== undefined) {
75
+ validateComponentCreations(retirements, docId);
76
+ if (
77
+ !(retirements as ComponentCreation[]).length ||
78
+ (retirements as ComponentCreation[]).some((retired) =>
79
+ (value as ComponentApproval[] | undefined)?.some((approval) =>
80
+ approval.intent.id === retired.id
81
+ )
82
+ )
83
+ ) {
84
+ fail(
85
+ "INVALID_APPROVAL",
86
+ "Component retirement overlaps its source acquisition.",
87
+ );
88
+ }
89
+ }
90
+ if (projectionRetirements !== undefined) {
91
+ validateComponentProjections(projectionRetirements, [
92
+ ...(value as ComponentApproval[] | undefined)?.map((approval) =>
93
+ approval.intent
94
+ ) ?? [],
95
+ ...(retirements as ComponentCreation[] | undefined) ?? [],
96
+ ]);
97
+ if (!projectionRetirements.length) {
98
+ fail(
99
+ "INVALID_APPROVAL",
100
+ "Projection retirement must be a nonempty exact collection.",
101
+ );
102
+ }
103
+ }
104
+ }
105
+ /** Compare approved metadata before consuming any text/background. No ID-only promotion. */
106
+ export function prepareComponentApprovals(
107
+ state: ProjectState,
108
+ generated: BentoDoc,
109
+ sourceHash: string,
110
+ approvals: ComponentApproval[] = [],
111
+ retirements: ComponentCreation[] = [],
112
+ freshProvenance?: ProvenanceMap,
113
+ projectionRetirements: ComponentProjection[] = [],
114
+ ): ProjectState {
115
+ const prepared = clone(state);
116
+ for (
117
+ const expected of [
118
+ ...approvals.map((approval) => approval.intent),
119
+ ...retirements,
120
+ ]
121
+ ) {
122
+ if (
123
+ canonical(
124
+ state.componentCreations?.find((intent) => intent.id === expected.id),
125
+ ) !== canonical(expected)
126
+ ) {
127
+ fail(
128
+ "STALE_APPROVAL",
129
+ "A component's saved membership or member snapshots changed after source synchronization; preserve and review it.",
130
+ );
131
+ }
132
+ }
133
+ for (const record of projectionRetirements) {
134
+ if (
135
+ canonical(
136
+ state.componentProjections?.find((item) => item.id === record.id),
137
+ ) !== canonical(record) ||
138
+ generated.slides.some((slide) => slide.id === record.slideId)
139
+ ) {
140
+ fail(
141
+ "STALE_APPROVAL",
142
+ "Retained projection retires only with its exact approved record and fresh parent deletion proof.",
143
+ );
144
+ }
145
+ prepared.componentProjections = prepared.componentProjections?.filter((
146
+ item,
147
+ ) => item.id !== record.id);
148
+ }
149
+ for (const intent of retirements) {
150
+ if (generated.slides.some((slide) => slide.id === intent.slideId)) {
151
+ fail(
152
+ "STALE_APPROVAL",
153
+ "Fresh execution must omit the exact approved parent slide before retiring its pending component.",
154
+ );
155
+ }
156
+ prepared.componentCreations = prepared.componentCreations?.filter((item) =>
157
+ item.id !== intent.id
158
+ );
159
+ }
160
+ for (const { intent, operation } of approvals) {
161
+ if (componentProblems(state, intent).length) {
162
+ fail(
163
+ "STALE_APPROVAL",
164
+ "Approved component no longer has its exact saved members and background.",
165
+ );
166
+ }
167
+ const slide = generated.slides.find((slide) => slide.id === intent.slideId);
168
+ const wrapper = componentMarkdown(operation);
169
+ // Both source hash and fresh source role proof are mandatory; input provenance is insufficient.
170
+ const background = slide?.elements.find((element) =>
171
+ element.id === intent.backgroundId
172
+ );
173
+ const proof = freshProvenance
174
+ ?.[
175
+ targetKey({ slideId: intent.slideId, elementId: intent.backgroundId })
176
+ ];
177
+ if (
178
+ !background ||
179
+ canonical(componentFrame(background)) !==
180
+ canonical(intent.projectionFrame) ||
181
+ background.type !== "shape" ||
182
+ background.shape !== "rect" || background.groupId !== intent.id ||
183
+ proof?.origin !== "authored" || proof.semanticId !== intent.id ||
184
+ proof.source?.hash !== sourceHash || proof.source?.kind !== "div" ||
185
+ proof.source?.blockId !== intent.id || proof.source?.stable !== true ||
186
+ String(proof.source?.content).replace(/\r\n/g, "\n") !==
187
+ wrapper.slice(wrapper.indexOf("\n") + 1, wrapper.lastIndexOf("::::"))
188
+ ) {
189
+ fail(
190
+ "COMPONENT_PROMOTION_PROOF",
191
+ "Fresh execution must prove the exact identified component and generated design-only background before consuming its pending design.",
192
+ );
193
+ }
194
+ for (const member of intent.members) {
195
+ const roleProof = freshProvenance
196
+ ?.[targetKey({ slideId: intent.slideId, elementId: member.nativeId })];
197
+ const element = slide?.elements.find((element) =>
198
+ element.id === member.nativeId
199
+ );
200
+ if (
201
+ !element || element.type !== "text" ||
202
+ roleProof?.origin !== "authored" ||
203
+ roleProof.semanticId !== member.nativeId ||
204
+ roleProof.source?.hash !== sourceHash ||
205
+ roleProof.source?.blockId !== member.nativeId ||
206
+ roleProof.source?.kind !==
207
+ (member.role === "title" ? "heading" : "paragraph") ||
208
+ (member.role !== "title" &&
209
+ (roleProof.source?.parentId !== member.sourceId ||
210
+ roleProof.source?.siblings !== 1))
211
+ ) {
212
+ fail(
213
+ "COMPONENT_PROMOTION_PROOF",
214
+ "Fresh execution must prove every authored role, source binding and native ID; computed substitutions cannot acquire an authored component.",
215
+ );
216
+ }
217
+ }
218
+ const saved = state.bentoOnly.find((item) =>
219
+ item.slideId === intent.slideId && item.element.id === intent.backgroundId
220
+ );
221
+ if (
222
+ !saved ||
223
+ canonical(saved.element) !== canonical(intent.backgroundSnapshot)
224
+ ) {
225
+ fail(
226
+ "STALE_APPROVAL",
227
+ "The approved pending background snapshot is no longer the saved decoration.",
228
+ );
229
+ }
230
+ const target = { slideId: intent.slideId, elementId: intent.backgroundId };
231
+ for (const field of fields(background, saved.element, ["id"])) {
232
+ let overlay = prepared.overrides.find((item) =>
233
+ canonical(item.target) === canonical(target)
234
+ );
235
+ if (!overlay) {
236
+ overlay = { target, fields: [] };
237
+ prepared.overrides.push(overlay);
238
+ }
239
+ overlay.fields = overlay.fields.filter((item) =>
240
+ canonical(item.path) !== canonical(field.path)
241
+ );
242
+ overlay.fields.push(clone(field));
243
+ }
244
+ // Persist the approved measurement bootstrap, never the saved decorative frame.
245
+ (prepared.componentProjections ??= []).push({
246
+ version: 1,
247
+ id: intent.id,
248
+ kind: intent.kind,
249
+ slideId: intent.slideId,
250
+ frame: clone(intent.projectionFrame),
251
+ });
252
+ // The native background placement is a visual override applied only by compose.
253
+ for (
254
+ const native of [
255
+ saved.element,
256
+ ...intent.members.map((member) => member.snapshot),
257
+ ]
258
+ ) {
259
+ const memberTarget = { slideId: intent.slideId, elementId: native.id };
260
+ let frameOverlay = prepared.overrides.find((item) =>
261
+ canonical(item.target) === canonical(memberTarget)
262
+ );
263
+ if (!frameOverlay) {
264
+ frameOverlay = { target: memberTarget, fields: [] };
265
+ prepared.overrides.push(frameOverlay);
266
+ }
267
+ for (const path of ["x", "y", "w", "h"]) {
268
+ frameOverlay.fields = frameOverlay.fields.filter((field) =>
269
+ canonical(field.path) !== canonical([path])
270
+ );
271
+ frameOverlay.fields.push({
272
+ path: [path],
273
+ value: { present: true, value: native[path] },
274
+ });
275
+ }
276
+ }
277
+ prepared.bentoOnly = prepared.bentoOnly.filter((item) =>
278
+ !(item.slideId === intent.slideId &&
279
+ item.element.id === intent.backgroundId)
280
+ );
281
+ prepared.componentCreations = prepared.componentCreations?.filter((item) =>
282
+ item.id !== intent.id
283
+ );
284
+ }
285
+ if (!prepared.componentCreations?.length) delete prepared.componentCreations;
286
+ if (!prepared.componentProjections?.length) {
287
+ delete prepared.componentProjections;
288
+ }
289
+ return prepared;
290
+ }
@@ -0,0 +1,78 @@
1
+ /** Observed original-QMD design traces, never a claim of historical restoration. */
2
+ import type { Diagnostic } from "../render/types.ts";
3
+ import type { DesignDefaults } from "../render/design.ts";
4
+ import type { ProjectState } from "../state/mod.ts";
5
+ import { type SourceDocument, SourceError } from "../source/mod.ts";
6
+ import { readDesignAnnotations } from "../source/annotations.ts";
7
+ import { annotationRequests } from "./annotations.ts";
8
+
9
+ /** Pure reevaluation keeps an empty newly rendered baseline from hiding lost design. */
10
+ export function missingDesignDiagnostics(
11
+ document: SourceDocument,
12
+ state: ProjectState | null | undefined,
13
+ registry: { defaults: DesignDefaults; hash: string | null },
14
+ ): Diagnostic[] {
15
+ let traces;
16
+ try {
17
+ traces = readDesignAnnotations(document);
18
+ } catch (error) {
19
+ if (!(error instanceof SourceError)) throw error;
20
+ return [{
21
+ code: "UNVERIFIABLE_DESIGN_ANNOTATION",
22
+ reference: ".qbento/",
23
+ message:
24
+ `An original QMD design trace cannot be verified: ${error.message} Preserve the QMD and restore .qbento/ from Git or backup, or explicitly reconcile the trace. Rendering a base deck does not recover its historical visual design.`,
25
+ }];
26
+ }
27
+ const creations =
28
+ state?.pending.filter((p) =>
29
+ ["create-slide", "create-element"].includes(p.kind)
30
+ ).map((p) => p.id) ?? [];
31
+ const requests = annotationRequests(document, state, creations, {
32
+ hash: registry.hash,
33
+ ...(registry.defaults.theme
34
+ ? { activeTheme: "validated-active-theme" }
35
+ : {}),
36
+ layouts: Object.keys(registry.defaults.layouts ?? {}),
37
+ });
38
+ const diagnostics: Diagnostic[] = [];
39
+ for (const trace of traces) {
40
+ const request = requests.find((r) => r.slideId === trace.slideId);
41
+ const missing = trace.references
42
+ ? trace.references.flatMap((reference) => {
43
+ const categories = reference === ".qbento/design.json"
44
+ ? ["theme", "layout"]
45
+ : ["overrides", "decorations", "stacking", "created-design"];
46
+ const declared = trace.features!.filter((feature) =>
47
+ categories.includes(feature)
48
+ );
49
+ const absent = declared.filter((feature) => {
50
+ if (request?.features.includes(feature)) return false;
51
+ // Approved creations become ordinary sparse visual effects after promotion.
52
+ // This checks current availability, never proof of historical identity/approval.
53
+ return feature !== "created-design" || !request?.features.some((f) =>
54
+ ["overrides", "decorations", "stacking"].includes(f)
55
+ );
56
+ });
57
+ return !request?.references.includes(reference) || !declared.length ||
58
+ absent.length
59
+ ? [{ reference, features: absent }]
60
+ : [];
61
+ })
62
+ : request
63
+ ? []
64
+ : [{ reference: ".qbento/", features: [] }];
65
+ for (const { reference, features } of missing) {
66
+ diagnostics.push({
67
+ code: "MISSING_DESIGN",
68
+ slideId: trace.slideId,
69
+ reference,
70
+ message:
71
+ `Slide ${trace.slideId} retains a QMD design trace for ${reference}, but corresponding recoverable external design is missing${
72
+ features.length ? ` (${features.join(", ")})` : ""
73
+ }. Restore ${reference} from Git or backup, then render again; or explicitly review and resolve the obsolete trace through approved sync. The QMD is unchanged. A base deck or newly saved design is not proof that the historical visual design was recovered.`,
74
+ });
75
+ }
76
+ }
77
+ return diagnostics;
78
+ }
@@ -0,0 +1,287 @@
1
+ import { resolve } from "node:path";
2
+ import {
3
+ assertDesignRegistryUnchanged,
4
+ type DesignRegistry,
5
+ MAX_DESIGN_REGISTRY_BYTES,
6
+ readDesignRegistry,
7
+ validateDesignRegistry,
8
+ } from "../render/registry.ts";
9
+ import { sha256, StateError } from "../state/mod.ts";
10
+ import { withProject } from "./project.ts";
11
+ import { syncDirectory, writeDurable } from "./io.ts";
12
+
13
+ export interface RegistryEditRequest {
14
+ expectedDocId: string;
15
+ expectedRevision: number;
16
+ expectedHash: string | null;
17
+ registry: DesignRegistry;
18
+ }
19
+ export async function readProjectRegistry(source: string) {
20
+ return await withProject(source, async (paths, store) => {
21
+ const state = await store.load();
22
+ if (!state) {
23
+ throw new StateError("NO_BASELINE", "Render the project first.");
24
+ }
25
+ return {
26
+ ...await readDesignRegistry(paths.directory),
27
+ docId: state.docId,
28
+ revision: state.revision,
29
+ };
30
+ });
31
+ }
32
+ function requestShape(request: RegistryEditRequest) {
33
+ if (
34
+ !request || typeof request !== "object" || Array.isArray(request) ||
35
+ Object.keys(request).sort().join(",") !==
36
+ "expectedDocId,expectedHash,expectedRevision,registry" ||
37
+ typeof request.expectedDocId !== "string" || !request.expectedDocId ||
38
+ !Number.isSafeInteger(request.expectedRevision) ||
39
+ request.expectedRevision < 0 ||
40
+ !(request.expectedHash === null ||
41
+ typeof request.expectedHash === "string" &&
42
+ /^[a-f0-9]{64}$/.test(request.expectedHash))
43
+ ) {
44
+ throw new StateError(
45
+ "INVALID_REGISTRY_EDIT",
46
+ "Registry edits require the saved document identity, revision and exact registry hash.",
47
+ );
48
+ }
49
+ }
50
+ async function exists(path: string) {
51
+ try {
52
+ await Deno.lstat(path);
53
+ return true;
54
+ } catch (error) {
55
+ if (error instanceof Deno.errors.NotFound) return false;
56
+ throw error;
57
+ }
58
+ }
59
+ /** No state, output or QMD write. Existing bytes and candidate stay recoverable. */
60
+ export async function saveProjectRegistry(
61
+ source: string,
62
+ request: RegistryEditRequest,
63
+ ) {
64
+ requestShape(request);
65
+ const registry = validateDesignRegistry(request.registry);
66
+ const text = JSON.stringify(registry, null, 2) + "\n";
67
+ if (new TextEncoder().encode(text).length > MAX_DESIGN_REGISTRY_BYTES) {
68
+ throw new StateError(
69
+ "DESIGN_REGISTRY_TOO_LARGE",
70
+ "Registry exceeds 256 KiB.",
71
+ );
72
+ }
73
+ return await withProject(source, async (paths, store) => {
74
+ const state = await store.load();
75
+ if (!state) {
76
+ throw new StateError("NO_BASELINE", "Render the project first.");
77
+ }
78
+ if (state.docId !== request.expectedDocId) {
79
+ throw new StateError(
80
+ "DOC_ID_CHANGED",
81
+ "Reload this project before saving its registry.",
82
+ );
83
+ }
84
+ if (state.revision !== request.expectedRevision) {
85
+ throw new StateError(
86
+ "STALE_REVISION",
87
+ "Another save changed the project. Reload before saving its registry.",
88
+ );
89
+ }
90
+ await readDesignRegistry(paths.directory); // Refuse invalid/unknown current schema, never replace it silently.
91
+ await assertDesignRegistryUnchanged(paths.directory, request.expectedHash);
92
+ const history = resolve(paths.stateDir, "history");
93
+ await Deno.mkdir(history, { recursive: true });
94
+ const info = await Deno.lstat(history);
95
+ if (info.isSymlink || !info.isDirectory) {
96
+ throw new StateError(
97
+ "UNSAFE_DESIGN_REGISTRY",
98
+ "Registry history must be a real directory.",
99
+ );
100
+ }
101
+ const token = crypto.randomUUID();
102
+ const transaction = resolve(history, "design-" + token);
103
+ await Deno.mkdir(transaction);
104
+ await syncDirectory(history);
105
+ const before = resolve(transaction, "before.json"),
106
+ after = resolve(transaction, "after.json");
107
+ const stage = resolve(paths.stateDir, ".design-stage-" + token + ".json");
108
+ const target = resolve(paths.stateDir, "design.json");
109
+ const marker = resolve(paths.stateDir, "design-save.json");
110
+ const hash = await sha256(text);
111
+ await writeDurable(after, text);
112
+ await writeDurable(stage, text);
113
+ await writeDurable(
114
+ resolve(transaction, "receipt.json"),
115
+ JSON.stringify(
116
+ {
117
+ schemaVersion: 1,
118
+ docId: state.docId,
119
+ revision: state.revision,
120
+ beforeHash: request.expectedHash,
121
+ afterHash: hash,
122
+ stage,
123
+ },
124
+ null,
125
+ 2,
126
+ ) + "\n",
127
+ );
128
+ await syncDirectory(transaction);
129
+ // Probe the filesystem capability while the original target is still intact.
130
+ const probe = resolve(paths.stateDir, ".design-link-probe-" + token);
131
+ await Deno.link(stage, probe);
132
+ await Deno.remove(probe);
133
+ await syncDirectory(paths.stateDir);
134
+ let moved = false, published = false, marked = false;
135
+
136
+ try {
137
+ await assertDesignRegistryUnchanged(
138
+ paths.directory,
139
+ request.expectedHash,
140
+ );
141
+ await writeDurable(
142
+ marker,
143
+ JSON.stringify(
144
+ {
145
+ schemaVersion: 1,
146
+ token,
147
+ transaction,
148
+ stage,
149
+ beforeHash: request.expectedHash,
150
+ afterHash: hash,
151
+ },
152
+ null,
153
+ 2,
154
+ ) + "\n",
155
+ );
156
+ marked = true;
157
+ await syncDirectory(paths.stateDir);
158
+ await assertDesignRegistryUnchanged(
159
+ paths.directory,
160
+ request.expectedHash,
161
+ token,
162
+ );
163
+ if (request.expectedHash !== null) {
164
+ await Deno.rename(target, before);
165
+ moved = true;
166
+ await syncDirectory(transaction);
167
+ await syncDirectory(paths.stateDir);
168
+ const previous = await Deno.lstat(before);
169
+ if (
170
+ !previous.isFile || previous.isSymlink ||
171
+ previous.nlink !== null && previous.nlink > 1 ||
172
+ previous.size > MAX_DESIGN_REGISTRY_BYTES
173
+ ) {
174
+ throw new StateError(
175
+ "DESIGN_REGISTRY_CHANGED",
176
+ "Registry path changed while preparing its save.",
177
+ );
178
+ }
179
+ if (
180
+ await sha256(await Deno.readFile(before)) !== request.expectedHash
181
+ ) {
182
+ throw new StateError(
183
+ "DESIGN_REGISTRY_CHANGED",
184
+ "Registry bytes changed while preparing its save.",
185
+ );
186
+ }
187
+ }
188
+ // Atomic create-if-absent publication, unlike rename-overwrite. A writer
189
+ // outside the project lock cannot have its replacement silently removed.
190
+ await Deno.link(stage, target);
191
+ published = true;
192
+ await Deno.remove(stage);
193
+ await syncDirectory(paths.stateDir);
194
+ await assertDesignRegistryUnchanged(paths.directory, hash, token);
195
+ await writeDurable(
196
+ resolve(transaction, "committed.json"),
197
+ JSON.stringify({ hash }) + "\n",
198
+ );
199
+ await syncDirectory(transaction);
200
+ await Deno.remove(marker);
201
+ marked = false;
202
+ await syncDirectory(paths.stateDir);
203
+ } catch (error) {
204
+ if (published) {
205
+ throw new StateError(
206
+ "REGISTRY_SAVED_RECOVERY_REQUIRED",
207
+ `Registry publication occurred but final verification/durability failed. Preserve the registry and recovery files at ${transaction}. ${
208
+ (error as Error).message
209
+ }`,
210
+ );
211
+ }
212
+ let restored = !moved;
213
+ if (moved && !await exists(target)) {
214
+ try {
215
+ const original = await Deno.lstat(before);
216
+ if (!original.isFile || original.isSymlink) {
217
+ throw new Error("Retained path is not a regular file");
218
+ }
219
+ // Keep before.json immutable: a concurrent editor may replace the
220
+ // restored target immediately after publication.
221
+ const rollbackStage = resolve(
222
+ paths.stateDir,
223
+ ".design-stage-" + token + "-rollback.json",
224
+ );
225
+ const originalBytes = await Deno.readFile(before);
226
+ if (await sha256(originalBytes) !== request.expectedHash) {
227
+ throw new Error("Retained original bytes changed");
228
+ }
229
+ const rollbackFile = await Deno.open(rollbackStage, {
230
+ createNew: true,
231
+ write: true,
232
+ mode: 0o600,
233
+ });
234
+ try {
235
+ let offset = 0;
236
+ while (offset < originalBytes.length) {
237
+ offset += await rollbackFile.write(
238
+ originalBytes.subarray(offset),
239
+ );
240
+ }
241
+ await rollbackFile.sync();
242
+ } finally {
243
+ rollbackFile.close();
244
+ }
245
+ await syncDirectory(paths.stateDir);
246
+ await Deno.link(rollbackStage, target); // Atomic create, never overwrite.
247
+ await Deno.remove(rollbackStage);
248
+ await syncDirectory(paths.stateDir);
249
+ await assertDesignRegistryUnchanged(
250
+ paths.directory,
251
+ request.expectedHash,
252
+ token,
253
+ );
254
+ restored = true;
255
+ } catch (restoreError) {
256
+ throw new StateError(
257
+ "DESIGN_REGISTRY_RECOVERY_REQUIRED",
258
+ `Registry save refused; existing bytes and candidate remain at ${transaction}. A concurrent replacement is never overwritten. ${
259
+ (restoreError as Error).message
260
+ }`,
261
+ );
262
+ }
263
+ }
264
+ if (!restored) {
265
+ throw new StateError(
266
+ "DESIGN_REGISTRY_RECOVERY_REQUIRED",
267
+ `Registry save refused and a concurrent replacement was preserved. Inspect ${marker} and ${transaction} before continuing.`,
268
+ );
269
+ }
270
+ if (marked) {
271
+ await Deno.remove(marker);
272
+ await syncDirectory(paths.stateDir);
273
+ }
274
+ if (
275
+ error instanceof Deno.errors.AlreadyExists ||
276
+ error instanceof Deno.errors.NotFound
277
+ ) {
278
+ throw new StateError(
279
+ "DESIGN_REGISTRY_CHANGED",
280
+ "Registry changed during publication; retry from a fresh snapshot. Recovery files were retained.",
281
+ );
282
+ }
283
+ throw error;
284
+ }
285
+ return { registry, hash, docId: state.docId, revision: state.revision };
286
+ });
287
+ }