@brianmsm/quarto-bento 0.0.0-stage → 0.1.1

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