@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,2423 @@
1
+ import {
2
+ applyCodeLanguages,
3
+ checkedCodeLanguage,
4
+ type CodeLanguagePreview,
5
+ type CodeLanguageRequest,
6
+ codeLanguageSyntax,
7
+ previewCodeLanguages,
8
+ } from "../source/code-language.ts";
9
+ import { IMAGE_CODEC_VERSION } from "../source/image.ts";
10
+ import {
11
+ literalTableBlock,
12
+ literalTableContent,
13
+ tableContent,
14
+ validateAuthoredTable,
15
+ } from "../source/table.ts";
16
+ import { loadDesignRegistry } from "../render/registry.ts";
17
+ import {
18
+ assertCurrentComponentSource,
19
+ componentOperation,
20
+ projectAuthoredComponent,
21
+ } from "./component-actions.ts";
22
+ import { componentMarkdown } from "../source/component-insertion.ts";
23
+ import {
24
+ type ComponentApproval,
25
+ prepareComponentApprovals,
26
+ validateComponentApprovals,
27
+ } from "./component-sync.ts";
28
+ import {
29
+ type ComponentCreation,
30
+ componentProblems,
31
+ type ComponentProjection,
32
+ } from "../state/mod.ts";
33
+ import {
34
+ canonicalInlineHtml,
35
+ imageCaptionHtmlToSource,
36
+ INLINE_CODEC_VERSION,
37
+ inlineHtmlToMarkdown,
38
+ renderInline,
39
+ } from "../render/inline.ts";
40
+ export { canonicalInlineHtml, inlineHtmlToMarkdown } from "../render/inline.ts";
41
+ import {
42
+ authoredText,
43
+ LIST_CODEC_VERSION,
44
+ parseNativeList,
45
+ renderList,
46
+ } from "../render/lists.ts";
47
+ import {
48
+ MATH_CODEC_VERSION,
49
+ mathToNativeHtml,
50
+ parseNativeDisplayMath,
51
+ } from "../render/math.ts";
52
+ import { graphSyncRequest, preflightGraphSync } from "./graph-sync.ts";
53
+ import {
54
+ applyGraphEndpoints,
55
+ type GraphEndpointPreview,
56
+ type GraphEndpointRequest,
57
+ previewGraphEndpoints,
58
+ } from "../source/graphs.ts";
59
+ /** Approved bounded text synchronization with a durable, recoverable QMD transaction. */
60
+ import { verifiedSourceSlide } from "./source-scope.ts";
61
+ import { basename, dirname, resolve } from "node:path";
62
+ import { projectPaths, withProject } from "./project.ts";
63
+ import {
64
+ canonical,
65
+ clone,
66
+ promoteCreated,
67
+ StateError,
68
+ StateStore,
69
+ targetKey,
70
+ } from "../state/mod.ts";
71
+ import type {
72
+ BentoDoc,
73
+ BentoSlide,
74
+ Change,
75
+ JsonObject,
76
+ ProjectState,
77
+ Provenance,
78
+ ProvenanceMap,
79
+ Target,
80
+ Value,
81
+ } from "../state/mod.ts";
82
+ import {
83
+ applyPatches,
84
+ hashSource,
85
+ previewPatches,
86
+ scanSource,
87
+ } from "../source/mod.ts";
88
+ import type {
89
+ PatchPreview,
90
+ SourceBlock,
91
+ SourceDocument,
92
+ } from "../source/mod.ts";
93
+ import type { Node } from "../render/types.ts";
94
+ import { readSourceInline } from "../render/source-inline.ts";
95
+ import {
96
+ applyIdentities,
97
+ type IdentityPreview,
98
+ type IdentityRequest,
99
+ previewIdentities,
100
+ } from "../source/identities.ts";
101
+ import {
102
+ applyAttributePatches,
103
+ previewAttributePatches,
104
+ sourceDesignAttribute,
105
+ } from "../source/attributes.ts";
106
+ import type {
107
+ AttributePreview,
108
+ AttributeRequest,
109
+ } from "../source/attributes.ts";
110
+ import {
111
+ applyNotes,
112
+ normalizeNotesText,
113
+ NOTES_CODEC_VERSION,
114
+ notesIdentity,
115
+ type NotesPreview,
116
+ type NotesRequest,
117
+ previewNotes,
118
+ sourceNotes,
119
+ } from "../source/notes.ts";
120
+ import { validateDesignValue } from "../render/design.ts";
121
+ import {
122
+ type AnnotationDiagnostic,
123
+ type AnnotationPreview,
124
+ applyAnnotations,
125
+ previewAnnotations,
126
+ } from "../source/annotations.ts";
127
+ import {
128
+ annotationIdentityDiagnostics,
129
+ type AnnotationRegistry,
130
+ annotationRegistry,
131
+ annotationRequests,
132
+ type AnnotationSummary,
133
+ originalAnnotationLevel,
134
+ registryEqual,
135
+ } from "./annotations.ts";
136
+ import { applyStructure, previewStructure } from "../source/structure.ts";
137
+ import {
138
+ convertedBlockId,
139
+ type TextConversion,
140
+ } from "../source/text-conversion.ts";
141
+ import { sourceDeletion } from "../source/deletion.ts";
142
+ import type {
143
+ StructureOperation,
144
+ StructurePreview,
145
+ } from "../source/structure.ts";
146
+ export interface SyncNormalization {
147
+ changeId: string;
148
+ expectedAfter: Value;
149
+ after: Value;
150
+ }
151
+ export interface SyncIdentityBinding {
152
+ target: Target;
153
+ before: Provenance;
154
+ after: Provenance;
155
+ expectedHtml?: string;
156
+ expectedTableHash?: string;
157
+ expectedImageHash?: string;
158
+ expectedCode?: { content: string; grammarName: string | null };
159
+ }
160
+ function identityExpectation(binding: SyncIdentityBinding): boolean {
161
+ const fields = [
162
+ binding.expectedHtml,
163
+ binding.expectedCode,
164
+ binding.expectedTableHash,
165
+ binding.expectedImageHash,
166
+ ];
167
+ if (fields.filter((value) => value !== undefined).length !== 1) return false;
168
+ const kind = binding.after?.source?.kind;
169
+ if (kind === "image") {
170
+ const image = binding.after.source?.image;
171
+ if (
172
+ !image || typeof image !== "object" || Array.isArray(image) ||
173
+ binding.after.source?.imageCodec !== IMAGE_CODEC_VERSION
174
+ ) return false;
175
+ if (image.role === "asset") {
176
+ return typeof binding.expectedImageHash === "string" &&
177
+ /^[a-f0-9]{64}$/.test(binding.expectedImageHash) &&
178
+ image.assetDigest === binding.expectedImageHash &&
179
+ image.imageId === binding.target.elementId;
180
+ }
181
+ return image.role === "caption" &&
182
+ image.captionId === binding.target.elementId &&
183
+ typeof binding.expectedHtml === "string";
184
+ }
185
+ if (binding.expectedImageHash !== undefined) return false;
186
+ if (kind === "unsupported") {
187
+ return typeof binding.expectedTableHash === "string" &&
188
+ /^[a-f0-9]{64}$/.test(binding.expectedTableHash);
189
+ }
190
+ if (kind === "code") {
191
+ return typeof binding.expectedCode?.content === "string" &&
192
+ (binding.expectedCode.grammarName === null ||
193
+ typeof binding.expectedCode.grammarName === "string");
194
+ }
195
+ return typeof binding.expectedHtml === "string";
196
+ }
197
+ export interface SyncApproval {
198
+ version: 1;
199
+ docId: string;
200
+ sourceHash: string;
201
+ stateRevision: number;
202
+ planHash: string;
203
+ expectedPending: Change[];
204
+ creationChangeIds: string[];
205
+ normalizations: SyncNormalization[];
206
+ componentApprovals?: ComponentApproval[];
207
+ componentRetirements?: ComponentCreation[];
208
+ componentProjectionRetirements?: ComponentProjection[];
209
+ /** Exact post-apply state before any element deletion may retire retained edits. */
210
+ deletionStateHash?: string;
211
+ identityBindings?: SyncIdentityBinding[];
212
+ }
213
+ export interface SyncApprovalEnvelope {
214
+ format: "qbento-sync-approval/1";
215
+ sha256: string;
216
+ approval: SyncApproval;
217
+ }
218
+ export interface SyncOptions {
219
+ confirmedDeleteIds?: string[];
220
+ }
221
+ export interface SyncItem {
222
+ changeId: string;
223
+ target: Change["target"];
224
+ blockId: string;
225
+ kind: string;
226
+ before: string;
227
+ after: string;
228
+ html: string;
229
+ }
230
+ export interface SyncPlan {
231
+ version: 1;
232
+ source: string;
233
+ sourceHash: string;
234
+ stateRevision: number;
235
+ stateHash: string;
236
+ selectedChangeIds: string[];
237
+ excludedChangeIds: string[];
238
+ changes: SyncItem[];
239
+ patch: PatchPreview;
240
+ attributes?: AttributePreview;
241
+ languages?: CodeLanguagePreview;
242
+ notes?: NotesPreview;
243
+ graphs?: GraphEndpointPreview;
244
+ structure?: StructurePreview;
245
+ identities?: IdentityPreview;
246
+ annotations: AnnotationPreview;
247
+ designRegistry: AnnotationRegistry;
248
+ result: string;
249
+ resultHash: string;
250
+ confirmedDeleteIds: string[];
251
+ creationChangeIds: string[];
252
+ normalizations: SyncNormalization[];
253
+ identityBindings?: SyncIdentityBinding[];
254
+ componentApprovals?: ComponentApproval[];
255
+ componentRetirements?: ComponentCreation[];
256
+ componentProjectionRetirements?: ComponentProjection[];
257
+ planHash: string;
258
+ }
259
+ export interface SyncReceipt {
260
+ version: 1;
261
+ transactionId: string;
262
+ source: string;
263
+ planHash: string;
264
+ previousSourceHash: string;
265
+ sourceHash: string;
266
+ previousRevision: number;
267
+ revision: number;
268
+ appliedChangeIds: string[];
269
+ sourceBackup: string;
270
+ stateBackup: string;
271
+ receiptPath: string;
272
+ renderNeeded: true;
273
+ pendingRetained: true;
274
+ annotations: AnnotationSummary;
275
+ }
276
+ function fail(code: string, message: string): never {
277
+ throw new StateError(code, message);
278
+ }
279
+ async function present(path: string) {
280
+ try {
281
+ await Deno.lstat(path);
282
+ return true;
283
+ } catch (error) {
284
+ if (error instanceof Deno.errors.NotFound) return false;
285
+ throw error;
286
+ }
287
+ }
288
+ async function regular(path: string) {
289
+ const stat = await Deno.lstat(path);
290
+ if (!stat.isFile || stat.isSymlink) {
291
+ fail("UNSAFE_SOURCE_PATH", `Expected a real regular file: ${path}.`);
292
+ }
293
+ return stat;
294
+ }
295
+ async function safeDirectory(path: string) {
296
+ const stat = await Deno.lstat(path);
297
+ if (!stat.isDirectory || stat.isSymlink) {
298
+ fail("UNSAFE_STATE_PATH", `Expected a real directory: ${path}.`);
299
+ }
300
+ }
301
+ async function fsyncDirectory(path: string) {
302
+ if (Deno.build.os === "windows") return;
303
+ const dir = await Deno.open(path, { read: true });
304
+ try {
305
+ await dir.sync();
306
+ } finally {
307
+ dir.close();
308
+ }
309
+ }
310
+ async function durableWrite(path: string, text: string, mode = 0o600) {
311
+ const file = await Deno.open(path, { write: true, createNew: true, mode });
312
+ try {
313
+ const bytes = new TextEncoder().encode(text);
314
+ let n = 0;
315
+ while (n < bytes.length) n += await file.write(bytes.subarray(n));
316
+ await file.sync();
317
+ } finally {
318
+ file.close();
319
+ }
320
+ }
321
+ export function validateSyncApproval(
322
+ value: unknown,
323
+ ): asserts value is SyncApprovalEnvelope {
324
+ const e = value as SyncApprovalEnvelope;
325
+ if (
326
+ !e || e.format !== "qbento-sync-approval/1" || !e.approval ||
327
+ e.sha256 !== hashSource(canonical(e.approval))
328
+ ) fail("INVALID_APPROVAL", "Sync approval checksum or version is invalid.");
329
+ const a = e.approval;
330
+ if (
331
+ a.version !== 1 || typeof a.docId !== "string" ||
332
+ typeof a.sourceHash !== "string" || typeof a.planHash !== "string" ||
333
+ !Number.isSafeInteger(a.stateRevision) || a.stateRevision < 1 ||
334
+ !Array.isArray(a.expectedPending) || !Array.isArray(a.creationChangeIds) ||
335
+ !Array.isArray(a.normalizations) ||
336
+ (a.deletionStateHash !== undefined &&
337
+ !/^[a-f0-9]{64}$/.test(a.deletionStateHash))
338
+ ) fail("INVALID_APPROVAL", "Sync approval metadata is malformed.");
339
+ if (a.identityBindings !== undefined) {
340
+ if (
341
+ !Array.isArray(a.identityBindings) || a.identityBindings.some((binding) =>
342
+ !binding || !binding.target
343
+ ) || new Set(a.identityBindings.map((binding) =>
344
+ targetKey(binding.target)
345
+ )).size !== a.identityBindings.length ||
346
+ a.identityBindings.some((binding) =>
347
+ typeof binding.target.slideId !== "string" || !binding.target.slideId ||
348
+ typeof binding.target.elementId !== "string" ||
349
+ !binding.target.elementId ||
350
+ !identityExpectation(binding) ||
351
+ binding.before?.origin !== "authored" ||
352
+ binding.after?.origin !== "authored" ||
353
+ typeof binding.before.source?.hash !== "string" ||
354
+ binding.after.source?.hash !== a.sourceHash ||
355
+ typeof binding.before.source?.blockId !== "string" ||
356
+ typeof binding.after.source?.blockId !== "string" ||
357
+ binding.before.semanticId !== binding.before.source.blockId ||
358
+ binding.after.semanticId !== binding.after.source.blockId
359
+ )
360
+ ) {
361
+ fail(
362
+ "INVALID_APPROVAL",
363
+ "Identity approval requires exact authored source bindings and native targets.",
364
+ );
365
+ }
366
+ }
367
+ validateComponentApprovals(
368
+ a.componentApprovals,
369
+ a.componentRetirements,
370
+ a.docId,
371
+ a.componentProjectionRetirements,
372
+ );
373
+ if (
374
+ ((a.componentRetirements?.length ?? 0) +
375
+ (a.componentProjectionRetirements?.length ?? 0)) > 0 &&
376
+ (!a.deletionStateHash ||
377
+ [
378
+ ...a.componentRetirements ?? [],
379
+ ...a.componentProjectionRetirements ?? [],
380
+ ].some((intent) =>
381
+ !a.expectedPending.some((pending) =>
382
+ pending.kind === "delete-slide" &&
383
+ pending.target.slideId === intent.slideId
384
+ )
385
+ ))
386
+ ) {
387
+ fail(
388
+ "INVALID_APPROVAL",
389
+ "Pending components retire only under exact approved parent deletion.",
390
+ );
391
+ }
392
+ for (const approval of a.componentApprovals ?? []) {
393
+ if (
394
+ approval.intent.members.some((member) =>
395
+ !a.expectedPending.some((pending) =>
396
+ pending.kind === "create-element" &&
397
+ pending.target.slideId === approval.intent.slideId &&
398
+ pending.target.elementId === member.nativeId &&
399
+ a.creationChangeIds.includes(pending.id)
400
+ )
401
+ )
402
+ ) {
403
+ fail(
404
+ "INVALID_APPROVAL",
405
+ "Every component role must have its own approved pending creation.",
406
+ );
407
+ }
408
+ }
409
+ const ids = a.expectedPending.map((p) => p?.id);
410
+ if (
411
+ ids.some((id) => typeof id !== "string") ||
412
+ new Set(ids).size !== ids.length ||
413
+ new Set(a.creationChangeIds).size !== a.creationChangeIds.length ||
414
+ (a.expectedPending.some((p) => p.kind === "delete-element") &&
415
+ !a.deletionStateHash) ||
416
+ a.creationChangeIds.some((id) =>
417
+ !a.expectedPending.some((p) =>
418
+ p.id === id && ["create-slide", "create-element"].includes(p.kind)
419
+ )
420
+ ) ||
421
+ new Set(a.normalizations.map((n) => n.changeId)).size !==
422
+ a.normalizations.length ||
423
+ a.normalizations.some((n) =>
424
+ !ids.includes(n.changeId) ||
425
+ canonical(n.expectedAfter) !== canonical(
426
+ a.expectedPending.find((p) => p.id === n.changeId)!.after,
427
+ )
428
+ )
429
+ ) {
430
+ fail(
431
+ "INVALID_APPROVAL",
432
+ "Sync approval expectations do not identify exact pending edits.",
433
+ );
434
+ }
435
+ }
436
+ /** Called only with fresh executed source before compose; approval is never inferred from IDs. */
437
+ export function prepareApprovedSync(
438
+ state: ProjectState,
439
+ generated: BentoDoc,
440
+ sourceHash: string,
441
+ envelope: SyncApprovalEnvelope,
442
+ freshProvenance?: ProvenanceMap,
443
+ ): ProjectState {
444
+ validateSyncApproval(envelope);
445
+ const a = envelope.approval;
446
+ if (
447
+ state.docId !== a.docId || generated.docId !== a.docId ||
448
+ sourceHash !== a.sourceHash || state.revision < a.stateRevision
449
+ ) {
450
+ fail(
451
+ "STALE_APPROVAL",
452
+ "Approved sync differs from the current source, document or state revision; recover/review before rendering.",
453
+ );
454
+ }
455
+ for (const expected of a.expectedPending) {
456
+ if (
457
+ canonical(state.pending.find((p) => p.id === expected.id)) !==
458
+ canonical(expected)
459
+ ) {
460
+ fail(
461
+ "STALE_APPROVAL",
462
+ "A selected pending edit changed after source synchronization; preserve it and review the approval.",
463
+ );
464
+ }
465
+ }
466
+ const elementDeletions = a.expectedPending.filter((p) =>
467
+ p.kind === "delete-element"
468
+ );
469
+ if (
470
+ (elementDeletions.length || a.componentRetirements?.length ||
471
+ a.componentProjectionRetirements?.length) &&
472
+ hashSource(canonical(state)) !== a.deletionStateHash
473
+ ) {
474
+ fail(
475
+ "STALE_APPROVAL",
476
+ "The saved project changed after approving source-element deletion; preserve the pending edits and recover/review before rendering.",
477
+ );
478
+ }
479
+ const prepared = clone(state);
480
+ for (const binding of a.identityBindings ?? []) {
481
+ const key = targetKey(binding.target), fresh = freshProvenance?.[key];
482
+ const element = generated.slides.find((slide) =>
483
+ slide.id === binding.target.slideId
484
+ )?.elements.find((element) => element.id === binding.target.elementId);
485
+ if (
486
+ canonical(state.provenance[key]) !== canonical(binding.before) ||
487
+ canonical(fresh) !== canonical(binding.after) ||
488
+ fresh?.source?.hash !== sourceHash ||
489
+ (binding.expectedImageHash
490
+ ? element?.type !== "image" || typeof element.src !== "string" ||
491
+ hashSource(element.src) !== binding.expectedImageHash
492
+ : binding.expectedTableHash
493
+ ? element?.type !== "table" ||
494
+ hashSource(canonical(tableContent(element))) !==
495
+ binding.expectedTableHash
496
+ : binding.expectedCode
497
+ ? element?.type !== "code" ||
498
+ element.content !== binding.expectedCode.content ||
499
+ (element.grammarName ?? null) !== binding.expectedCode.grammarName
500
+ : element?.type !== "text" || element.html !== binding.expectedHtml)
501
+ ) {
502
+ fail(
503
+ "STALE_APPROVAL",
504
+ "Approved identity acquisition lacks exact fresh source and content proof; pending edits are retained.",
505
+ );
506
+ }
507
+ prepared.provenance[key] = clone(fresh!);
508
+ }
509
+ for (const n of a.normalizations) {
510
+ prepared.pending.find((p) => p.id === n.changeId)!.after = clone(n.after);
511
+ }
512
+ for (
513
+ const change of a.expectedPending.filter((p) => p.kind === "delete-slide")
514
+ ) {
515
+ const slideId = change.target.slideId;
516
+ if (!slideId || generated.slides.some((slide) => slide.id === slideId)) {
517
+ fail(
518
+ "STALE_APPROVAL",
519
+ "Approved source deletion is not reflected by fresh execution.",
520
+ );
521
+ }
522
+ delete prepared.provenance[targetKey({ slideId })];
523
+ for (
524
+ const element of prepared.generatedBaseline.slides.find((slide) =>
525
+ slide.id === slideId
526
+ )?.elements ?? []
527
+ ) {
528
+ delete prepared.provenance[targetKey({ slideId, elementId: element.id })];
529
+ }
530
+ }
531
+ for (const change of elementDeletions) {
532
+ const { slideId, elementId } = change.target;
533
+ const slide = generated.slides.find((item) => item.id === slideId);
534
+ if (
535
+ !slideId || !elementId || !slide ||
536
+ slide.elements.some((item) => item.id === elementId)
537
+ ) {
538
+ fail(
539
+ "STALE_APPROVAL",
540
+ "Fresh execution must retain the slide and omit exactly the approved source element before its pending content and design are retired.",
541
+ );
542
+ }
543
+ const covers = (target: Change["target"]) =>
544
+ target.slideId === slideId && target.elementId === elementId;
545
+ delete prepared.provenance[targetKey(change.target)];
546
+ delete prepared.ownership[targetKey(change.target)];
547
+ prepared.overrides = prepared.overrides.filter((item) =>
548
+ !covers(item.target)
549
+ );
550
+ prepared.bentoOnly = prepared.bentoOnly.filter((item) =>
551
+ !(item.slideId === slideId && item.element.id === elementId)
552
+ );
553
+ prepared.pending = prepared.pending.filter((item) => !covers(item.target));
554
+ for (
555
+ const order of prepared.elementOrders.filter((item) =>
556
+ item.slideId === slideId
557
+ )
558
+ ) {
559
+ order.ids = order.ids.filter((id) => id !== elementId);
560
+ }
561
+ }
562
+ const components = prepareComponentApprovals(
563
+ prepared,
564
+ generated,
565
+ sourceHash,
566
+ a.componentApprovals,
567
+ a.componentRetirements,
568
+ freshProvenance,
569
+ a.componentProjectionRetirements,
570
+ );
571
+ return promoteCreated(components, generated, a.creationChangeIds);
572
+ }
573
+ function chooseBlock(
574
+ sourceText: string,
575
+ state: ProjectState,
576
+ change: Change,
577
+ kinds: string[] = ["heading", "paragraph"],
578
+ ): { block: SourceBlock; expected: string } {
579
+ const source = state.provenance[targetKey(change.target)]?.source;
580
+ if (
581
+ !source || typeof source.blockId !== "string" ||
582
+ typeof source.content !== "string" || typeof source.hash !== "string" ||
583
+ typeof source.kind !== "string"
584
+ ) {
585
+ fail(
586
+ "UNMAPPED_SOURCE",
587
+ `Change ${change.id} has no verified original source fragment.`,
588
+ );
589
+ }
590
+ // Old saved projections flattened links/code. Require a fresh faithful baseline
591
+ // before permitting rich original fragments to synchronize after an upgrade.
592
+ if (
593
+ ["heading", "paragraph"].includes(source.kind) &&
594
+ source.inlineCodec !== INLINE_CODEC_VERSION &&
595
+ /(?<!\\)(?:`|\[|<)|https?:\/\//i.test(source.content)
596
+ ) {
597
+ fail(
598
+ "SOURCE_FORMAT_UNSUPPORTED",
599
+ "This saved baseline predates faithful inline projection. Recalculate and review the source before syncing links or inline code.",
600
+ );
601
+ }
602
+ if (source.kind === "list" && source.listCodec !== LIST_CODEC_VERSION) {
603
+ fail(
604
+ "SOURCE_FORMAT_UNSUPPORTED",
605
+ "Recalculate before synchronizing a list without its verified codec baseline.",
606
+ );
607
+ }
608
+ if (source.kind === "math" && source.mathCodec !== MATH_CODEC_VERSION) {
609
+ fail(
610
+ "SOURCE_FORMAT_UNSUPPORTED",
611
+ "This saved equation lacks a verified display-math baseline; recalculate and review before syncing.",
612
+ );
613
+ }
614
+ const doc = scanSource(sourceText),
615
+ matches = doc.blocks.filter((b) => b.id === source.blockId);
616
+ if (matches.length !== 1) {
617
+ fail(
618
+ "AMBIGUOUS_IDENTITY",
619
+ `Change ${change.id} does not identify exactly one current source block.`,
620
+ );
621
+ }
622
+ const block = matches[0];
623
+ if (
624
+ !kinds.includes(block.kind) || !block.editable ||
625
+ block.origin !== "authored" || block.kind !== source.kind
626
+ ) {
627
+ fail(
628
+ "UNSUPPORTED_SYNC",
629
+ `Source kind for ${change.id} cannot be synchronized as authored text.`,
630
+ );
631
+ }
632
+ if (block.content !== source.content) {
633
+ fail(
634
+ "SOURCE_CONFLICT",
635
+ `QMD independently changed the same fragment for ${change.id}.`,
636
+ );
637
+ }
638
+ if (doc.hash !== source.hash) {
639
+ const stable = source.stable === true && block.stable;
640
+ const singleton = typeof source.parentId === "string" &&
641
+ source.siblings === 1 && block.parentId === source.parentId &&
642
+ doc.blocks.filter((b) =>
643
+ b.parentId === block.parentId && b.kind === block.kind
644
+ ).length === 1 &&
645
+ doc.blocks.some((b) => b.id === block.parentId && b.stable);
646
+ if (!stable && !singleton) {
647
+ fail(
648
+ "AMBIGUOUS_IDENTITY",
649
+ "Source changed around an unpinned element; pin its identity explicitly before synchronizing.",
650
+ );
651
+ }
652
+ }
653
+ return { block, expected: source.content };
654
+ }
655
+ function acquiredProvenance(
656
+ before: Provenance,
657
+ block: SourceBlock,
658
+ document: SourceDocument,
659
+ ): Provenance {
660
+ const source: JsonObject = {
661
+ ...clone(before.source!),
662
+ hash: document.hash,
663
+ inlineCodec: INLINE_CODEC_VERSION,
664
+ blockId: block.id,
665
+ content: block.content,
666
+ kind: block.kind,
667
+ stable: block.stable,
668
+ };
669
+ delete source.listCodec;
670
+ if (block.kind === "list") source.listCodec = LIST_CODEC_VERSION;
671
+ delete source.parentId;
672
+ delete source.siblings;
673
+ if (block.parentId) {
674
+ source.parentId = block.parentId;
675
+ source.siblings = document.blocks.filter((candidate) =>
676
+ candidate.parentId === block.parentId && candidate.kind === block.kind
677
+ ).length;
678
+ }
679
+ return { ...clone(before), semanticId: block.id, source };
680
+ }
681
+ /** A global source hash change affects every weak authored binding, selected or not. */
682
+ async function identityAcquisition(
683
+ text: string,
684
+ state: ProjectState,
685
+ selected: Change[],
686
+ sourcePath?: string,
687
+ ) {
688
+ if (!selected.length) return undefined;
689
+ const document = scanSource(text), requests: IdentityRequest[] = [];
690
+ const codeProofs =
691
+ document.blocks.some((block) =>
692
+ ["code", "image"].includes(block.kind) && block.origin === "authored"
693
+ )
694
+ ? await readSourceInline(
695
+ document,
696
+ sourcePath ? dirname(sourcePath) : undefined,
697
+ )
698
+ : undefined;
699
+ for (const [key, proof] of Object.entries(state.provenance)) {
700
+ if (proof.origin === "computed") continue;
701
+ const binding = proof.source;
702
+ const block = document.blocks.find((candidate) =>
703
+ candidate.id === binding?.blockId
704
+ );
705
+ if (
706
+ block?.kind === "image" && binding?.image &&
707
+ typeof binding.image === "object" && !Array.isArray(binding.image) &&
708
+ binding.image.role === "asset"
709
+ ) {
710
+ const fresh = codeProofs?.get(block.id)?.image;
711
+ const image = state.generatedBaseline.slides.find((slide) =>
712
+ slide.id === JSON.parse(key)[0]
713
+ )?.elements.find((element) => element.id === JSON.parse(key)[1]);
714
+ if (
715
+ !fresh?.data || image?.type !== "image" || image.src !== fresh.data ||
716
+ binding.image.assetDigest !== hashSource(fresh.data)
717
+ ) {
718
+ fail(
719
+ "STALE_IMAGE_ASSET",
720
+ "Recalculate before syncing a caption whose original source image bytes changed; pending edits are retained.",
721
+ );
722
+ }
723
+ continue;
724
+ }
725
+ const singleton = binding?.siblings === 1 &&
726
+ typeof binding.parentId === "string" &&
727
+ document.blocks.some((parent) =>
728
+ parent.id === binding.parentId && parent.stable
729
+ ) &&
730
+ document.blocks.filter((candidate) =>
731
+ candidate.parentId === binding.parentId &&
732
+ candidate.kind === binding.kind
733
+ ).length === 1;
734
+ if (binding?.stable === true && block?.stable || singleton) continue;
735
+ const target = JSON.parse(key) as [string | null, string | null];
736
+ const slideId = target[0], elementId = target[1];
737
+ if (
738
+ selected.some((change) =>
739
+ change.kind === "delete-slide" && change.target.slideId === slideId ||
740
+ change.kind === "delete-element" && targetKey(change.target) === key &&
741
+ block?.kind !== "list"
742
+ )
743
+ ) continue;
744
+ const element = state.generatedBaseline.slides.find((slide) =>
745
+ slide.id === slideId
746
+ )?.elements.find((element) => element.id === elementId);
747
+ const table = block?.kind === "unsupported" && element?.type === "table" &&
748
+ validateAuthoredTable(document, block.id, element);
749
+ if (
750
+ !slideId || !elementId || proof.origin !== "authored" || !binding ||
751
+ !block || document.blocks.filter((candidate) =>
752
+ candidate.id === block.id
753
+ ).length !== 1 ||
754
+ binding.hash !== document.hash || proof.semanticId !== block.id ||
755
+ block.content !== binding.content || block.kind !== binding.kind ||
756
+ block.origin !== "authored" || !block.editable && !table ||
757
+ !table &&
758
+ !["heading", "paragraph", "list", "math", "code", "image"].includes(
759
+ block.kind,
760
+ ) ||
761
+ (table
762
+ ? element?.type !== "table"
763
+ : block.kind === "code"
764
+ ? element?.type !== "code" || typeof element.content !== "string"
765
+ : element?.type !== "text" || typeof element.html !== "string")
766
+ ) {
767
+ fail(
768
+ "AMBIGUOUS_IDENTITY",
769
+ "A source change needs exact weak authored heading/paragraph/list/math/code proofs from the unchanged executed baseline; pin unsupported or externally changed identities and recalculate before syncing. Pending edits are retained.",
770
+ );
771
+ }
772
+ if (
773
+ block.kind === "math" && (binding.mathCodec !== MATH_CODEC_VERSION ||
774
+ element!.html !== mathToNativeHtml(block.content)) ||
775
+ block.kind === "code" && (!codeProofs?.get(block.id)?.code ||
776
+ element!.content !== codeProofs.get(block.id)!.code!.content ||
777
+ (element!.grammarName ?? null) !==
778
+ codeProofs.get(block.id)!.code!.grammarName)
779
+ ) {
780
+ fail(
781
+ "AMBIGUOUS_IDENTITY",
782
+ "Display math/code identity requires the exact original codec content and language in the executed baseline.",
783
+ );
784
+ }
785
+ if (block.kind === "image") {
786
+ const image = binding.image;
787
+ if (
788
+ !image || typeof image !== "object" || Array.isArray(image) ||
789
+ image.role !== "caption" || image.captionId !== elementId ||
790
+ codeProofs?.get(block.id)?.html !== element!.html ||
791
+ typeof image.imageId !== "string"
792
+ ) {
793
+ fail(
794
+ "AMBIGUOUS_IDENTITY",
795
+ "Caption identity requires the exact original image/caption pair.",
796
+ );
797
+ }
798
+ if (requests.some((request) => request.blockId === block.id)) continue;
799
+ }
800
+ const title = block.kind === "heading" &&
801
+ document.slides.some((slide) => slide.headingId === block.id);
802
+ requests.push({
803
+ blockId: block.id,
804
+ sourceId: title
805
+ ? slideId
806
+ : block.kind === "heading"
807
+ ? elementId
808
+ : `qb-identity-${
809
+ hashSource(canonical([state.docId, slideId, elementId])).slice(0, 24)
810
+ }`,
811
+ nativeId: elementId,
812
+ ...(block.kind === "image"
813
+ ? { imageNativeId: (binding.image as JsonObject).imageId as string }
814
+ : {}),
815
+ title,
816
+ });
817
+ }
818
+ const producerRequests = document.blocks.filter((block) =>
819
+ block.kind === "chunk" && !block.stable && !block.parentId &&
820
+ !selected.some((change) =>
821
+ change.kind === "delete-slide" && change.target.slideId === block.slideId
822
+ )
823
+ ).map((block) => block.id);
824
+ if (producerRequests.length) {
825
+ const hashes = new Set(
826
+ Object.values(state.provenance).map((proof) => proof.source?.hash).filter(
827
+ (hash) => typeof hash === "string",
828
+ ),
829
+ );
830
+ if (hashes.size !== 1 || !hashes.has(document.hash)) {
831
+ fail(
832
+ "AMBIGUOUS_IDENTITY",
833
+ "Retaining executable producer identities requires the unchanged executed source; recalculate before review.",
834
+ );
835
+ }
836
+ }
837
+ return requests.length || producerRequests.length
838
+ ? previewIdentities(document, requests, producerRequests)
839
+ : undefined;
840
+ }
841
+ function assertMappedDisplayMath(html: string): void {
842
+ if (/\$\$|\\\[/.test(html)) {
843
+ fail(
844
+ "UNSUPPORTED_MATH",
845
+ "A new display equation requires a separately authored QMD math block; this text conversion has no verified math mapping.",
846
+ );
847
+ }
848
+ }
849
+ /** New source acquisition needs the current executed identities, never ID coincidence. */
850
+ function assertTextCreationSource(
851
+ state: ProjectState,
852
+ source: SourceDocument,
853
+ change: Change,
854
+ acquiredIds: ReadonlyMap<string, string> = new Map(),
855
+ ) {
856
+ const slideId = change.target.slideId, elementId = change.target.elementId;
857
+ const baseline = state.generatedBaseline.slides.find((slide) =>
858
+ slide.id === slideId
859
+ );
860
+ if (
861
+ !slideId || !elementId || !baseline ||
862
+ !state.emitted.slides.some((slide) => slide.id === slideId) ||
863
+ change.origin !== "authored" || change.before.present ||
864
+ baseline.elements.some((element) => element.id === elementId) ||
865
+ state.provenance[targetKey(change.target)]
866
+ ) {
867
+ fail(
868
+ "UNSUPPORTED_TEXT_INSERTION",
869
+ "New text needs an existing source slide and an unused native identity; restore or resolve conflicting objects first. Pending edits are retained.",
870
+ );
871
+ }
872
+ const proofs = Object.values(state.provenance);
873
+ const hashes = new Set(
874
+ proofs.map((proof) => proof.source?.hash).filter((hash) =>
875
+ typeof hash === "string"
876
+ ),
877
+ );
878
+ if (hashes.size !== 1 || !hashes.has(source.hash)) {
879
+ fail(
880
+ "STALE_SOURCE",
881
+ "QMD differs from the executed baseline; recalculate and review before inserting new text. Pending edits are retained.",
882
+ );
883
+ }
884
+ for (const proof of proofs.filter((proof) => proof.origin !== "computed")) {
885
+ const binding = proof.source;
886
+ const parent = source.blocks.find((block) =>
887
+ block.id === binding?.parentId
888
+ );
889
+ if (
890
+ binding?.stable !== true &&
891
+ !(parent?.stable && binding?.siblings === 1) &&
892
+ !acquiredIds.has(String(binding?.blockId))
893
+ ) {
894
+ fail(
895
+ "AMBIGUOUS_IDENTITY",
896
+ "Pin existing authored block identities in QMD and recalculate before inserting text; the insertion must not invalidate saved visual mappings. Pending edits are retained.",
897
+ );
898
+ }
899
+ }
900
+ }
901
+ /** Parse only the new fragment; this never executes source or rewrites the document. */
902
+ async function assertInsertedInline(markdown: string, expected: string) {
903
+ const process = new Deno.Command("quarto", {
904
+ args: ["pandoc", "--from", "markdown", "--to", "json"],
905
+ stdin: "piped",
906
+ stdout: "piped",
907
+ stderr: "piped",
908
+ }).spawn();
909
+ const output = process.output(), writer = process.stdin.getWriter();
910
+ await writer.write(new TextEncoder().encode(markdown));
911
+ await writer.close();
912
+ const result = await output;
913
+ if (!result.success) {
914
+ fail(
915
+ "UNSUPPORTED_TEXT_INSERTION",
916
+ "Pandoc could not verify the inserted text fragment; author it explicitly in QMD.",
917
+ );
918
+ }
919
+ const parsed = JSON.parse(new TextDecoder().decode(result.stdout));
920
+ if (
921
+ !Array.isArray(parsed.blocks) || parsed.blocks.length !== 1 ||
922
+ !["Para", "BulletList", "OrderedList"].includes(parsed.blocks[0].t) ||
923
+ (parsed.blocks[0].t === "Para"
924
+ ? renderInline(parsed.blocks[0].c as Node[])
925
+ : renderList(parsed.blocks[0])) !== expected
926
+ ) {
927
+ fail(
928
+ "UNSUPPORTED_TEXT_INSERTION",
929
+ "The inserted text does not round-trip faithfully through Pandoc; remove unsupported formatting or author it explicitly in QMD. Pending edits are retained.",
930
+ );
931
+ }
932
+ }
933
+ async function buildPlan(
934
+ source: string,
935
+ text: string,
936
+ state: ProjectState,
937
+ selectedChangeIds?: string[],
938
+ options: SyncOptions = {},
939
+ ): Promise<SyncPlan> {
940
+ const conversions: {
941
+ change: Change;
942
+ operation: TextConversion;
943
+ blockId: string;
944
+ }[] = [];
945
+ const ids = selectedChangeIds ?? state.pending.map((p) => p.id),
946
+ confirmedDeleteIds = options.confirmedDeleteIds ?? [];
947
+ if (
948
+ new Set(ids).size !== ids.length ||
949
+ new Set(confirmedDeleteIds).size !== confirmedDeleteIds.length ||
950
+ confirmedDeleteIds.some((id) =>
951
+ !ids.includes(id) ||
952
+ !state.pending.some((p) =>
953
+ p.id === id &&
954
+ ["delete-slide", "delete-element"].includes(p.kind)
955
+ )
956
+ )
957
+ ) {
958
+ fail(
959
+ "INVALID_SELECTION",
960
+ "Selected changes must be unique and deletion confirmations must be selected.",
961
+ );
962
+ }
963
+ const selected = ids.map((id) => {
964
+ const p = state.pending.find((p) => p.id === id);
965
+ if (!p) fail("INVALID_SELECTION", `Pending change ${id} is missing.`);
966
+ if (p.status === "conflict") {
967
+ fail("SYNC_CONFLICT", `Resolve conflict ${id} before synchronization.`);
968
+ }
969
+ return p;
970
+ });
971
+ if (
972
+ selected.some((change) =>
973
+ change.kind === "delete-element" &&
974
+ state.pending.some((pending) =>
975
+ pending.kind === "delete-slide" &&
976
+ pending.target.slideId === change.target.slideId
977
+ )
978
+ )
979
+ ) {
980
+ fail(
981
+ "OVERLAPPING_SOURCE_DELETE",
982
+ "This element belongs to a slide also marked for deletion. Restore the pending element deletion through Compare with QMD before reviewing the slide deletion, or restore the slide before reviewing the element. No QMD changes are proposed.",
983
+ );
984
+ }
985
+ const identities = await identityAcquisition(text, state, selected, source);
986
+ const identityResult = identities?.result ?? text;
987
+ const acquiredIds = new Map(
988
+ identities?.bindings.map((
989
+ binding,
990
+ ) => [binding.blockId, binding.newBlockId]) ?? [],
991
+ );
992
+ const componentApprovals: ComponentApproval[] = [],
993
+ componentRetirements: ComponentCreation[] = [];
994
+ const componentProjectionRetirements = (state.componentProjections ?? [])
995
+ .filter((record) =>
996
+ selected.some((pending) =>
997
+ pending.kind === "delete-slide" &&
998
+ pending.target.slideId === record.slideId
999
+ )
1000
+ ).map((record) => clone(record));
1001
+ const componentChangeIds = new Set<string>();
1002
+ for (const intent of state.componentCreations ?? []) {
1003
+ const memberChanges = state.pending.filter((pending) =>
1004
+ pending.kind === "create-element" &&
1005
+ pending.target.slideId === intent.slideId &&
1006
+ intent.members.some((member) =>
1007
+ member.nativeId === pending.target.elementId
1008
+ )
1009
+ );
1010
+ const selectedMembers = memberChanges.filter((pending) =>
1011
+ ids.includes(pending.id)
1012
+ );
1013
+ const parentDelete = selected.some((pending) =>
1014
+ pending.kind === "delete-slide" &&
1015
+ pending.target.slideId === intent.slideId
1016
+ );
1017
+ if (parentDelete) {
1018
+ componentRetirements.push(clone(intent));
1019
+ memberChanges.forEach((pending) => componentChangeIds.add(pending.id));
1020
+ continue;
1021
+ }
1022
+ if (!selectedMembers.length && selectedChangeIds !== undefined) continue;
1023
+ const problems = componentProblems(state, intent);
1024
+ if (problems.length) {
1025
+ fail(
1026
+ "COMPONENT_INCOMPLETE",
1027
+ problems.join(" ") +
1028
+ " Abre Añadir componente para restaurar miembros ausentes o descartar la creación pendiente.",
1029
+ );
1030
+ }
1031
+ if (selectedMembers.length !== intent.members.length) {
1032
+ fail(
1033
+ "PARTIAL_COMPONENT_SELECTION",
1034
+ "Review all fields of a pending component together; a partial source acquisition is not approved.",
1035
+ );
1036
+ }
1037
+ const mapping = scanSource(text);
1038
+ assertCurrentComponentSource(state, mapping);
1039
+ for (const pending of memberChanges) {
1040
+ assertTextCreationSource(state, mapping, pending, acquiredIds);
1041
+ }
1042
+ const operation = componentOperation(intent, state.emitted);
1043
+ previewStructure(scanSource(identityResult), [operation]);
1044
+ const registry = await loadDesignRegistry(projectPaths(source).directory);
1045
+ const projection = await projectAuthoredComponent(
1046
+ operation,
1047
+ state.docId,
1048
+ registry.defaults,
1049
+ new Map([[intent.id, clone(intent.projectionFrame)]]),
1050
+ {
1051
+ elements: new Map(
1052
+ intent.members.map((
1053
+ member,
1054
+ ) => [member.nativeId, clone(member.snapshot)]),
1055
+ ),
1056
+ resources: state
1057
+ .emitted as unknown as import("../render/types.ts").BentoDoc,
1058
+ },
1059
+ );
1060
+ for (const member of intent.members) {
1061
+ const expected = canonicalInlineHtml(String(member.snapshot.html));
1062
+ if (
1063
+ projection.find((element) => element.id === member.nativeId)?.html !==
1064
+ expected
1065
+ ) {
1066
+ fail(
1067
+ "COMPONENT_FRAGMENT_UNSUPPORTED",
1068
+ "The component text changes when parsed; author unsupported constructs explicitly in QMD. Pending edits remain retained.",
1069
+ );
1070
+ }
1071
+ }
1072
+ componentApprovals.push({ version: 1, intent: clone(intent), operation });
1073
+ memberChanges.forEach((pending) => componentChangeIds.add(pending.id));
1074
+ }
1075
+ const changes: SyncItem[] = [],
1076
+ normalizations: SyncNormalization[] = [],
1077
+ operations: StructureOperation[] = [],
1078
+ creationChangeIds: string[] = [];
1079
+ const attributeRequests: AttributeRequest[] = [];
1080
+ const notesRequests: NotesRequest[] = [];
1081
+ const graphRequests: GraphEndpointRequest[] = [];
1082
+ const languageRequests: CodeLanguageRequest[] = [];
1083
+ const textRequests: {
1084
+ blockId: string;
1085
+ expected: string;
1086
+ replacement: string;
1087
+ }[] = [];
1088
+ const addNormalization = (change: Change, after: Value) => {
1089
+ if (canonical(change.after) !== canonical(after)) {
1090
+ normalizations.push({
1091
+ changeId: change.id,
1092
+ expectedAfter: clone(change.after),
1093
+ after,
1094
+ });
1095
+ }
1096
+ };
1097
+ for (const approval of componentApprovals) {
1098
+ operations.push(approval.operation);
1099
+ for (const member of approval.intent.members) {
1100
+ const change = selected.find((pending) =>
1101
+ pending.target.slideId === approval.intent.slideId &&
1102
+ pending.target.elementId === member.nativeId
1103
+ )!;
1104
+ creationChangeIds.push(change.id);
1105
+ addNormalization(change, {
1106
+ present: true,
1107
+ value: {
1108
+ ...clone(change.after.value as Record<string, never>),
1109
+ html: canonicalInlineHtml(String(member.snapshot.html)),
1110
+ },
1111
+ });
1112
+ }
1113
+ changes.push({
1114
+ changeId: `component:${approval.intent.id}`,
1115
+ target: { slideId: approval.intent.slideId },
1116
+ blockId: approval.intent.id,
1117
+ kind: "create-component",
1118
+ before: "",
1119
+ after: componentMarkdown(approval.operation),
1120
+ html: "",
1121
+ });
1122
+ }
1123
+ for (const intent of componentRetirements) {
1124
+ changes.push({
1125
+ changeId: `component-retirement:${intent.id}`,
1126
+ target: { slideId: intent.slideId },
1127
+ blockId: intent.id,
1128
+ kind: "retire-pending-component",
1129
+ before: intent.members.map((member) =>
1130
+ `${member.role}: ${member.snapshot.html}`
1131
+ ).join("\n"),
1132
+ after: "Se descarta con la eliminación de su diapositiva aprobada.",
1133
+ html: "",
1134
+ });
1135
+ }
1136
+ // Existing text edits first, then source creation/deletion, finally the requested final order.
1137
+ for (const change of selected) {
1138
+ if (componentChangeIds.has(change.id)) continue;
1139
+ // Deletion retires this object's whole pending content after fresh execution;
1140
+ // never publish an intermediate text/attribute edit to the source being removed.
1141
+ if (
1142
+ change.kind === "field" &&
1143
+ selected.some((item) =>
1144
+ item.kind === "delete-element" &&
1145
+ canonical(item.target) === canonical(change.target)
1146
+ )
1147
+ ) continue;
1148
+ if (change.kind === "field") {
1149
+ const graphRequest = graphSyncRequest(text, state, change);
1150
+ if (graphRequest) {
1151
+ graphRequests.push(graphRequest);
1152
+ changes.push({
1153
+ changeId: change.id,
1154
+ target: change.target,
1155
+ blockId: graphRequest.edgeId,
1156
+ kind: "graph-endpoint",
1157
+ before: graphRequest.expected,
1158
+ after: graphRequest.nodeId,
1159
+ html: "",
1160
+ });
1161
+ continue;
1162
+ }
1163
+ const key = targetKey(change.target);
1164
+ if (
1165
+ change.path.length === 1 &&
1166
+ state.ownership[key]?.includes(change.path[0])
1167
+ ) {
1168
+ const design = state.provenance[key]?.source?.design;
1169
+ const binding =
1170
+ design && !Array.isArray(design) && typeof design === "object"
1171
+ ? design[change.path[0]]
1172
+ : undefined;
1173
+ if (
1174
+ !binding || typeof binding !== "object" || Array.isArray(binding) ||
1175
+ typeof binding.blockId !== "string" ||
1176
+ typeof binding.attribute !== "string" ||
1177
+ typeof binding.value !== "string"
1178
+ ) {
1179
+ fail(
1180
+ "UNMAPPED_DESIGN",
1181
+ "This source-owned field has no independent inverse mapping. Edit its parent declaration in QMD or keep the source value.",
1182
+ );
1183
+ }
1184
+ if (
1185
+ !change.after.present ||
1186
+ !["string", "number"].includes(typeof change.after.value)
1187
+ ) {
1188
+ fail(
1189
+ "UNSUPPORTED_SYNC",
1190
+ "A declared visual attribute needs a supported explicit value.",
1191
+ );
1192
+ }
1193
+ const expected = sourceDesignAttribute(
1194
+ scanSource(text),
1195
+ binding.blockId,
1196
+ binding.attribute,
1197
+ );
1198
+ if (expected !== binding.value) {
1199
+ fail(
1200
+ "SOURCE_CONFLICT",
1201
+ "The same visual attribute changed in QMD; compare with QMD and resolve it before syncing.",
1202
+ );
1203
+ }
1204
+ const value = validateDesignValue(
1205
+ binding.attribute,
1206
+ change.after.value as string | number,
1207
+ );
1208
+ attributeRequests.push({
1209
+ blockId: binding.blockId,
1210
+ attribute: binding.attribute,
1211
+ expected,
1212
+ value,
1213
+ });
1214
+ changes.push({
1215
+ changeId: change.id,
1216
+ target: change.target,
1217
+ blockId: binding.blockId,
1218
+ kind: "visual-attribute",
1219
+ before: `${binding.attribute}=${expected}`,
1220
+ after: `${binding.attribute}=${value}`,
1221
+ html: "",
1222
+ });
1223
+ continue;
1224
+ }
1225
+ if (
1226
+ canonical(change.path) === canonical(["notes"]) &&
1227
+ !change.target.elementId && change.target.slideId
1228
+ ) {
1229
+ const provenance = state.provenance[key], baseline = provenance?.source;
1230
+ if (
1231
+ !provenance && change.after.present &&
1232
+ typeof change.after.value === "string"
1233
+ ) {
1234
+ const mapping = scanSource(text);
1235
+ const originalSlide = state.generatedBaseline.slides.find((slide) =>
1236
+ slide.id === change.target.slideId
1237
+ );
1238
+ const hashes = new Set(
1239
+ Object.values(state.provenance).map((proof) => proof.source?.hash),
1240
+ );
1241
+ const sourceSlide = mapping.slides.find((slide) =>
1242
+ (acquiredIds.get(slide.headingId) ?? slide.id) ===
1243
+ change.target.slideId
1244
+ );
1245
+ if (
1246
+ change.origin !== "authored" || !originalSlide ||
1247
+ (originalSlide.notes !== undefined && originalSlide.notes !== "") ||
1248
+ canonical(change.before) !==
1249
+ canonical(
1250
+ originalSlide.notes === undefined
1251
+ ? { present: false }
1252
+ : { present: true, value: originalSlide.notes },
1253
+ ) ||
1254
+ hashes.size !== 1 || !hashes.has(mapping.hash) || !sourceSlide
1255
+ ) {
1256
+ fail(
1257
+ "UNMAPPED_NOTES",
1258
+ "New notes require an empty executed baseline and the unchanged source slide; recalculate and review first.",
1259
+ );
1260
+ }
1261
+ verifiedSourceSlide(
1262
+ mapping,
1263
+ state,
1264
+ sourceSlide.id,
1265
+ change.target.slideId,
1266
+ );
1267
+ const normalized = normalizeNotesText(change.after.value);
1268
+ const blockId = notesIdentity(change.target.slideId);
1269
+ const operation: StructureOperation = {
1270
+ type: "insert-notes",
1271
+ slideId: change.target.slideId,
1272
+ id: blockId,
1273
+ text: normalized,
1274
+ };
1275
+ previewStructure(scanSource(identityResult), [operation]);
1276
+ operations.push(operation);
1277
+ changes.push({
1278
+ changeId: change.id,
1279
+ target: change.target,
1280
+ blockId,
1281
+ kind: "notes",
1282
+ before: "",
1283
+ after: normalized,
1284
+ html: "",
1285
+ });
1286
+ addNormalization(change, { present: true, value: normalized });
1287
+ continue;
1288
+ }
1289
+ if (
1290
+ change.origin !== "authored" || provenance?.origin !== "authored" ||
1291
+ baseline?.kind !== "notes" ||
1292
+ baseline.notesCodec !== NOTES_CODEC_VERSION ||
1293
+ typeof baseline.blockId !== "string" ||
1294
+ typeof baseline.content !== "string"
1295
+ ) {
1296
+ fail(
1297
+ "UNMAPPED_NOTES",
1298
+ "These notes have no verified existing QMD notes block. Author an identified .notes div and recalculate before syncing.",
1299
+ );
1300
+ }
1301
+ if (!change.after.present || typeof change.after.value !== "string") {
1302
+ fail(
1303
+ "UNSUPPORTED_SYNC",
1304
+ "Clear notes with an empty string; removing the notes property is unsupported.",
1305
+ );
1306
+ }
1307
+ const { block } = sourceNotes(
1308
+ scanSource(text),
1309
+ baseline.blockId,
1310
+ change.target.slideId,
1311
+ );
1312
+ if (block.content !== baseline.content) {
1313
+ fail(
1314
+ "SOURCE_CONFLICT",
1315
+ "QMD independently changed the same speaker notes; compare and resolve before syncing.",
1316
+ );
1317
+ }
1318
+ const normalized = normalizeNotesText(change.after.value);
1319
+ notesRequests.push({
1320
+ blockId: block.id,
1321
+ slideId: change.target.slideId,
1322
+ expected: block.content,
1323
+ text: normalized,
1324
+ });
1325
+ changes.push({
1326
+ changeId: change.id,
1327
+ target: change.target,
1328
+ blockId: block.id,
1329
+ kind: "notes",
1330
+ before: block.content,
1331
+ after: normalized,
1332
+ html: "",
1333
+ });
1334
+ addNormalization(change, { present: true, value: normalized });
1335
+ continue;
1336
+ }
1337
+ if (canonical(change.path) === canonical(["grammarName"])) {
1338
+ if (
1339
+ change.origin !== "authored" ||
1340
+ state.provenance[targetKey(change.target)]?.origin !== "authored"
1341
+ ) {
1342
+ fail(
1343
+ "COMPUTED_SYNC_FORBIDDEN",
1344
+ "Executable code language stays computed; pending edits are retained.",
1345
+ );
1346
+ }
1347
+ const { block } = chooseBlock(text, state, change, ["code"]);
1348
+ const baseline = state.generatedBaseline.slides.find((slide) =>
1349
+ slide.id === change.target.slideId
1350
+ )?.elements.find((element) => element.id === change.target.elementId);
1351
+ const language = !change.after.present || change.after.value === ""
1352
+ ? null
1353
+ : checkedCodeLanguage(change.after.value);
1354
+ const original = (await readSourceInline(scanSource(text))).get(
1355
+ block.id,
1356
+ )?.code;
1357
+ if (
1358
+ baseline?.type !== "code" || !original ||
1359
+ original.content !== baseline.content ||
1360
+ original.grammarName !== (baseline.grammarName ?? null) ||
1361
+ codeLanguageSyntax(block.raw).grammar !== original.grammarName
1362
+ ) {
1363
+ fail(
1364
+ "SOURCE_CONFLICT",
1365
+ "Original displayed language differs from its executed baseline; recalculate before review.",
1366
+ );
1367
+ }
1368
+ languageRequests.push({
1369
+ blockId: block.id,
1370
+ expected: original.grammarName,
1371
+ language,
1372
+ });
1373
+ changes.push({
1374
+ changeId: change.id,
1375
+ target: change.target,
1376
+ blockId: block.id,
1377
+ kind: "code-language",
1378
+ before: original.grammarName ?? "",
1379
+ after: language ?? "",
1380
+ html: "",
1381
+ });
1382
+ addNormalization(
1383
+ change,
1384
+ language === null
1385
+ ? { present: false }
1386
+ : { present: true, value: language },
1387
+ );
1388
+ continue;
1389
+ }
1390
+ const displayedCode = canonical(change.path) === canonical(["content"]);
1391
+ if (
1392
+ (!displayedCode && canonical(change.path) !== canonical(["html"])) ||
1393
+ !change.after.present || typeof change.after.value !== "string"
1394
+ ) {
1395
+ fail(
1396
+ "UNSUPPORTED_SYNC",
1397
+ `Field ${
1398
+ change.path.join(".")
1399
+ } on change ${change.id} cannot synchronize safely for this object. Pending edits are retained; edit the original QMD or explicitly resolve or detach this change.`,
1400
+ );
1401
+ }
1402
+ if (
1403
+ change.origin !== "authored" ||
1404
+ state.provenance[targetKey(change.target)]?.origin !== "authored"
1405
+ ) {
1406
+ fail(
1407
+ "COMPUTED_SYNC_FORBIDDEN",
1408
+ `Change ${change.id} is not authored source text.`,
1409
+ );
1410
+ }
1411
+ const displayedMath = !displayedCode &&
1412
+ state.provenance[targetKey(change.target)]?.source?.kind === "math";
1413
+ if (!displayedCode && !displayedMath) {
1414
+ assertMappedDisplayMath(change.after.value);
1415
+ }
1416
+ const imageSource = state.provenance[targetKey(change.target)]?.source;
1417
+ const imageRole = imageSource?.image;
1418
+ const displayedImage = imageSource?.kind === "image" &&
1419
+ imageRole && typeof imageRole === "object" &&
1420
+ !Array.isArray(imageRole) &&
1421
+ imageRole.role === "caption";
1422
+ const displayedList =
1423
+ state.provenance[targetKey(change.target)]?.source?.kind === "list";
1424
+ const beforeKind = state.provenance[targetKey(change.target)]?.source
1425
+ ?.kind;
1426
+ if (["paragraph", "list"].includes(String(beforeKind))) {
1427
+ const converted = authoredText(change.after.value);
1428
+ if (converted.kind !== beforeKind) {
1429
+ const { block, expected } = chooseBlock(text, state, change, [
1430
+ String(beforeKind),
1431
+ ]);
1432
+ const current = scanSource(identityResult),
1433
+ id = acquiredIds.get(block.id) ?? block.id;
1434
+ const operation: TextConversion = {
1435
+ type: "convert-text",
1436
+ blockId: id,
1437
+ expected,
1438
+ markdown: converted.markdown,
1439
+ from: beforeKind as "paragraph" | "list",
1440
+ to: converted.kind,
1441
+ nativeId: change.target.elementId!,
1442
+ };
1443
+ const nextId = convertedBlockId(current, operation);
1444
+ previewStructure(current, [operation]);
1445
+ await assertInsertedInline(converted.markdown, converted.html);
1446
+ conversions.push({ change, operation, blockId: nextId });
1447
+ operations.push(operation);
1448
+ changes.push({
1449
+ changeId: change.id,
1450
+ target: change.target,
1451
+ blockId: block.id,
1452
+ kind: "convert-text",
1453
+ before: expected,
1454
+ after: converted.markdown,
1455
+ html: converted.html,
1456
+ });
1457
+ addNormalization(change, { present: true, value: converted.html });
1458
+ continue;
1459
+ }
1460
+ }
1461
+ const parsedImage = displayedImage
1462
+ ? imageCaptionHtmlToSource(change.after.value)
1463
+ : undefined;
1464
+ const parsedList = displayedList
1465
+ ? parseNativeList(change.after.value)
1466
+ : undefined;
1467
+ const parsedMath = displayedMath
1468
+ ? parseNativeDisplayMath(change.after.value)
1469
+ : undefined;
1470
+ const { block, expected } = chooseBlock(
1471
+ text,
1472
+ state,
1473
+ change,
1474
+ displayedCode
1475
+ ? ["code"]
1476
+ : displayedMath
1477
+ ? ["math"]
1478
+ : displayedImage
1479
+ ? ["image"]
1480
+ : displayedList
1481
+ ? ["list"]
1482
+ : ["heading", "paragraph"],
1483
+ ),
1484
+ normalized = displayedCode
1485
+ ? change.after.value.replace(/\r\n|\r/g, "\n")
1486
+ : parsedImage?.html ?? parsedList?.html ?? parsedMath?.html ??
1487
+ canonicalInlineHtml(change.after.value),
1488
+ after = displayedCode
1489
+ ? normalized + "\n"
1490
+ : parsedMath
1491
+ ? parsedMath.tex + "\n"
1492
+ : parsedImage?.markdown ?? parsedList?.markdown ??
1493
+ inlineHtmlToMarkdown(change.after.value);
1494
+ textRequests.push({ blockId: block.id, expected, replacement: after });
1495
+ changes.push({
1496
+ changeId: change.id,
1497
+ target: change.target,
1498
+ blockId: block.id,
1499
+ kind: block.kind,
1500
+ before: expected,
1501
+ after,
1502
+ html: change.after.value,
1503
+ });
1504
+ addNormalization(change, {
1505
+ present: true,
1506
+ value: normalized,
1507
+ });
1508
+ } else if (change.kind === "create-element") {
1509
+ const element = change.after.value as Record<string, unknown>;
1510
+ if (
1511
+ !change.after.present || !element || element.type !== "text" ||
1512
+ element.id !== change.target.elementId ||
1513
+ typeof element.html !== "string"
1514
+ ) {
1515
+ fail(
1516
+ "UNSUPPORTED_TEXT_INSERTION",
1517
+ "Only a new authored text object on an existing slide can be inserted into QMD. Pending content is retained.",
1518
+ );
1519
+ }
1520
+ if (element.groupId) {
1521
+ fail(
1522
+ "UNSUPPORTED_TEXT_INSERTION",
1523
+ "Ungroup the new text before syncing it as a standalone block, or author its component explicitly in QMD. Grouped content remains pending.",
1524
+ );
1525
+ }
1526
+ const mapping = scanSource(text);
1527
+ assertTextCreationSource(state, mapping, change, acquiredIds);
1528
+ assertMappedDisplayMath(element.html);
1529
+ const { markdown, html, kind: textKind } = authoredText(element.html);
1530
+ const blockId = `qb-text-${
1531
+ hashSource(canonical([change.target.slideId, element.id])).slice(0, 24)
1532
+ }`;
1533
+ const operation: StructureOperation = {
1534
+ type: "insert-text",
1535
+ slideId: change.target.slideId!,
1536
+ id: blockId,
1537
+ elementId: change.target.elementId!,
1538
+ markdown,
1539
+ textKind,
1540
+ };
1541
+ // Validate boundaries/identities before consulting Pandoc's fragment reader.
1542
+ previewStructure(scanSource(identityResult), [operation]);
1543
+ await assertInsertedInline(markdown, html);
1544
+ operations.push(operation);
1545
+ creationChangeIds.push(change.id);
1546
+ addNormalization(change, {
1547
+ present: true,
1548
+ value: { ...clone(element), html },
1549
+ });
1550
+ changes.push({
1551
+ changeId: change.id,
1552
+ target: change.target,
1553
+ blockId,
1554
+ kind: "create-element",
1555
+ before: "",
1556
+ after: markdown,
1557
+ html,
1558
+ });
1559
+ } else if (change.kind === "create-slide") {
1560
+ const slide = change.after.value as BentoSlide;
1561
+ if (
1562
+ !change.after.present || !slide || slide.id !== change.target.slideId ||
1563
+ !Array.isArray(slide.elements)
1564
+ ) {
1565
+ fail(
1566
+ "UNSUPPORTED_SYNC",
1567
+ "New slide lacks a valid native document identity.",
1568
+ );
1569
+ }
1570
+ if (
1571
+ (slide.title && slide.title !== "")
1572
+ ) {
1573
+ fail(
1574
+ "UNSUPPORTED_SYNC",
1575
+ "Separate title metadata needs explicit source authoring.",
1576
+ );
1577
+ }
1578
+ if (
1579
+ slide.elements.some((e) =>
1580
+ !["text", "shape", "line", "arrow", "path"].includes(e.type)
1581
+ )
1582
+ ) {
1583
+ fail(
1584
+ "UNSUPPORTED_SYNC",
1585
+ "New slides currently support authored text and pure visual decorations.",
1586
+ );
1587
+ }
1588
+ const texts = slide.elements.filter((e) => e.type === "text");
1589
+ if (!texts.length || texts.some((e) => typeof e.html !== "string")) {
1590
+ fail(
1591
+ "UNSUPPORTED_SYNC",
1592
+ "New slide requires a text title and supported text bodies.",
1593
+ );
1594
+ }
1595
+ for (const element of texts) {
1596
+ assertMappedDisplayMath(element.html as string);
1597
+ }
1598
+ const title = inlineHtmlToMarkdown(texts[0].html as string),
1599
+ body = texts.slice(1).map((e) =>
1600
+ authoredText(e.html as string).markdown
1601
+ );
1602
+ for (const element of texts.slice(1)) {
1603
+ const parsed = authoredText(element.html as string);
1604
+ await assertInsertedInline(parsed.markdown, parsed.html);
1605
+ }
1606
+ operations.push({
1607
+ type: "insert-slide",
1608
+ id: slide.id,
1609
+ title,
1610
+ body: body.join("\n\n"),
1611
+ bodyFragments: body,
1612
+ titleElementId: texts[0].id,
1613
+ bodyElementIds: texts.slice(1).map((e) => e.id),
1614
+ });
1615
+ if (slide.notes) {
1616
+ operations.push({
1617
+ type: "insert-notes",
1618
+ slideId: slide.id,
1619
+ id: notesIdentity(slide.id),
1620
+ text: normalizeNotesText(slide.notes as string),
1621
+ });
1622
+ }
1623
+ creationChangeIds.push(change.id);
1624
+ const normalized = clone(slide);
1625
+ if (slide.notes) {
1626
+ normalized.notes = normalizeNotesText(slide.notes as string);
1627
+ }
1628
+ for (const e of normalized.elements) {
1629
+ if (e.type === "text") e.html = authoredText(e.html as string).html;
1630
+ }
1631
+ addNormalization(change, { present: true, value: normalized });
1632
+ changes.push({
1633
+ changeId: change.id,
1634
+ target: change.target,
1635
+ blockId: slide.id,
1636
+ kind: "create-slide",
1637
+ before: "",
1638
+ after: [title, ...body].join("\n\n"),
1639
+ html: canonical(slide),
1640
+ });
1641
+ } else if (change.kind === "slide-order") {
1642
+ if (
1643
+ !Array.isArray(change.after.value) ||
1644
+ change.after.value.some((id) => typeof id !== "string")
1645
+ ) fail("UNSUPPORTED_SYNC", "Pending slide order is malformed.");
1646
+ changes.push({
1647
+ changeId: change.id,
1648
+ target: change.target,
1649
+ blockId: "slides",
1650
+ kind: "slide-order",
1651
+ before: canonical(change.before.value),
1652
+ after: canonical(change.after.value),
1653
+ html: "",
1654
+ });
1655
+ } else if (change.kind === "delete-element") {
1656
+ if (!confirmedDeleteIds.includes(change.id)) {
1657
+ fail(
1658
+ "CONFIRM_DELETE_REQUIRED",
1659
+ "Deleting a source element requires its own explicit confirmedDeleteIds selection.",
1660
+ );
1661
+ }
1662
+ const key = targetKey(change.target);
1663
+ const baseline = state.generatedBaseline.slides.find((slide) =>
1664
+ slide.id === change.target.slideId
1665
+ )
1666
+ ?.elements.find((element) => element.id === change.target.elementId);
1667
+ if (
1668
+ !change.target.slideId || !change.target.elementId || !baseline ||
1669
+ change.origin !== "authored" ||
1670
+ state.provenance[key]?.origin !== "authored" ||
1671
+ !change.before.present || change.after.present ||
1672
+ canonical(change.before.value) !== canonical(baseline)
1673
+ ) {
1674
+ fail(
1675
+ "COMPUTED_SYNC_FORBIDDEN",
1676
+ "Element deletion requires exactly one existing authored source object; computed or detached objects cannot delete their producer.",
1677
+ );
1678
+ }
1679
+ const hashes = new Set(
1680
+ Object.values(state.provenance).map((proof) => proof.source?.hash),
1681
+ );
1682
+ if (hashes.size !== 1 || !hashes.has(hashSource(text))) {
1683
+ fail(
1684
+ "STALE_SOURCE",
1685
+ "QMD differs from the executed source baseline. Recalculate and review before deleting a source element; pending edits are retained.",
1686
+ );
1687
+ }
1688
+ const { block } = chooseBlock(text, state, change, [
1689
+ "paragraph",
1690
+ "list",
1691
+ "code",
1692
+ "math",
1693
+ ]);
1694
+ // The executed proof above is checked against the unchanged source. Structural
1695
+ // ownership uses the reviewed identity phase, which can pin a plain heading.
1696
+ const deletionSource = scanSource(identityResult);
1697
+ const deletionId = acquiredIds.get(block.id) ?? block.id;
1698
+ const deletionBlock = deletionSource.blocks.find((candidate) =>
1699
+ candidate.id === deletionId
1700
+ );
1701
+ const deletion = sourceDeletion(deletionSource, deletionId);
1702
+ if (
1703
+ deletionBlock?.slideId !== change.target.slideId ||
1704
+ Object.entries(state.provenance).some(([otherKey, proof]) =>
1705
+ otherKey !== key && typeof proof.source?.blockId === "string" &&
1706
+ deletion.removedIds.includes(proof.source.blockId)
1707
+ )
1708
+ ) {
1709
+ fail(
1710
+ "UNSUPPORTED_SYNC",
1711
+ "This source unit is shared by another object; delete or edit its structure explicitly in QMD.",
1712
+ );
1713
+ }
1714
+ operations.push({
1715
+ type: "delete-block",
1716
+ blockId: deletionId,
1717
+ confirmed: true,
1718
+ });
1719
+ changes.push({
1720
+ changeId: change.id,
1721
+ target: change.target,
1722
+ blockId: block.id,
1723
+ kind: "delete-element",
1724
+ before: deletion.expected,
1725
+ after: "",
1726
+ html: "",
1727
+ });
1728
+ } else if (change.kind === "delete-slide") {
1729
+ if (!confirmedDeleteIds.includes(change.id) || !change.target.slideId) {
1730
+ fail(
1731
+ "CONFIRM_DELETE_REQUIRED",
1732
+ "Source deletion requires a separately explicit confirmedDeleteIds selection.",
1733
+ );
1734
+ }
1735
+ operations.push({
1736
+ type: "delete-slide",
1737
+ id: change.target.slideId,
1738
+ confirmed: true,
1739
+ });
1740
+ changes.push({
1741
+ changeId: change.id,
1742
+ target: change.target,
1743
+ blockId: change.target.slideId,
1744
+ kind: "delete-slide",
1745
+ before: canonical(change.before.value),
1746
+ after: "",
1747
+ html: "",
1748
+ });
1749
+ } else {fail(
1750
+ "UNSUPPORTED_SYNC",
1751
+ `Change ${change.id} is not a supported source synchronization operation.`,
1752
+ );}
1753
+ }
1754
+ for (const change of selected.filter((p) => p.kind === "slide-order")) {
1755
+ operations.push({
1756
+ type: "reorder-slides",
1757
+ ids: change.after.value as string[],
1758
+ });
1759
+ }
1760
+ const patch = previewPatches(
1761
+ scanSource(identityResult),
1762
+ textRequests.map((request) => ({
1763
+ ...request,
1764
+ blockId: acquiredIds.get(request.blockId) ?? request.blockId,
1765
+ })),
1766
+ );
1767
+ const listChanges = changes.filter((change) => change.kind === "list");
1768
+ if (listChanges.length) {
1769
+ const proofs = await readSourceInline(scanSource(patch.result));
1770
+ for (const change of listChanges) {
1771
+ const id = acquiredIds.get(change.blockId) ?? change.blockId;
1772
+ if (proofs.get(id)?.html !== parseNativeList(change.html).html) {
1773
+ fail(
1774
+ "UNSUPPORTED_LIST",
1775
+ "Edited list does not round-trip faithfully through the original-source Pandoc reader; pending edits are retained.",
1776
+ );
1777
+ }
1778
+ }
1779
+ }
1780
+ const notes = notesRequests.length
1781
+ ? previewNotes(scanSource(patch.result), notesRequests)
1782
+ : undefined;
1783
+ const notesResult = notes?.result ?? patch.result;
1784
+ const attributes = attributeRequests.length
1785
+ ? previewAttributePatches(scanSource(notesResult), attributeRequests)
1786
+ : undefined;
1787
+ const attributeResult = attributes?.result ?? notesResult;
1788
+ const languages = languageRequests.length
1789
+ ? previewCodeLanguages(
1790
+ scanSource(attributeResult),
1791
+ languageRequests.map((request) => ({
1792
+ ...request,
1793
+ blockId: acquiredIds.get(request.blockId) ?? request.blockId,
1794
+ })),
1795
+ )
1796
+ : undefined;
1797
+ const languageResult = languages?.result ?? attributeResult;
1798
+ if (languages) {
1799
+ const proofs = await readSourceInline(scanSource(languageResult));
1800
+ for (const request of languages.requests) {
1801
+ if (proofs.get(request.blockId)?.code?.grammarName !== request.language) {
1802
+ fail(
1803
+ "UNSUPPORTED_CODE_LANGUAGE",
1804
+ "Edited language does not match the original-source Pandoc proof.",
1805
+ );
1806
+ }
1807
+ }
1808
+ }
1809
+ const structure = operations.length
1810
+ ? previewStructure(scanSource(languageResult), operations)
1811
+ : undefined;
1812
+ const structureResult = structure?.result ?? languageResult;
1813
+ const graphs = graphRequests.length
1814
+ ? previewGraphEndpoints(scanSource(structureResult), graphRequests)
1815
+ : undefined;
1816
+ const semanticResult = graphs?.result ?? structureResult;
1817
+ const designRegistry = await annotationRegistry(
1818
+ projectPaths(source).directory,
1819
+ );
1820
+ const level = await originalAnnotationLevel(text);
1821
+ const annotationDocument = scanSource(semanticResult);
1822
+ const signalDiagnostics: AnnotationDiagnostic[] = [];
1823
+ const requests = level === "none" ? [] : annotationRequests(
1824
+ annotationDocument,
1825
+ state,
1826
+ ids,
1827
+ designRegistry,
1828
+ signalDiagnostics,
1829
+ );
1830
+ let annotations = previewAnnotations(
1831
+ annotationDocument,
1832
+ level,
1833
+ requests,
1834
+ signalDiagnostics,
1835
+ );
1836
+ if (annotations.patches.length) {
1837
+ const annotationState = clone(state);
1838
+ for (const [key, proof] of Object.entries(annotationState.provenance)) {
1839
+ const id = acquiredIds.get(String(proof.source?.blockId));
1840
+ const block = annotationDocument.blocks.find((block) => block.id === id);
1841
+ if (block) {
1842
+ annotationState.provenance[key] = acquiredProvenance(
1843
+ proof,
1844
+ block,
1845
+ annotationDocument,
1846
+ );
1847
+ }
1848
+ }
1849
+ const blocked = annotationIdentityDiagnostics(
1850
+ annotationDocument,
1851
+ annotationState,
1852
+ );
1853
+ if (blocked.length) {
1854
+ annotations = previewAnnotations(
1855
+ annotationDocument,
1856
+ level,
1857
+ requests,
1858
+ [...signalDiagnostics, ...blocked],
1859
+ );
1860
+ }
1861
+ }
1862
+ if (
1863
+ !ids.length && !annotations.patches.length &&
1864
+ !annotations.diagnostics.length
1865
+ ) {
1866
+ fail(
1867
+ "NO_PENDING_CHANGES",
1868
+ "No pending changes or optional annotation updates selected.",
1869
+ );
1870
+ }
1871
+ for (const [index, patch] of annotations.patches.entries()) {
1872
+ changes.push({
1873
+ changeId: `annotation:${patch.blockId}:${index}`,
1874
+ target: { slideId: patch.blockId },
1875
+ blockId: patch.blockId,
1876
+ kind: "visual-annotation",
1877
+ before: patch.expected,
1878
+ after: patch.replacement,
1879
+ html: "",
1880
+ });
1881
+ }
1882
+ for (const [index, diagnostic] of annotations.diagnostics.entries()) {
1883
+ changes.push({
1884
+ changeId: `annotation-diagnostic:${index}`,
1885
+ target: diagnostic.slideId ? { slideId: diagnostic.slideId } : {},
1886
+ blockId: diagnostic.slideId ?? "annotations",
1887
+ kind: "annotation-diagnostic",
1888
+ before: "",
1889
+ after: `${diagnostic.code}: ${diagnostic.message}`,
1890
+ html: "",
1891
+ });
1892
+ }
1893
+ const result = annotations.result;
1894
+ const identityBindings: SyncIdentityBinding[] = [];
1895
+ if (identities) {
1896
+ const document = scanSource(result),
1897
+ inline = await readSourceInline(document, dirname(source));
1898
+ for (const binding of identities.bindings) {
1899
+ const matchingEntries = Object.entries(state.provenance).filter((
1900
+ [, proof],
1901
+ ) => proof.source?.blockId === binding.blockId);
1902
+ for (const entry of matchingEntries) {
1903
+ if (
1904
+ entry && selected.some((change) =>
1905
+ change.kind === "delete-element" &&
1906
+ targetKey(change.target) === entry[0]
1907
+ )
1908
+ ) continue;
1909
+ const block = document.blocks.find((block) =>
1910
+ block.id ===
1911
+ (conversions.find((c) =>
1912
+ c.operation.blockId === binding.newBlockId
1913
+ )
1914
+ ?.blockId ?? binding.newBlockId)
1915
+ ),
1916
+ html = inline.get(block?.id ?? binding.newBlockId)?.html;
1917
+ const tableHash = block?.kind === "unsupported"
1918
+ ? hashSource(
1919
+ canonical(
1920
+ literalTableContent(
1921
+ literalTableBlock(document, block.id).content,
1922
+ ),
1923
+ ),
1924
+ )
1925
+ : undefined;
1926
+ if (!entry || !block || typeof html !== "string" && !tableHash) {
1927
+ fail(
1928
+ "AMBIGUOUS_IDENTITY",
1929
+ "Acquired source identity has no faithful approved content proof.",
1930
+ );
1931
+ }
1932
+ const [slideId, elementId] = JSON.parse(entry[0]) as [string, string];
1933
+ identityBindings.push({
1934
+ target: { slideId, elementId },
1935
+ before: clone(entry[1]),
1936
+ after: acquiredProvenance(entry[1], block, document),
1937
+ ...(block.kind === "image" && entry[1].source?.image &&
1938
+ typeof entry[1].source.image === "object" &&
1939
+ !Array.isArray(entry[1].source.image) &&
1940
+ entry[1].source.image.role === "asset"
1941
+ ? {
1942
+ expectedImageHash: hashSource(inline.get(block.id)!.image!.data!),
1943
+ }
1944
+ : tableHash
1945
+ ? { expectedTableHash: tableHash }
1946
+ : block.kind === "code"
1947
+ ? { expectedCode: inline.get(block.id)!.code! }
1948
+ : { expectedHtml: html }),
1949
+ });
1950
+ }
1951
+ }
1952
+ for (const patch of identities.patches) {
1953
+ const binding = identityBindings.find((binding) =>
1954
+ binding.before.source?.blockId === patch.blockId
1955
+ );
1956
+ const removed = selected.find((change) =>
1957
+ change.kind === "delete-element" &&
1958
+ state.provenance[targetKey(change.target)]?.source?.blockId ===
1959
+ patch.blockId
1960
+ );
1961
+ const producer = document.blocks.find((block) =>
1962
+ block.id === patch.blockId
1963
+ );
1964
+ changes.push({
1965
+ changeId: `identity:${patch.blockId}`,
1966
+ target: binding?.target ?? removed?.target ??
1967
+ { ...(producer?.slideId ? { slideId: producer.slideId } : {}) },
1968
+ blockId: patch.blockId,
1969
+ kind: "source-identity",
1970
+ before: patch.expected,
1971
+ after: patch.replacement,
1972
+ html: "",
1973
+ });
1974
+ }
1975
+ }
1976
+ if (conversions.length) {
1977
+ const document = scanSource(result),
1978
+ proofs = await readSourceInline(document);
1979
+ for (const conversion of conversions) {
1980
+ const target = conversion.change.target;
1981
+ if (
1982
+ identityBindings.some((binding) =>
1983
+ targetKey(binding.target) === targetKey(target)
1984
+ )
1985
+ ) continue;
1986
+ const before = state.provenance[targetKey(target)],
1987
+ block = document.blocks.find((b) => b.id === conversion.blockId),
1988
+ html = proofs.get(conversion.blockId)?.html;
1989
+ if (
1990
+ !block ||
1991
+ html !== authoredText(conversion.change.after.value as string).html
1992
+ ) {
1993
+ fail(
1994
+ "UNSUPPORTED_SYNC",
1995
+ "Converted source does not exactly preserve approved native content.",
1996
+ );
1997
+ }
1998
+ identityBindings.push({
1999
+ target,
2000
+ before: clone(before),
2001
+ after: acquiredProvenance(before, block, document),
2002
+ expectedHtml: html,
2003
+ });
2004
+ }
2005
+ }
2006
+ // Conversion may be reviewed together with source-owned visual attributes.
2007
+ // Bind the approval to those final exact declarations, not their old values.
2008
+ if (conversions.length) {
2009
+ const document = scanSource(result);
2010
+ for (const conversion of conversions) {
2011
+ const binding = identityBindings.find((item) =>
2012
+ targetKey(item.target) === targetKey(conversion.change.target)
2013
+ );
2014
+ const design = binding?.after.source?.design;
2015
+ if (design && typeof design === "object" && !Array.isArray(design)) {
2016
+ for (const declaration of Object.values(design)) {
2017
+ if (
2018
+ declaration && typeof declaration === "object" &&
2019
+ !Array.isArray(declaration) &&
2020
+ typeof declaration.blockId === "string" &&
2021
+ typeof declaration.attribute === "string"
2022
+ ) {
2023
+ declaration.value = sourceDesignAttribute(
2024
+ document,
2025
+ declaration.blockId,
2026
+ declaration.attribute,
2027
+ );
2028
+ }
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+ await preflightGraphSync(state, text, result);
2034
+ const unsigned = {
2035
+ version: 1 as const,
2036
+ source,
2037
+ sourceHash: hashSource(text),
2038
+ stateRevision: state.revision,
2039
+ stateHash: hashSource(canonical(state)),
2040
+ selectedChangeIds: [...ids],
2041
+ excludedChangeIds: state.pending.filter((p) => !ids.includes(p.id)).map((
2042
+ p,
2043
+ ) => p.id),
2044
+ changes,
2045
+ patch,
2046
+ ...(attributes ? { attributes } : {}),
2047
+ ...(languages ? { languages } : {}),
2048
+ ...(notes ? { notes } : {}),
2049
+ ...(graphs ? { graphs } : {}),
2050
+ ...(structure ? { structure } : {}),
2051
+ ...(identities ? { identities } : {}),
2052
+ ...((identities || identityBindings.length) ? { identityBindings } : {}),
2053
+ annotations,
2054
+ designRegistry,
2055
+ result,
2056
+ resultHash: hashSource(result),
2057
+ confirmedDeleteIds: [...confirmedDeleteIds],
2058
+ creationChangeIds,
2059
+ normalizations,
2060
+ ...(componentApprovals.length ? { componentApprovals } : {}),
2061
+ ...(componentRetirements.length ? { componentRetirements } : {}),
2062
+ ...(componentProjectionRetirements.length
2063
+ ? { componentProjectionRetirements }
2064
+ : {}),
2065
+ };
2066
+ return { ...unsigned, planHash: hashSource(canonical(unsigned)) };
2067
+ }
2068
+ async function readableSnapshot(source: string) {
2069
+ const paths = projectPaths(source);
2070
+ await safeDirectory(paths.stateDir);
2071
+ await regular(paths.source);
2072
+ for (
2073
+ const name of [
2074
+ "operation.lock",
2075
+ "sync-transaction.json",
2076
+ "render-transaction.json",
2077
+ "sync-approved.json",
2078
+ ]
2079
+ ) {
2080
+ if (await present(resolve(paths.stateDir, name))) {
2081
+ fail(
2082
+ "RECOVERY_OR_BUSY",
2083
+ `Project has ${name}; finish or recover that operation first.`,
2084
+ );
2085
+ }
2086
+ }
2087
+ const identity = resolve(paths.stateDir, "project.json");
2088
+ if (await present(identity)) {
2089
+ await regular(identity);
2090
+ const value = JSON.parse(await Deno.readTextFile(identity));
2091
+ if (value.version !== 1 || value.source !== basename(paths.source)) {
2092
+ fail(
2093
+ "PROJECT_SOURCE_MISMATCH",
2094
+ "This .qbento state belongs to another source.",
2095
+ );
2096
+ }
2097
+ }
2098
+ const store = new StateStore(paths.stateDir), state = await store.load();
2099
+ if (!state) {
2100
+ fail("NO_BASELINE", "Render and save the project before synchronizing.");
2101
+ }
2102
+ const text = await Deno.readTextFile(paths.source),
2103
+ again = await store.load();
2104
+ if (
2105
+ !again || canonical(again) !== canonical(state) ||
2106
+ hashSource(await Deno.readTextFile(paths.source)) !== hashSource(text)
2107
+ ) {
2108
+ fail(
2109
+ "STALE_PREVIEW",
2110
+ "Project changed while reading; create a new preview.",
2111
+ );
2112
+ }
2113
+ return { paths, state, text };
2114
+ }
2115
+ /** Read-only: preview creates no files, locks, backups, or source annotations. */
2116
+ export async function previewSync(
2117
+ source: string,
2118
+ selectedChangeIds?: string[],
2119
+ options: SyncOptions = {},
2120
+ ): Promise<SyncPlan> {
2121
+ const { paths, state, text } = await readableSnapshot(source);
2122
+ return buildPlan(paths.source, text, state, selectedChangeIds, options);
2123
+ }
2124
+ /** Explicit approved action; leaves pending semantic edits until a future render confirms them. */
2125
+ export async function applySync(
2126
+ source: string,
2127
+ plan: SyncPlan,
2128
+ ): Promise<SyncReceipt> {
2129
+ return await withProject(source, async (paths, store) => {
2130
+ if (await present(resolve(paths.stateDir, "sync-approved.json"))) {
2131
+ fail(
2132
+ "APPROVAL_PENDING",
2133
+ "Render or explicitly recover the previous approved sync before applying another.",
2134
+ );
2135
+ }
2136
+ const stat = await regular(paths.source), state = await store.load();
2137
+ if (!state) fail("NO_BASELINE", "Project baseline is missing.");
2138
+ if (plan.version !== 1 || plan.source !== paths.source) {
2139
+ fail("INVALID_PLAN", "Sync plan belongs to another source or version.");
2140
+ }
2141
+ if (
2142
+ state.revision !== plan.stateRevision ||
2143
+ hashSource(canonical(state)) !== plan.stateHash
2144
+ ) {
2145
+ fail(
2146
+ "STALE_REVISION",
2147
+ "State changed since preview; inspect a new plan.",
2148
+ );
2149
+ }
2150
+ const text = await Deno.readTextFile(paths.source);
2151
+ if (hashSource(text) !== plan.sourceHash) {
2152
+ fail("STALE_SOURCE", "QMD changed since preview; inspect a new plan.");
2153
+ }
2154
+ if (
2155
+ !registryEqual(
2156
+ await annotationRegistry(paths.directory),
2157
+ plan.designRegistry,
2158
+ )
2159
+ ) {
2160
+ fail(
2161
+ "STALE_DESIGN_REGISTRY",
2162
+ "External design registry changed since preview; inspect a new plan.",
2163
+ );
2164
+ }
2165
+ const verified = await buildPlan(
2166
+ paths.source,
2167
+ text,
2168
+ state,
2169
+ plan.selectedChangeIds,
2170
+ { confirmedDeleteIds: plan.confirmedDeleteIds },
2171
+ );
2172
+ if (canonical(verified) !== canonical(plan)) {
2173
+ fail(
2174
+ "INVALID_PLAN",
2175
+ "Sync plan was altered or no longer reproduces the approved result.",
2176
+ );
2177
+ }
2178
+ const identityResult = verified.identities
2179
+ ? applyIdentities(text, verified.identities)
2180
+ : text;
2181
+ const textResult = applyPatches(identityResult, verified.patch);
2182
+ const notesResult = verified.notes
2183
+ ? applyNotes(textResult, verified.notes)
2184
+ : textResult;
2185
+ const attributeResult = verified.attributes
2186
+ ? applyAttributePatches(notesResult, verified.attributes)
2187
+ : notesResult;
2188
+ const languageResult = verified.languages
2189
+ ? applyCodeLanguages(attributeResult, verified.languages)
2190
+ : attributeResult;
2191
+ const structureResult = verified.structure
2192
+ ? applyStructure(languageResult, verified.structure)
2193
+ : languageResult;
2194
+ const graphResult = verified.graphs
2195
+ ? applyGraphEndpoints(structureResult, verified.graphs)
2196
+ : structureResult;
2197
+ const result = applyAnnotations(graphResult, verified.annotations);
2198
+ if (result === text && !verified.selectedChangeIds.length) {
2199
+ fail(
2200
+ "NO_SOURCE_CHANGES",
2201
+ "No source patches are approved. Inspect the reviewed annotation diagnostics; no QMD change is approved by this plan.",
2202
+ );
2203
+ }
2204
+ if (
2205
+ result !== verified.result || hashSource(result) !== verified.resultHash
2206
+ ) {
2207
+ fail(
2208
+ "INVALID_PLAN",
2209
+ "Combined text and structure result differs from approved source.",
2210
+ );
2211
+ }
2212
+ const annotationSummary: AnnotationSummary = {
2213
+ level: verified.annotations.level,
2214
+ patchCount: verified.annotations.patches.length,
2215
+ diagnostics: clone(verified.annotations.diagnostics),
2216
+ };
2217
+ const transactionId = crypto.randomUUID(),
2218
+ history = resolve(paths.stateDir, "history");
2219
+ await Deno.mkdir(history, { recursive: true });
2220
+ await safeDirectory(history);
2221
+ const sourceBackup = resolve(history, `source-${transactionId}.qmd`),
2222
+ stateBackup = resolve(history, `state-${transactionId}.json`),
2223
+ receiptPath = resolve(history, `sync-${transactionId}.json`);
2224
+ const staging = resolve(
2225
+ paths.directory,
2226
+ `.${basename(paths.source)}.sync-${transactionId}`,
2227
+ ),
2228
+ journal = resolve(paths.stateDir, "sync-transaction.json");
2229
+ await durableWrite(
2230
+ staging,
2231
+ result,
2232
+ stat.mode === null ? 0o600 : stat.mode & 0o777,
2233
+ );
2234
+ await fsyncDirectory(paths.directory);
2235
+ await durableWrite(stateBackup, await Deno.readTextFile(store.path));
2236
+ await fsyncDirectory(history);
2237
+ const needsApproval = verified.creationChangeIds.length > 0 ||
2238
+ verified.normalizations.length > 0 ||
2239
+ verified.confirmedDeleteIds.length > 0 ||
2240
+ (verified.identityBindings?.length ?? 0) > 0 ||
2241
+ (verified.componentRetirements?.length ?? 0) > 0 ||
2242
+ (verified.componentProjectionRetirements?.length ?? 0) > 0;
2243
+ const approvalPath = resolve(paths.stateDir, "sync-approved.json"),
2244
+ approvalStaging = resolve(history, `sync-approval-${transactionId}.json`);
2245
+ if (needsApproval) {
2246
+ const approval: SyncApproval = {
2247
+ version: 1,
2248
+ docId: state.docId,
2249
+ sourceHash: verified.resultHash,
2250
+ stateRevision: state.revision + 1,
2251
+ planHash: verified.planHash,
2252
+ expectedPending: verified.selectedChangeIds.map((id) =>
2253
+ clone(state.pending.find((p) => p.id === id)!)
2254
+ ),
2255
+ creationChangeIds: verified.creationChangeIds,
2256
+ normalizations: verified.normalizations,
2257
+ ...(verified.componentApprovals
2258
+ ? { componentApprovals: clone(verified.componentApprovals) }
2259
+ : {}),
2260
+ ...(verified.componentRetirements
2261
+ ? { componentRetirements: clone(verified.componentRetirements) }
2262
+ : {}),
2263
+ ...(verified.componentProjectionRetirements
2264
+ ? {
2265
+ componentProjectionRetirements: clone(
2266
+ verified.componentProjectionRetirements,
2267
+ ),
2268
+ }
2269
+ : {}),
2270
+ ...((verified.componentRetirements?.length ||
2271
+ verified.componentProjectionRetirements?.length ||
2272
+ verified.selectedChangeIds.some((id) =>
2273
+ state.pending.some((p) =>
2274
+ p.id === id && p.kind === "delete-element"
2275
+ )
2276
+ ))
2277
+ ? {
2278
+ deletionStateHash: hashSource(
2279
+ canonical({ ...clone(state), revision: state.revision + 1 }),
2280
+ ),
2281
+ }
2282
+ : {}),
2283
+ ...(verified.identityBindings
2284
+ ? { identityBindings: verified.identityBindings }
2285
+ : {}),
2286
+ };
2287
+ const envelope: SyncApprovalEnvelope = {
2288
+ format: "qbento-sync-approval/1",
2289
+ sha256: hashSource(canonical(approval)),
2290
+ approval,
2291
+ };
2292
+ await durableWrite(
2293
+ approvalStaging,
2294
+ JSON.stringify(envelope, null, 2) + "\n",
2295
+ );
2296
+ await fsyncDirectory(history);
2297
+ }
2298
+ await durableWrite(
2299
+ journal,
2300
+ JSON.stringify(
2301
+ {
2302
+ version: 1,
2303
+ transactionId,
2304
+ source: paths.source,
2305
+ sourceBackup,
2306
+ stateBackup,
2307
+ staging,
2308
+ receiptPath,
2309
+ sourceHash: plan.sourceHash,
2310
+ resultHash: plan.resultHash,
2311
+ stateRevision: state.revision,
2312
+ planHash: plan.planHash,
2313
+ selectedChangeIds: plan.selectedChangeIds,
2314
+ annotations: annotationSummary,
2315
+ ...(needsApproval ? { approvalPath, approvalStaging } : {}),
2316
+ },
2317
+ null,
2318
+ 2,
2319
+ ) + "\n",
2320
+ );
2321
+ await fsyncDirectory(paths.stateDir);
2322
+ // Recheck after durable preparation; failed transactions deliberately retain recovery artifacts.
2323
+ if (hashSource(await Deno.readTextFile(paths.source)) !== plan.sourceHash) {
2324
+ fail(
2325
+ "STALE_SOURCE",
2326
+ "QMD changed during preparation; inspect the retained transaction.",
2327
+ );
2328
+ }
2329
+ if (
2330
+ !registryEqual(
2331
+ await annotationRegistry(paths.directory),
2332
+ plan.designRegistry,
2333
+ )
2334
+ ) {
2335
+ fail(
2336
+ "STALE_DESIGN_REGISTRY",
2337
+ "External design registry changed during preparation; inspect the retained transaction.",
2338
+ );
2339
+ }
2340
+ const freshState = await store.load();
2341
+ if (!freshState || canonical(freshState) !== canonical(state)) {
2342
+ fail(
2343
+ "STALE_REVISION",
2344
+ "State changed during preparation; inspect the retained transaction.",
2345
+ );
2346
+ }
2347
+ await Deno.rename(paths.source, sourceBackup);
2348
+ await fsyncDirectory(paths.directory);
2349
+ await fsyncDirectory(history);
2350
+ // A concurrent editor save is preserved in backup or at the original path, never overwritten.
2351
+ if (hashSource(await Deno.readTextFile(sourceBackup)) !== plan.sourceHash) {
2352
+ try {
2353
+ await Deno.link(sourceBackup, paths.source);
2354
+ } catch (error) {
2355
+ if (!(error instanceof Deno.errors.AlreadyExists)) throw error;
2356
+ }
2357
+ fail(
2358
+ "STALE_SOURCE",
2359
+ "Concurrent QMD save preserved in history; explicit recovery required.",
2360
+ );
2361
+ }
2362
+ try {
2363
+ await Deno.link(staging, paths.source);
2364
+ } catch (error) {
2365
+ if (error instanceof Deno.errors.AlreadyExists) {
2366
+ fail(
2367
+ "CONCURRENT_SOURCE",
2368
+ "A concurrent editor recreated QMD; its file and the original backup are preserved. Recover the retained transaction.",
2369
+ );
2370
+ }
2371
+ throw error;
2372
+ }
2373
+ await fsyncDirectory(paths.directory);
2374
+ if (
2375
+ hashSource(await Deno.readTextFile(paths.source)) !==
2376
+ verified.resultHash
2377
+ ) {
2378
+ fail(
2379
+ "STALE_SOURCE",
2380
+ "Source changed during publication; recover the retained transaction.",
2381
+ );
2382
+ }
2383
+ // No baseline advancement and no pending removal before a later execution confirms the source.
2384
+ const saved = await store.save(state, plan.stateRevision);
2385
+ if (
2386
+ hashSource(await Deno.readTextFile(paths.source)) !==
2387
+ verified.resultHash
2388
+ ) {
2389
+ fail(
2390
+ "STALE_SOURCE",
2391
+ "Source changed while recording sync; inspect the retained transaction.",
2392
+ );
2393
+ }
2394
+ if (needsApproval) {
2395
+ await Deno.link(approvalStaging, approvalPath);
2396
+ await fsyncDirectory(paths.stateDir);
2397
+ }
2398
+ const receipt: SyncReceipt = {
2399
+ version: 1,
2400
+ transactionId,
2401
+ source: paths.source,
2402
+ planHash: plan.planHash,
2403
+ previousSourceHash: plan.sourceHash,
2404
+ sourceHash: verified.resultHash,
2405
+ previousRevision: state.revision,
2406
+ revision: saved.revision,
2407
+ appliedChangeIds: [...plan.selectedChangeIds],
2408
+ sourceBackup,
2409
+ stateBackup,
2410
+ receiptPath,
2411
+ renderNeeded: true,
2412
+ pendingRetained: true,
2413
+ annotations: annotationSummary,
2414
+ };
2415
+ await durableWrite(receiptPath, JSON.stringify(receipt, null, 2) + "\n");
2416
+ await fsyncDirectory(history);
2417
+ await Deno.remove(staging);
2418
+ await Deno.remove(journal);
2419
+ await fsyncDirectory(paths.directory);
2420
+ await fsyncDirectory(paths.stateDir);
2421
+ return receipt;
2422
+ });
2423
+ }