@brianmsm/quarto-bento 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/bin/qbento-npm.mjs +62 -0
  4. package/distribution/LICENSE +21 -0
  5. package/distribution/PACKAGING.md +59 -0
  6. package/distribution/bin/qbento +5 -0
  7. package/distribution/bin/qbento.cmd +4 -0
  8. package/distribution/manifest.json +720 -0
  9. package/distribution/runtime/LICENSE +21 -0
  10. package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
  11. package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
  12. package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
  13. package/distribution/runtime/_extensions/qbento/post.ts +2 -0
  14. package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
  15. package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
  16. package/distribution/runtime/src/bridge/browser.js +168 -0
  17. package/distribution/runtime/src/bridge/isolation.ts +55 -0
  18. package/distribution/runtime/src/browser/chromium.ts +218 -0
  19. package/distribution/runtime/src/cli.ts +219 -0
  20. package/distribution/runtime/src/core/agent.ts +565 -0
  21. package/distribution/runtime/src/core/annotations.ts +384 -0
  22. package/distribution/runtime/src/core/capture.ts +203 -0
  23. package/distribution/runtime/src/core/component-actions.ts +550 -0
  24. package/distribution/runtime/src/core/component-frames.ts +59 -0
  25. package/distribution/runtime/src/core/component-sync.ts +290 -0
  26. package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
  27. package/distribution/runtime/src/core/design-registry.ts +287 -0
  28. package/distribution/runtime/src/core/graph-relations.ts +354 -0
  29. package/distribution/runtime/src/core/graph-scope.ts +63 -0
  30. package/distribution/runtime/src/core/graph-sync.ts +228 -0
  31. package/distribution/runtime/src/core/io.ts +27 -0
  32. package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
  33. package/distribution/runtime/src/core/layouts.ts +245 -0
  34. package/distribution/runtime/src/core/project.ts +759 -0
  35. package/distribution/runtime/src/core/reconcile.ts +384 -0
  36. package/distribution/runtime/src/core/recovery.ts +1255 -0
  37. package/distribution/runtime/src/core/slide-actions.ts +158 -0
  38. package/distribution/runtime/src/core/source-scope.ts +50 -0
  39. package/distribution/runtime/src/core/sync.ts +2423 -0
  40. package/distribution/runtime/src/core/widgets.ts +300 -0
  41. package/distribution/runtime/src/integration/install.ts +98 -0
  42. package/distribution/runtime/src/integration/invocation.ts +100 -0
  43. package/distribution/runtime/src/integration/quarto.ts +457 -0
  44. package/distribution/runtime/src/render/assets.ts +66 -0
  45. package/distribution/runtime/src/render/capture.lua +8 -0
  46. package/distribution/runtime/src/render/chart.ts +165 -0
  47. package/distribution/runtime/src/render/components.ts +199 -0
  48. package/distribution/runtime/src/render/design.ts +820 -0
  49. package/distribution/runtime/src/render/diagnostics.ts +36 -0
  50. package/distribution/runtime/src/render/graphs.ts +747 -0
  51. package/distribution/runtime/src/render/html.ts +82 -0
  52. package/distribution/runtime/src/render/images.ts +268 -0
  53. package/distribution/runtime/src/render/inline.ts +259 -0
  54. package/distribution/runtime/src/render/kpi.ts +385 -0
  55. package/distribution/runtime/src/render/lists.ts +183 -0
  56. package/distribution/runtime/src/render/math.ts +413 -0
  57. package/distribution/runtime/src/render/measure.ts +185 -0
  58. package/distribution/runtime/src/render/native-layout.ts +174 -0
  59. package/distribution/runtime/src/render/producer-capture.ts +319 -0
  60. package/distribution/runtime/src/render/producer-environment.R +49 -0
  61. package/distribution/runtime/src/render/producer-environment.ts +96 -0
  62. package/distribution/runtime/src/render/producer-profile.R +91 -0
  63. package/distribution/runtime/src/render/producer-site-profile.R +18 -0
  64. package/distribution/runtime/src/render/project.ts +1806 -0
  65. package/distribution/runtime/src/render/quarto.ts +127 -0
  66. package/distribution/runtime/src/render/registry.ts +233 -0
  67. package/distribution/runtime/src/render/resources.ts +292 -0
  68. package/distribution/runtime/src/render/shell.ts +110 -0
  69. package/distribution/runtime/src/render/source-inline.ts +169 -0
  70. package/distribution/runtime/src/render/table.ts +220 -0
  71. package/distribution/runtime/src/render/text-overflow.ts +208 -0
  72. package/distribution/runtime/src/render/types.ts +71 -0
  73. package/distribution/runtime/src/render/validate.ts +572 -0
  74. package/distribution/runtime/src/rich/project.ts +117 -0
  75. package/distribution/runtime/src/rich/table.ts +374 -0
  76. package/distribution/runtime/src/source/annotations.ts +490 -0
  77. package/distribution/runtime/src/source/attributes.ts +415 -0
  78. package/distribution/runtime/src/source/chart.ts +165 -0
  79. package/distribution/runtime/src/source/code-language.ts +277 -0
  80. package/distribution/runtime/src/source/component-insertion.ts +160 -0
  81. package/distribution/runtime/src/source/deletion.ts +278 -0
  82. package/distribution/runtime/src/source/graphs.ts +517 -0
  83. package/distribution/runtime/src/source/identities.ts +256 -0
  84. package/distribution/runtime/src/source/identity-id.ts +6 -0
  85. package/distribution/runtime/src/source/image.ts +50 -0
  86. package/distribution/runtime/src/source/kpi.ts +196 -0
  87. package/distribution/runtime/src/source/mod.ts +876 -0
  88. package/distribution/runtime/src/source/notes.ts +308 -0
  89. package/distribution/runtime/src/source/structure.ts +647 -0
  90. package/distribution/runtime/src/source/table.ts +290 -0
  91. package/distribution/runtime/src/source/text-conversion.ts +146 -0
  92. package/distribution/runtime/src/state/components.ts +281 -0
  93. package/distribution/runtime/src/state/graphs.ts +200 -0
  94. package/distribution/runtime/src/state/json.ts +162 -0
  95. package/distribution/runtime/src/state/merge.ts +1034 -0
  96. package/distribution/runtime/src/state/mod.ts +5 -0
  97. package/distribution/runtime/src/state/store.ts +226 -0
  98. package/distribution/runtime/src/state/types.ts +151 -0
  99. package/distribution/runtime/src/studio/client.js +790 -0
  100. package/distribution/runtime/src/studio/components.js +188 -0
  101. package/distribution/runtime/src/studio/index.html +278 -0
  102. package/distribution/runtime/src/studio/layouts.js +170 -0
  103. package/distribution/runtime/src/studio/registry.js +283 -0
  104. package/distribution/runtime/src/studio/relations.js +169 -0
  105. package/distribution/runtime/src/studio/server.ts +433 -0
  106. package/distribution/runtime/src/widgets/deck.ts +39 -0
  107. package/distribution/runtime/src/widgets/plotly.ts +354 -0
  108. package/distribution/runtime/src/widgets/presenter.ts +367 -0
  109. package/distribution/runtime/src/widgets/snapshot.ts +116 -0
  110. package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
  111. package/distribution/runtime/vendor/bento/LICENSE +21 -0
  112. package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
  113. package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
  114. package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
  115. package/distribution/runtime/vendor/bento/pin.json +69 -0
  116. package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
  117. package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
  118. package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
  119. package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
  120. package/distribution/runtime/vendor/bento/shell.html +269 -0
  121. package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
  122. package/package.json +40 -4
@@ -0,0 +1,281 @@
1
+ import { clone, targetKey, validateDoc } from "./json.ts";
2
+ import {
3
+ type BentoDoc,
4
+ type ComponentCreation,
5
+ type ComponentFrame,
6
+ type ComponentProjection,
7
+ type ProjectState,
8
+ StateError,
9
+ type Target,
10
+ } from "./types.ts";
11
+
12
+ const object = (value: unknown): value is Record<string, unknown> =>
13
+ !!value && typeof value === "object" && !Array.isArray(value);
14
+ function exact(value: Record<string, unknown>, keys: string[]) {
15
+ return Object.keys(value).length === keys.length &&
16
+ keys.every((key) => Object.hasOwn(value, key));
17
+ }
18
+ const sourceId = (value: unknown): value is string =>
19
+ typeof value === "string" && /^[A-Za-z][\w:.-]{0,119}$/.test(value);
20
+ function validFrame(value: unknown): value is ComponentFrame {
21
+ return object(value) && exact(value, ["x", "y", "w", "h"]) &&
22
+ Object.values(value).every((number) =>
23
+ typeof number === "number" && Number.isFinite(number)
24
+ ) &&
25
+ Number(value.w) > 0 && Number(value.h) > 0;
26
+ }
27
+ /** Reserve source and generated identities, including hidden durable metadata. */
28
+ export function componentIdentities(
29
+ id: string,
30
+ kind: "card" | "kpi",
31
+ ): string[] {
32
+ const roles = kind === "card"
33
+ ? ["title", "body"]
34
+ : ["label", "value", "detail"];
35
+ return [
36
+ id,
37
+ `${id}/qb-${kind}-background`,
38
+ ...roles.flatMap((role) => [
39
+ `${id}-${role}`,
40
+ ...(role === "title" ? [] : [`${id}-${role}/paragraph:1`]),
41
+ ]),
42
+ ];
43
+ }
44
+ export function validateComponentProjections(
45
+ value: unknown,
46
+ intents: ComponentCreation[] = [],
47
+ ): asserts value is ComponentProjection[] | undefined {
48
+ if (value === undefined) return;
49
+ const fail = (): never => {
50
+ throw new StateError(
51
+ "INVALID_COMPONENT_PROJECTION",
52
+ "Invalid, overlapping or unknown component projection metadata.",
53
+ );
54
+ };
55
+ if (!Array.isArray(value) || value.length + intents.length > 5000) fail();
56
+ const reserved = new Set(
57
+ intents.flatMap((intent) => componentIdentities(intent.id, intent.kind)),
58
+ );
59
+ for (const entry of value as ComponentProjection[]) {
60
+ if (
61
+ !object(entry) ||
62
+ !exact(entry, ["version", "id", "kind", "slideId", "frame"]) ||
63
+ entry.version !== 1 || !sourceId(entry.id) ||
64
+ !["card", "kpi"].includes(entry.kind) ||
65
+ typeof entry.slideId !== "string" || !entry.slideId ||
66
+ !validFrame(entry.frame)
67
+ ) fail();
68
+ const ids = componentIdentities(entry.id, entry.kind);
69
+ if (ids.some((id) => reserved.has(id))) fail();
70
+ ids.forEach((id) => reserved.add(id));
71
+ }
72
+ }
73
+ export function componentOwns(state: ProjectState, target: Target): boolean {
74
+ return !!state.componentCreations?.some((intent) =>
75
+ intent.slideId === target.slideId &&
76
+ (intent.backgroundId === target.elementId ||
77
+ intent.members.some((member) => member.nativeId === target.elementId))
78
+ );
79
+ }
80
+ /** Exact versioned ownership, never inferred from positions or native groupId. */
81
+ export function validateComponentCreations(
82
+ value: unknown,
83
+ docId: string,
84
+ ): asserts value is ComponentCreation[] | undefined {
85
+ if (value === undefined) return;
86
+ const fail = (): never => {
87
+ throw new StateError(
88
+ "INVALID_COMPONENT_INTENT",
89
+ "Invalid, overlapping or unknown component creation metadata.",
90
+ );
91
+ };
92
+ if (!Array.isArray(value) || value.length > 128) fail();
93
+ const sources = new Set<string>(), native = new Set<string>();
94
+ for (const entry of value as ComponentCreation[]) {
95
+ if (
96
+ !object(entry) ||
97
+ !exact(entry, [
98
+ "version",
99
+ "id",
100
+ "kind",
101
+ "slideId",
102
+ "backgroundId",
103
+ "backgroundSnapshot",
104
+ "projectionFrame",
105
+ "restoreOrder",
106
+ "members",
107
+ ]) || entry.version !== 1 || !sourceId(entry.id) ||
108
+ !["card", "kpi"].includes(entry.kind) ||
109
+ typeof entry.slideId !== "string" || !entry.slideId ||
110
+ entry.backgroundId !== `${entry.id}/qb-${entry.kind}-background` ||
111
+ !Array.isArray(entry.members) || !validFrame(entry.projectionFrame)
112
+ ) fail();
113
+ const roles = entry.kind === "card"
114
+ ? ["title", "body"]
115
+ : ["label", "value", ...(entry.members.length === 3 ? ["detail"] : [])];
116
+ if (entry.members.length !== roles.length) fail();
117
+ if (sources.has(entry.id)) fail();
118
+ sources.add(entry.id);
119
+ for (const [index, member] of entry.members.entries()) {
120
+ if (
121
+ !object(member) ||
122
+ !exact(member, ["role", "sourceId", "nativeId", "snapshot"]) ||
123
+ member.role !== roles[index] ||
124
+ member.sourceId !== `${entry.id}-${member.role}` ||
125
+ member.nativeId !==
126
+ (member.role === "title"
127
+ ? member.sourceId
128
+ : `${member.sourceId}/paragraph:1`) ||
129
+ !object(member.snapshot) || member.snapshot.id !== member.nativeId ||
130
+ member.snapshot.type !== "text" ||
131
+ typeof member.snapshot.html !== "string" || sources.has(member.sourceId)
132
+ ) fail();
133
+ sources.add(member.sourceId);
134
+ // Implicit paragraph IDs are source identities too.
135
+ if (member.nativeId !== member.sourceId) {
136
+ if (sources.has(member.nativeId)) fail();
137
+ sources.add(member.nativeId);
138
+ }
139
+ }
140
+ if (
141
+ !object(entry.backgroundSnapshot) ||
142
+ entry.backgroundSnapshot.id !== entry.backgroundId ||
143
+ entry.backgroundSnapshot.type !== "shape" ||
144
+ entry.backgroundSnapshot.shape !== "rect"
145
+ ) fail();
146
+ const memberIds = [
147
+ entry.backgroundId,
148
+ ...entry.members.map((member) => member.nativeId),
149
+ ];
150
+ if (
151
+ !Array.isArray(entry.restoreOrder) || entry.restoreOrder.length > 5000 ||
152
+ entry.restoreOrder.some((id) => typeof id !== "string" || !id) ||
153
+ new Set(entry.restoreOrder).size !== entry.restoreOrder.length ||
154
+ memberIds.some((id) => !entry.restoreOrder.includes(id))
155
+ ) fail();
156
+ const snapshots = [
157
+ entry.backgroundSnapshot,
158
+ ...entry.members.map((member) => member.snapshot),
159
+ ];
160
+ for (const snapshot of snapshots) {
161
+ const key = targetKey({ slideId: entry.slideId, elementId: snapshot.id });
162
+ if (native.has(key)) fail();
163
+ native.add(key);
164
+ }
165
+ validateDoc({
166
+ docId,
167
+ slides: [{ id: entry.slideId, elements: snapshots }],
168
+ });
169
+ }
170
+ }
171
+ /** Last-present snapshots survive missing fields and the containing slide's deletion. */
172
+ export function captureComponentSnapshots(
173
+ state: ProjectState,
174
+ edited: BentoDoc,
175
+ ): void {
176
+ state.componentCreations = state.componentCreations?.filter((intent) => {
177
+ const slide = edited.slides.find((slide) => slide.id === intent.slideId);
178
+ if (!slide) return true;
179
+ const ids = [
180
+ intent.backgroundId,
181
+ ...intent.members.map((member) => member.nativeId),
182
+ ];
183
+ const known = slide.elements.filter((element) => ids.includes(element.id));
184
+ const extra = slide.elements.some((element) =>
185
+ element.groupId === intent.id && !ids.includes(element.id)
186
+ );
187
+ if (!known.length && !extra) return false;
188
+ if (ids.every((id) => known.some((element) => element.id === id))) {
189
+ intent.restoreOrder = slide.elements.map((element) => element.id);
190
+ }
191
+ for (const member of intent.members) {
192
+ const current = known.find((element) => element.id === member.nativeId);
193
+ if (current?.type === "text" && typeof current.html === "string") {
194
+ member.snapshot = clone(current);
195
+ }
196
+ }
197
+ const background = known.find((element) =>
198
+ element.id === intent.backgroundId
199
+ );
200
+ if (background?.type === "shape" && background.shape === "rect") {
201
+ intent.backgroundSnapshot = clone(background);
202
+ }
203
+ return true;
204
+ });
205
+ if (!state.componentCreations?.length) delete state.componentCreations;
206
+ }
207
+ export function componentProblems(
208
+ state: ProjectState,
209
+ intent: ComponentCreation,
210
+ ): string[] {
211
+ const slide = state.emitted.slides.find((slide) =>
212
+ slide.id === intent.slideId
213
+ );
214
+ if (!slide) {
215
+ return [
216
+ "La diapositiva está pendiente de eliminación: restáurala primero o revisa su eliminación.",
217
+ ];
218
+ }
219
+ const ids = [
220
+ intent.backgroundId,
221
+ ...intent.members.map((member) => member.nativeId),
222
+ ];
223
+ const problems: string[] = [];
224
+ const background = slide.elements.find((element) =>
225
+ element.id === intent.backgroundId
226
+ );
227
+ if (
228
+ !background || background.type !== "shape" || background.shape !== "rect"
229
+ ) problems.push("Falta el fondo rectangular original o cambió su tipo.");
230
+ for (const member of intent.members) {
231
+ const element = slide.elements.find((element) =>
232
+ element.id === member.nativeId
233
+ );
234
+ if (
235
+ !element || element.type !== "text" || typeof element.html !== "string"
236
+ ) {
237
+ problems.push(
238
+ `Falta el campo ${
239
+ {
240
+ title: "título",
241
+ body: "cuerpo",
242
+ label: "etiqueta",
243
+ value: "valor",
244
+ detail: "detalle",
245
+ }[member.role]
246
+ } original o cambió su tipo.`,
247
+ );
248
+ }
249
+ const pending = state.pending.find((change) =>
250
+ change.kind === "create-element" &&
251
+ change.target.slideId === intent.slideId &&
252
+ change.target.elementId === member.nativeId
253
+ );
254
+ if (
255
+ element &&
256
+ (!pending || pending.origin !== "authored" || pending.before.present)
257
+ ) {
258
+ problems.push(
259
+ `El campo ${
260
+ {
261
+ title: "título",
262
+ body: "cuerpo",
263
+ label: "etiqueta",
264
+ value: "valor",
265
+ detail: "detalle",
266
+ }[member.role]
267
+ } no conserva una creación pendiente verificable.`,
268
+ );
269
+ }
270
+ }
271
+ if (
272
+ slide.elements.some((element) =>
273
+ element.groupId === intent.id && !ids.includes(element.id)
274
+ )
275
+ ) {
276
+ problems.push(
277
+ "Hay objetos adicionales en el grupo. Sepáralos o descarta el componente pendiente.",
278
+ );
279
+ }
280
+ return problems;
281
+ }
@@ -0,0 +1,200 @@
1
+ /** Checked relationships for source graphs only; ordinary decorations remain visual. */
2
+ import {
3
+ type BentoDoc,
4
+ type Change,
5
+ type JsonObject,
6
+ type Provenance,
7
+ type ProvenanceMap,
8
+ StateError,
9
+ } from "./types.ts";
10
+
11
+ export interface GraphReceipt extends JsonObject {
12
+ version: 1;
13
+ kind: "diagram" | "timeline";
14
+ componentId: string;
15
+ sourceId: string;
16
+ role: "node" | "milestone" | "edge" | "timeline-link";
17
+ nodeIds: string[];
18
+ }
19
+ export function graphReceipt(
20
+ provenance?: Provenance,
21
+ ): GraphReceipt | undefined {
22
+ const graph = provenance?.source?.graph;
23
+ if (graph === undefined) return undefined;
24
+ if (
25
+ !graph || typeof graph !== "object" || Array.isArray(graph) ||
26
+ graph.version !== 1 ||
27
+ !["diagram", "timeline"].includes(String(graph.kind)) ||
28
+ typeof graph.componentId !== "string" ||
29
+ typeof graph.sourceId !== "string" ||
30
+ !["node", "milestone", "edge", "timeline-link"].includes(
31
+ String(graph.role),
32
+ ) ||
33
+ !Array.isArray(graph.nodeIds) ||
34
+ graph.nodeIds.some((id) => typeof id !== "string") ||
35
+ new Set(graph.nodeIds).size !== graph.nodeIds.length
36
+ ) {
37
+ throw new StateError(
38
+ "INVALID_GRAPH_PROVENANCE",
39
+ "A source graph requires a checked bounded membership receipt.",
40
+ );
41
+ }
42
+ return graph as GraphReceipt;
43
+ }
44
+ export function graphRelationshipPath(
45
+ provenance: Provenance | undefined,
46
+ path: string[],
47
+ ): boolean {
48
+ const graph = graphReceipt(provenance);
49
+ return !!graph && ["edge", "timeline-link"].includes(graph.role) &&
50
+ ["from", "to"].includes(path[0]) && (path.length === 1 || path[1] === "el");
51
+ }
52
+ function fail(code: string, message: string): never {
53
+ throw new StateError(code, message);
54
+ }
55
+ /** Capture preserves missing known source parts as pending; publication cannot emit dangling native graphs. */
56
+ export function assertGraphDocument(
57
+ document: BentoDoc,
58
+ provenance: ProvenanceMap,
59
+ options: {
60
+ baseline?: BentoDoc;
61
+ capture?: boolean;
62
+ strict?: boolean;
63
+ pending?: Change[];
64
+ } = {},
65
+ ): void {
66
+ const partial = options.capture || options.strict === false;
67
+ for (const slide of document.slides) {
68
+ const proofs = Object.entries(provenance).flatMap(([key, source]) => {
69
+ const graph = graphReceipt(source);
70
+ if (!graph) return [];
71
+ const [slideId, elementId] = JSON.parse(key) as [
72
+ string | null,
73
+ string | null,
74
+ ];
75
+ return slideId === slide.id && elementId && graph
76
+ ? [{ elementId, graph }]
77
+ : [];
78
+ });
79
+ for (const { elementId, graph } of proofs) {
80
+ const element = slide.elements.find((element) =>
81
+ element.id === elementId
82
+ );
83
+ if (!element) {
84
+ if (!partial) {
85
+ fail(
86
+ "GRAPH_PENDING_DELETION",
87
+ "A source graph part is removed only in Bento; restore it or synchronize an explicitly supported source operation before rendering.",
88
+ );
89
+ }
90
+ continue;
91
+ }
92
+ if (
93
+ graph.labelId &&
94
+ !slide.elements.some((element) => element.id === graph.labelId)
95
+ ) {
96
+ if (!partial) {
97
+ fail(
98
+ "GRAPH_PENDING_DELETION",
99
+ "A source graph label is pending deletion; preserve/review the source component before publishing.",
100
+ );
101
+ }
102
+ }
103
+ if (
104
+ !partial && graph.labelId &&
105
+ slide.elements.find((label) => label.id === graph.labelId)?.type !==
106
+ "text"
107
+ ) {
108
+ fail(
109
+ "GRAPH_PENDING_STRUCTURE",
110
+ "The native source graph label changed renderer; restore its text object before publishing.",
111
+ );
112
+ }
113
+ if (graph.role === "node" || graph.role === "milestone") {
114
+ if (
115
+ !partial &&
116
+ (element.type !== "shape" ||
117
+ ["line", "path"].includes(String(element.shape)))
118
+ ) {
119
+ fail(
120
+ "GRAPH_PENDING_STRUCTURE",
121
+ "The native source node changed to an incompatible renderer; restore the node shape before publishing.",
122
+ );
123
+ }
124
+ continue;
125
+ }
126
+ if (!partial && (element.type !== "shape" || element.shape !== "line")) {
127
+ fail(
128
+ "GRAPH_PENDING_STRUCTURE",
129
+ "The native source connector changed its renderer; restore its line shape before publishing the relationship.",
130
+ );
131
+ }
132
+ for (const endpoint of ["from", "to"] as const) {
133
+ const value = element[endpoint];
134
+ const reference =
135
+ value && typeof value === "object" && !Array.isArray(value)
136
+ ? value.el
137
+ : undefined;
138
+ if (reference === undefined && partial) continue; // Native detachment remains pending, never style.
139
+ if (typeof reference !== "string" || !reference) {
140
+ fail(
141
+ "GRAPH_ENDPOINT_REQUIRED",
142
+ "Source graph connectors need both explicit native node endpoints; restore or review the detached endpoint.",
143
+ );
144
+ }
145
+ const expected = String(graph[endpoint]) + "/qb-node";
146
+ if (
147
+ graph.role === "timeline-link" && !partial && reference !== expected
148
+ ) {
149
+ fail(
150
+ "UNSUPPORTED_TIMELINE_RELATION",
151
+ "Timeline links derive from lexical QMD order. Native endpoint edits remain pending; restore them or reorder original milestone blocks.",
152
+ );
153
+ }
154
+ const targetProof = proofs.find((proof) =>
155
+ proof.elementId === reference
156
+ )?.graph;
157
+ const target = slide.elements.find((element) =>
158
+ element.id === reference
159
+ );
160
+ if (
161
+ !targetProof || !["node", "milestone"].includes(targetProof.role) ||
162
+ targetProof.kind !== graph.kind ||
163
+ targetProof.componentId !== graph.componentId ||
164
+ !graph.nodeIds.includes(targetProof.sourceId) ||
165
+ reference !== targetProof.sourceId + "/qb-node"
166
+ ) {
167
+ fail(
168
+ "GRAPH_ENDPOINT_SCOPE",
169
+ "A source connector can reference only a direct source node shape in its own original graph and slide.",
170
+ );
171
+ }
172
+ if (!target) {
173
+ const baseline = options.baseline?.slides.find((baseline) =>
174
+ baseline.id === slide.id
175
+ );
176
+ const previous = baseline?.elements.find((previous) =>
177
+ previous.id === elementId
178
+ )?.[endpoint];
179
+ const unchanged = previous && typeof previous === "object" &&
180
+ !Array.isArray(previous) && previous.el === reference;
181
+ if (!partial || !unchanged) {
182
+ fail(
183
+ "GRAPH_DANGLING_ENDPOINT",
184
+ "The referenced native node is absent; restore the pending node before publishing this graph.",
185
+ );
186
+ }
187
+ }
188
+ }
189
+ const from = element.from as JsonObject | undefined,
190
+ to = element.to as JsonObject | undefined;
191
+ if (from?.el && from.el === to?.el) {
192
+ fail(
193
+ "GRAPH_ENDPOINT_SCOPE",
194
+ "Self edges are outside the bounded source diagram contract.",
195
+ );
196
+ }
197
+ }
198
+ }
199
+ // A removed whole source slide is already a separately controlled pending action.
200
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ type BentoDoc,
3
+ type Field,
4
+ type Json,
5
+ type JsonObject,
6
+ StateError,
7
+ type Target,
8
+ type Value,
9
+ } from "./types.ts";
10
+
11
+ export function assertJson(value: unknown, depth = 0): asserts value is Json {
12
+ if (depth > 100) {
13
+ throw new StateError("INVALID_JSON", "JSON nesting exceeds 100 levels.");
14
+ }
15
+ if (
16
+ value === null || typeof value === "string" || typeof value === "boolean"
17
+ ) return;
18
+ if (typeof value === "number" && Number.isFinite(value)) return;
19
+ if (Array.isArray(value)) {
20
+ for (const item of value) assertJson(item, depth + 1);
21
+ return;
22
+ }
23
+ if (
24
+ typeof value !== "object" ||
25
+ ![Object.prototype, null].includes(Object.getPrototypeOf(value))
26
+ ) throw new StateError("INVALID_JSON", "Expected plain JSON data.");
27
+ for (const [key, item] of Object.entries(value)) {
28
+ if (["__proto__", "prototype", "constructor"].includes(key)) {
29
+ throw new StateError("UNSAFE_KEY", `Unsafe JSON key: ${key}`);
30
+ }
31
+ assertJson(item, depth + 1);
32
+ }
33
+ }
34
+ export function clone<T>(value: T): T {
35
+ return structuredClone(value);
36
+ }
37
+ export function canonical(value: unknown): string {
38
+ if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
39
+ if (value && typeof value === "object") {
40
+ return "{" + Object.keys(value).sort().map((k) =>
41
+ JSON.stringify(k) + ":" + canonical((value as Record<string, unknown>)[k])
42
+ ).join(",") + "}";
43
+ }
44
+ return JSON.stringify(value);
45
+ }
46
+ export function equal(a: unknown, b: unknown): boolean {
47
+ return canonical(a) === canonical(b);
48
+ }
49
+ export function targetKey(target: Target): string {
50
+ return JSON.stringify([target.slideId ?? null, target.elementId ?? null]);
51
+ }
52
+ export function validateDoc(doc: unknown): asserts doc is BentoDoc {
53
+ assertJson(doc);
54
+ if (
55
+ !doc || Array.isArray(doc) || typeof doc !== "object" ||
56
+ typeof doc.docId !== "string" || !doc.docId || !Array.isArray(doc.slides)
57
+ ) {
58
+ throw new StateError(
59
+ "INVALID_DOCUMENT",
60
+ "Document requires docId and slides.",
61
+ );
62
+ }
63
+ const slideIds = new Set();
64
+ for (const slide of doc.slides) {
65
+ if (
66
+ !slide || Array.isArray(slide) || typeof slide !== "object" ||
67
+ typeof slide.id !== "string" || !slide.id ||
68
+ !Array.isArray(slide.elements)
69
+ ) {
70
+ throw new StateError(
71
+ "INVALID_ID",
72
+ "Each slide requires a stable nonempty id and elements array.",
73
+ );
74
+ }
75
+ if (slideIds.has(slide.id)) {
76
+ throw new StateError("DUPLICATE_ID", `Duplicate slide ID ${slide.id}`);
77
+ }
78
+ slideIds.add(slide.id);
79
+ const ids = new Set();
80
+ for (const el of slide.elements) {
81
+ if (
82
+ !el || Array.isArray(el) || typeof el !== "object" ||
83
+ typeof el.id !== "string" || !el.id || typeof el.type !== "string" ||
84
+ !el.type
85
+ ) {
86
+ throw new StateError(
87
+ "INVALID_ID",
88
+ "Each element requires a stable nonempty id and type.",
89
+ );
90
+ }
91
+ if (ids.has(el.id)) {
92
+ throw new StateError(
93
+ "DUPLICATE_ID",
94
+ `Duplicate element ID ${el.id} on ${slide.id}`,
95
+ );
96
+ }
97
+ ids.add(el.id);
98
+ }
99
+ }
100
+ }
101
+ export function entity(doc: BentoDoc, target: Target): JsonObject | undefined {
102
+ if (!target.slideId) return doc;
103
+ const slide = doc.slides.find((s) => s.id === target.slideId);
104
+ return target.elementId
105
+ ? slide?.elements.find((e) => e.id === target.elementId)
106
+ : slide;
107
+ }
108
+ export function get(object: JsonObject | undefined, path: string[]): Value {
109
+ let value: Json | undefined = object;
110
+ for (const key of path) {
111
+ if (
112
+ !value || typeof value !== "object" || Array.isArray(value) ||
113
+ !Object.hasOwn(value, key)
114
+ ) return { present: false };
115
+ value = value[key];
116
+ }
117
+ return value === undefined
118
+ ? { present: false }
119
+ : { present: true, value: clone(value) };
120
+ }
121
+ export function set(object: JsonObject, path: string[], value: Value): void {
122
+ if (
123
+ !path.length ||
124
+ path.some((k) => ["__proto__", "constructor", "prototype"].includes(k))
125
+ ) throw new StateError("INVALID_PATH", "Unsafe or empty property path.");
126
+ let cursor = object;
127
+ for (const key of path.slice(0, -1)) {
128
+ if (
129
+ !cursor[key] || typeof cursor[key] !== "object" ||
130
+ Array.isArray(cursor[key])
131
+ ) cursor[key] = {};
132
+ cursor = cursor[key] as JsonObject;
133
+ }
134
+ if (value.present) cursor[path.at(-1)!] = clone(value.value!);
135
+ else delete cursor[path.at(-1)!];
136
+ }
137
+ export function fields(
138
+ before: JsonObject,
139
+ after: JsonObject,
140
+ skip: string[] = [],
141
+ ): Field[] {
142
+ const result: Field[] = [];
143
+ function visit(a: JsonObject, b: JsonObject, path: string[]) {
144
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
145
+ if (!path.length && skip.includes(key)) continue;
146
+ const av = a[key], bv = b[key], next = [...path, key];
147
+ if (equal(av, bv)) continue;
148
+ if (
149
+ av && bv && typeof av === "object" && typeof bv === "object" &&
150
+ !Array.isArray(av) && !Array.isArray(bv)
151
+ ) visit(av, bv, next);
152
+ else {result.push({
153
+ path: next,
154
+ value: bv === undefined
155
+ ? { present: false }
156
+ : { present: true, value: clone(bv) },
157
+ });}
158
+ }
159
+ }
160
+ visit(before, after, []);
161
+ return result;
162
+ }