konpeki 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/authoring-visuals/SKILL.md +82 -0
- package/AGENTS.md +33 -0
- package/AUTHORING.md +233 -0
- package/LICENSE +201 -0
- package/README.md +70 -0
- package/SETUP.md +86 -0
- package/composition/README.md +166 -0
- package/composition/compile.ts +227 -0
- package/composition/document.ts +709 -0
- package/composition/schema.json +2992 -0
- package/composition/schema.ts +435 -0
- package/composition/theme-tokens.ts +16 -0
- package/composition/types.ts +286 -0
- package/composition/validate.ts +684 -0
- package/composition/vector.ts +143 -0
- package/composition/visualizations.ts +335 -0
- package/design/README.md +17 -0
- package/design/palettes/README.md +14 -0
- package/design/palettes/base.ts +14 -0
- package/design/palettes/candidates.ts +19 -0
- package/design/palettes/index.ts +78 -0
- package/design/review/color-theme.md +44 -0
- package/design/review/layout.md +16 -0
- package/design/review/text.md +18 -0
- package/design/review/typography.md +15 -0
- package/design/review/visuals.md +31 -0
- package/design/semantic-patterns.md +43 -0
- package/design/themes/README.md +22 -0
- package/design/themes/index.ts +13 -0
- package/design/visual-languages/technical-product.md +17 -0
- package/design/visual-review.md +88 -0
- package/docs/development.md +140 -0
- package/docs/workflow.md +61 -0
- package/index.html +17 -0
- package/lib/assets.d.ts +8 -0
- package/lib/charts.ts +18 -0
- package/lib/contrast.ts +16 -0
- package/lib/layouts.ts +50 -0
- package/lib/slide.tsx +42 -0
- package/lib/taste.ts +17 -0
- package/lib/text.tsx +89 -0
- package/lib/typeface.ts +44 -0
- package/package.json +85 -0
- package/runtime/konpeki.mjs +1685 -0
- package/scripts/migrate-react-page.ts +120 -0
- package/slides/README.md +153 -0
- package/slides/architecture/PROMPT.md +31 -0
- package/slides/architecture/index.tsx +102 -0
- package/slides/article-brief/PROMPT.md +35 -0
- package/slides/article-brief/index.tsx +71 -0
- package/slides/bar-chart/PROMPT.md +39 -0
- package/slides/bar-chart/index.tsx +97 -0
- package/slides/comparison/PROMPT.md +29 -0
- package/slides/comparison/index.tsx +95 -0
- package/slides/decision-memo/PROMPT.md +34 -0
- package/slides/decision-memo/index.tsx +85 -0
- package/slides/delivery-plan/PROMPT.md +45 -0
- package/slides/delivery-plan/index.tsx +105 -0
- package/slides/experiment/PROMPT.md +44 -0
- package/slides/experiment/index.tsx +127 -0
- package/slides/incident-workflow/PROMPT.md +57 -0
- package/slides/incident-workflow/index.tsx +78 -0
- package/slides/introducing-konpeki/PROMPT.md +19 -0
- package/slides/introducing-konpeki/README.md +54 -0
- package/slides/introducing-konpeki/SOURCE.md +20 -0
- package/slides/introducing-konpeki/author.ts +163 -0
- package/slides/introducing-konpeki/composition.json +3265 -0
- package/slides/line-chart/PROMPT.md +40 -0
- package/slides/line-chart/index.tsx +72 -0
- package/slides/migration/PROMPT.md +38 -0
- package/slides/migration/index.tsx +89 -0
- package/slides/og-images/PROMPT.md +21 -0
- package/slides/og-images/index.tsx +76 -0
- package/slides/product-introduction/PROMPT.md +24 -0
- package/slides/product-introduction/index.tsx +105 -0
- package/slides/research-brief/PROMPT.md +40 -0
- package/slides/research-brief/index.tsx +104 -0
- package/slides/results-explanation/PROMPT.md +32 -0
- package/slides/results-explanation/index.tsx +96 -0
- package/slides/retrospective/PROMPT.md +43 -0
- package/slides/retrospective/index.tsx +105 -0
- package/slides/sankey/PROMPT.md +11 -0
- package/slides/sankey/index.tsx +93 -0
- package/slides/teaching/PROMPT.md +45 -0
- package/slides/teaching/index.tsx +124 -0
- package/slides/vertical-bar-charts/PROMPT.md +13 -0
- package/slides/vertical-bar-charts/index.tsx +97 -0
- package/src/app/App.tsx +694 -0
- package/src/assets/konpeki-mark.png +0 -0
- package/src/components/Canvas.tsx +1168 -0
- package/src/components/DiagramTypeIcon.tsx +78 -0
- package/src/components/InspectorPanel.tsx +687 -0
- package/src/components/LeftPanel.tsx +107 -0
- package/src/components/PageSizePicker.tsx +30 -0
- package/src/components/Presentation.tsx +105 -0
- package/src/components/RightPanel.tsx +201 -0
- package/src/components/VectorOverflowWarning.tsx +46 -0
- package/src/components/WorkspaceChrome.tsx +199 -0
- package/src/components/ui.tsx +53 -0
- package/src/lib/examples/react-page-migration.json +1295 -0
- package/src/lib/examples.ts +42 -0
- package/src/lib/export-png.ts +101 -0
- package/src/lib/file-session.ts +74 -0
- package/src/lib/history.ts +53 -0
- package/src/lib/model.ts +188 -0
- package/src/lib/page-size.ts +24 -0
- package/src/lib/presentation.ts +17 -0
- package/src/lib/storage.ts +43 -0
- package/src/lib/theme.ts +25 -0
- package/src/lib/use-file-session.ts +162 -0
- package/src/main.tsx +26 -0
- package/src/styles/base.css +105 -0
- package/src/styles/canvas.css +268 -0
- package/src/styles/chrome.css +214 -0
- package/src/styles/component-previews.css +386 -0
- package/src/styles/feedback.css +71 -0
- package/src/styles/left-panel.css +125 -0
- package/src/styles/presentation.css +72 -0
- package/src/styles/right-panel.css +1172 -0
- package/src/styles/shell.css +247 -0
- package/vite.config.ts +6 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
|
+
import { schema } from "./schema.ts";
|
|
3
|
+
import { migrateLegacyDiagramType } from "./visualizations.ts";
|
|
4
|
+
import {
|
|
5
|
+
compositionSchema,
|
|
6
|
+
legacyCompositionSchemas,
|
|
7
|
+
type CompositionDocument,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
import { vectorAttributeNames } from "./vector.ts";
|
|
10
|
+
import { validThemeBinding } from "./theme-tokens.ts";
|
|
11
|
+
|
|
12
|
+
export type ValidationIssue = { path: string; message: string };
|
|
13
|
+
export type ValidationResult =
|
|
14
|
+
| { ok: true; document: CompositionDocument }
|
|
15
|
+
| { ok: false; issues: ValidationIssue[] };
|
|
16
|
+
const structural = new Ajv2020({
|
|
17
|
+
allErrors: true,
|
|
18
|
+
strict: false,
|
|
19
|
+
}).compile<CompositionDocument>(schema);
|
|
20
|
+
function migratePreV5Document(input: Record<string, unknown>) {
|
|
21
|
+
const slide = input.slide as Record<string, unknown> | undefined;
|
|
22
|
+
const components = Array.isArray(slide?.components)
|
|
23
|
+
? slide.components.map((value: unknown) => {
|
|
24
|
+
if (!value || typeof value !== "object") return value;
|
|
25
|
+
const component = value as Record<string, unknown>;
|
|
26
|
+
if (
|
|
27
|
+
component.kind !== "callout" &&
|
|
28
|
+
component.kind !== "comparison" &&
|
|
29
|
+
component.kind !== "text-block"
|
|
30
|
+
)
|
|
31
|
+
return component;
|
|
32
|
+
const previous =
|
|
33
|
+
component.appearance && typeof component.appearance === "object"
|
|
34
|
+
? (component.appearance as Record<string, unknown>)
|
|
35
|
+
: {};
|
|
36
|
+
const common = Object.fromEntries(
|
|
37
|
+
["alignment", "border", "titleStyle", "rule"].flatMap((key) =>
|
|
38
|
+
key in previous ? [[key, previous[key]]] : [],
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const appearance =
|
|
42
|
+
component.kind === "callout"
|
|
43
|
+
? {
|
|
44
|
+
...common,
|
|
45
|
+
layout: "single",
|
|
46
|
+
orientation: "horizontal",
|
|
47
|
+
purpose: "emphasis",
|
|
48
|
+
treatment: "strong",
|
|
49
|
+
}
|
|
50
|
+
: component.kind === "comparison"
|
|
51
|
+
? {
|
|
52
|
+
...common,
|
|
53
|
+
layout: "two-column",
|
|
54
|
+
orientation: "horizontal",
|
|
55
|
+
purpose: "comparison",
|
|
56
|
+
treatment: "plain",
|
|
57
|
+
}
|
|
58
|
+
: {
|
|
59
|
+
...previous,
|
|
60
|
+
purpose: previous.purpose ?? "narrative",
|
|
61
|
+
treatment: previous.treatment ?? "plain",
|
|
62
|
+
};
|
|
63
|
+
return { ...component, kind: "text-block", appearance };
|
|
64
|
+
})
|
|
65
|
+
: slide?.components;
|
|
66
|
+
return {
|
|
67
|
+
...Object.fromEntries(
|
|
68
|
+
Object.entries(input).filter(
|
|
69
|
+
([key]) => key !== "target" && key !== "slide",
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
schema: "konpeki-composition/v5",
|
|
73
|
+
...(slide
|
|
74
|
+
? { slides: [{ ...slide, name: "Slide 01", components }] }
|
|
75
|
+
: {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function migrateV5Document(input: Record<string, unknown>) {
|
|
79
|
+
const slides = Array.isArray(input.slides)
|
|
80
|
+
? input.slides.map((value: unknown) => {
|
|
81
|
+
if (!value || typeof value !== "object") return value;
|
|
82
|
+
const slide = value as Record<string, unknown>;
|
|
83
|
+
const previousComponents = Array.isArray(slide.components)
|
|
84
|
+
? (slide.components as Record<string, unknown>[])
|
|
85
|
+
: [];
|
|
86
|
+
const pageNumber = previousComponents.find(
|
|
87
|
+
(component) => component.kind === "page-number",
|
|
88
|
+
);
|
|
89
|
+
const pageAppearance =
|
|
90
|
+
pageNumber?.appearance && typeof pageNumber.appearance === "object"
|
|
91
|
+
? (pageNumber.appearance as Record<string, unknown>)
|
|
92
|
+
: {};
|
|
93
|
+
const removedIds = new Set(
|
|
94
|
+
previousComponents
|
|
95
|
+
.filter((component) => component.kind === "page-number")
|
|
96
|
+
.map((component) => String(component.id)),
|
|
97
|
+
);
|
|
98
|
+
const removedSlots = new Set(
|
|
99
|
+
previousComponents
|
|
100
|
+
.filter((component) => component.kind === "page-number")
|
|
101
|
+
.flatMap((component) =>
|
|
102
|
+
Array.isArray(component.slotIds)
|
|
103
|
+
? component.slotIds.map(String)
|
|
104
|
+
: [],
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
const components = previousComponents.flatMap((component) => {
|
|
108
|
+
const appearance =
|
|
109
|
+
component.appearance && typeof component.appearance === "object"
|
|
110
|
+
? (component.appearance as Record<string, unknown>)
|
|
111
|
+
: {};
|
|
112
|
+
if (component.kind === "page-number") return [];
|
|
113
|
+
if (component.kind === "evidence")
|
|
114
|
+
return [{ ...component, kind: "visual" }];
|
|
115
|
+
if (component.kind === "process")
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
...component,
|
|
119
|
+
kind: "diagram",
|
|
120
|
+
appearance: { ...appearance, type: "process" },
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
if (component.kind === "system-map")
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
...component,
|
|
127
|
+
kind: "diagram",
|
|
128
|
+
appearance: { ...appearance, type: "system" },
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
return [component];
|
|
132
|
+
});
|
|
133
|
+
const componentIds = new Set(components.map((component) => String(component.id)));
|
|
134
|
+
const groups = Array.isArray(slide.groups)
|
|
135
|
+
? (slide.groups as Record<string, unknown>[])
|
|
136
|
+
.map((group) => ({
|
|
137
|
+
...group,
|
|
138
|
+
childIds: Array.isArray(group.childIds)
|
|
139
|
+
? group.childIds.filter((id) => !removedIds.has(String(id)))
|
|
140
|
+
: group.childIds,
|
|
141
|
+
}))
|
|
142
|
+
.filter(
|
|
143
|
+
(group) =>
|
|
144
|
+
!Array.isArray(group.childIds) || group.childIds.length > 0,
|
|
145
|
+
)
|
|
146
|
+
: slide.groups;
|
|
147
|
+
const groupIds = new Set(
|
|
148
|
+
Array.isArray(groups)
|
|
149
|
+
? groups.map((group) => String(group.id))
|
|
150
|
+
: [],
|
|
151
|
+
);
|
|
152
|
+
const contentSlots = Array.isArray(slide.contentSlots)
|
|
153
|
+
? (slide.contentSlots as Record<string, unknown>[])
|
|
154
|
+
.filter((slot) => !removedSlots.has(String(slot.id)))
|
|
155
|
+
.map((slot) => {
|
|
156
|
+
if (!Array.isArray(slot.targets)) return slot;
|
|
157
|
+
const targets = slot.targets.filter(
|
|
158
|
+
(target) =>
|
|
159
|
+
!removedIds.has(String(target)) &&
|
|
160
|
+
!removedSlots.has(String(target)),
|
|
161
|
+
);
|
|
162
|
+
return {
|
|
163
|
+
...slot,
|
|
164
|
+
targets: targets.length
|
|
165
|
+
? targets
|
|
166
|
+
: [String(components[0]?.id ?? "")],
|
|
167
|
+
};
|
|
168
|
+
})
|
|
169
|
+
: slide.contentSlots;
|
|
170
|
+
return {
|
|
171
|
+
...slide,
|
|
172
|
+
pageNumber: {
|
|
173
|
+
style: pageNumber ? (pageAppearance.style ?? "01") : "none",
|
|
174
|
+
color: pageAppearance.color ?? "muted",
|
|
175
|
+
},
|
|
176
|
+
components,
|
|
177
|
+
contentSlots,
|
|
178
|
+
groups,
|
|
179
|
+
readingOrder: Array.isArray(slide.readingOrder)
|
|
180
|
+
? (slide.readingOrder as Record<string, unknown>[]).filter((entry) =>
|
|
181
|
+
entry.kind === "group"
|
|
182
|
+
? groupIds.has(String(entry.id))
|
|
183
|
+
: componentIds.has(String(entry.id)),
|
|
184
|
+
)
|
|
185
|
+
: slide.readingOrder,
|
|
186
|
+
paintOrder: Array.isArray(slide.paintOrder)
|
|
187
|
+
? slide.paintOrder.filter((id) => !removedIds.has(String(id)))
|
|
188
|
+
: slide.paintOrder,
|
|
189
|
+
relationships: Array.isArray(slide.relationships)
|
|
190
|
+
? (slide.relationships as Record<string, unknown>[]).filter(
|
|
191
|
+
(relationship) => {
|
|
192
|
+
const from = relationship.from as Record<string, unknown> | undefined;
|
|
193
|
+
const to = relationship.to as Record<string, unknown> | undefined;
|
|
194
|
+
return (
|
|
195
|
+
!removedIds.has(String(from?.nodeId)) &&
|
|
196
|
+
!removedIds.has(String(to?.nodeId))
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
: slide.relationships,
|
|
201
|
+
};
|
|
202
|
+
})
|
|
203
|
+
: input.slides;
|
|
204
|
+
return { ...input, schema: compositionSchema, slides };
|
|
205
|
+
}
|
|
206
|
+
function migrateV7Document(input: Record<string, unknown>) {
|
|
207
|
+
const slides = Array.isArray(input.slides)
|
|
208
|
+
? input.slides.map((value: unknown) => {
|
|
209
|
+
if (!value || typeof value !== "object") return value;
|
|
210
|
+
const slide = value as Record<string, unknown>;
|
|
211
|
+
const components = Array.isArray(slide.components)
|
|
212
|
+
? slide.components.map((value: unknown) => {
|
|
213
|
+
if (!value || typeof value !== "object") return value;
|
|
214
|
+
const component = value as Record<string, unknown>;
|
|
215
|
+
const appearance =
|
|
216
|
+
component.appearance && typeof component.appearance === "object"
|
|
217
|
+
? (component.appearance as Record<string, unknown>)
|
|
218
|
+
: {};
|
|
219
|
+
if (component.kind !== "visual" || appearance.template !== "image")
|
|
220
|
+
return component;
|
|
221
|
+
return {
|
|
222
|
+
...component,
|
|
223
|
+
kind: "image",
|
|
224
|
+
appearance: {
|
|
225
|
+
...(appearance.border ? { border: appearance.border } : {}),
|
|
226
|
+
fit: "contain",
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
})
|
|
230
|
+
: slide.components;
|
|
231
|
+
return { ...slide, components };
|
|
232
|
+
})
|
|
233
|
+
: input.slides;
|
|
234
|
+
return { ...input, schema: compositionSchema, slides };
|
|
235
|
+
}
|
|
236
|
+
function migrateV10Document(input: Record<string, unknown>) {
|
|
237
|
+
const slides = Array.isArray(input.slides)
|
|
238
|
+
? input.slides.map((value: unknown) => {
|
|
239
|
+
if (!value || typeof value !== "object") return value;
|
|
240
|
+
const slide = value as Record<string, unknown>;
|
|
241
|
+
const components = Array.isArray(slide.components)
|
|
242
|
+
? slide.components.map((value: unknown) => {
|
|
243
|
+
if (!value || typeof value !== "object") return value;
|
|
244
|
+
const component = value as Record<string, unknown>;
|
|
245
|
+
const appearance =
|
|
246
|
+
component.appearance && typeof component.appearance === "object"
|
|
247
|
+
? (component.appearance as Record<string, unknown>)
|
|
248
|
+
: {};
|
|
249
|
+
if (component.kind === "headline")
|
|
250
|
+
return {
|
|
251
|
+
...component,
|
|
252
|
+
kind: "text-block",
|
|
253
|
+
appearance: {
|
|
254
|
+
...appearance,
|
|
255
|
+
role: "title",
|
|
256
|
+
layout: "single",
|
|
257
|
+
orientation: "horizontal",
|
|
258
|
+
purpose: "narrative",
|
|
259
|
+
treatment: "plain",
|
|
260
|
+
logicalOrder: "none",
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
if (component.kind === "footnote")
|
|
264
|
+
return {
|
|
265
|
+
...component,
|
|
266
|
+
kind: "text-block",
|
|
267
|
+
appearance: {
|
|
268
|
+
...appearance,
|
|
269
|
+
role: "footnote",
|
|
270
|
+
layout: "single",
|
|
271
|
+
orientation: "horizontal",
|
|
272
|
+
purpose: "narrative",
|
|
273
|
+
treatment: "plain",
|
|
274
|
+
logicalOrder: "none",
|
|
275
|
+
titleStyle: "plain",
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
if (component.kind === "text-block")
|
|
279
|
+
return {
|
|
280
|
+
...component,
|
|
281
|
+
appearance: { role: "body", ...appearance },
|
|
282
|
+
};
|
|
283
|
+
if (component.kind === "visual")
|
|
284
|
+
return { ...component, kind: "chart" };
|
|
285
|
+
if (component.kind === "diagram") {
|
|
286
|
+
const { template, ...rest } = appearance;
|
|
287
|
+
return {
|
|
288
|
+
...component,
|
|
289
|
+
appearance: {
|
|
290
|
+
...rest,
|
|
291
|
+
layout: template ?? appearance.layout ?? "linear",
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (component.kind === "icon" || component.kind === "shape") {
|
|
296
|
+
const slotIds = Array.isArray(component.slotIds)
|
|
297
|
+
? component.slotIds.map(String)
|
|
298
|
+
: [];
|
|
299
|
+
const slotId = slotIds[0] ?? `${String(component.id)}-content`;
|
|
300
|
+
const primitive = component.kind === "icon"
|
|
301
|
+
? {
|
|
302
|
+
kind: "icon" as const,
|
|
303
|
+
...(typeof component.intent === "string" && component.intent.trim()
|
|
304
|
+
? { name: component.intent.trim() }
|
|
305
|
+
: {}),
|
|
306
|
+
...(appearance.style ? { style: appearance.style } : {}),
|
|
307
|
+
...(appearance.color ? { color: appearance.color } : {}),
|
|
308
|
+
}
|
|
309
|
+
: {
|
|
310
|
+
kind: "shape" as const,
|
|
311
|
+
shape: appearance.shape ?? "rectangle",
|
|
312
|
+
...(appearance.color ? { color: appearance.color } : {}),
|
|
313
|
+
...(appearance.fill ? { fill: appearance.fill } : {}),
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
...component,
|
|
317
|
+
kind: "diagram",
|
|
318
|
+
slotIds: slotIds.length ? slotIds : [slotId],
|
|
319
|
+
appearance: {
|
|
320
|
+
type: "system",
|
|
321
|
+
layout: "hub-and-spoke",
|
|
322
|
+
border: appearance.border ?? "none",
|
|
323
|
+
colorScheme: "accent-with-muted-context",
|
|
324
|
+
emphasis: "primary",
|
|
325
|
+
density: "sparse",
|
|
326
|
+
},
|
|
327
|
+
topology: {
|
|
328
|
+
kind: "explicit",
|
|
329
|
+
nodes: (slotIds.length ? slotIds : [slotId]).map(
|
|
330
|
+
(currentSlotId, index) => ({
|
|
331
|
+
id: index
|
|
332
|
+
? `${String(component.id)}-slot-${index + 1}`
|
|
333
|
+
: String(component.id),
|
|
334
|
+
slotId: currentSlotId,
|
|
335
|
+
...(index ? { visible: false } : { primitive }),
|
|
336
|
+
...(component.preferredRect && typeof component.preferredRect === "object"
|
|
337
|
+
? { preferredRect: component.preferredRect }
|
|
338
|
+
: {}),
|
|
339
|
+
}),
|
|
340
|
+
),
|
|
341
|
+
edges: [],
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return component;
|
|
346
|
+
})
|
|
347
|
+
: slide.components;
|
|
348
|
+
const contentSlots = Array.isArray(slide.contentSlots)
|
|
349
|
+
? slide.contentSlots.map((value: unknown) => {
|
|
350
|
+
if (!value || typeof value !== "object") return value;
|
|
351
|
+
const slot = value as Record<string, unknown>;
|
|
352
|
+
return slot.role === "icon" || slot.role === "shape"
|
|
353
|
+
? { ...slot, role: "entity" }
|
|
354
|
+
: slot;
|
|
355
|
+
})
|
|
356
|
+
: slide.contentSlots;
|
|
357
|
+
return { ...slide, components, contentSlots };
|
|
358
|
+
})
|
|
359
|
+
: input.slides;
|
|
360
|
+
return { ...input, schema: "konpeki-composition/v11", slides };
|
|
361
|
+
}
|
|
362
|
+
function migrateV11Document(input: Record<string, unknown>) {
|
|
363
|
+
const slides = Array.isArray(input.slides)
|
|
364
|
+
? input.slides.map((value: unknown) => {
|
|
365
|
+
if (!value || typeof value !== "object") return value;
|
|
366
|
+
const slide = value as Record<string, unknown>;
|
|
367
|
+
const components = Array.isArray(slide.components)
|
|
368
|
+
? slide.components.map((value: unknown) => {
|
|
369
|
+
if (!value || typeof value !== "object") return value;
|
|
370
|
+
const component = value as Record<string, unknown>;
|
|
371
|
+
if (component.kind !== "diagram") return component;
|
|
372
|
+
const appearance =
|
|
373
|
+
component.appearance && typeof component.appearance === "object"
|
|
374
|
+
? (component.appearance as Record<string, unknown>)
|
|
375
|
+
: {};
|
|
376
|
+
const migratedType = migrateLegacyDiagramType(
|
|
377
|
+
appearance.type,
|
|
378
|
+
appearance.layout,
|
|
379
|
+
);
|
|
380
|
+
const { layout, ...rest } = appearance;
|
|
381
|
+
return {
|
|
382
|
+
...component,
|
|
383
|
+
appearance: {
|
|
384
|
+
...rest,
|
|
385
|
+
type: migratedType ?? null,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
})
|
|
389
|
+
: slide.components;
|
|
390
|
+
return { ...slide, components };
|
|
391
|
+
})
|
|
392
|
+
: input.slides;
|
|
393
|
+
return { ...input, schema: compositionSchema, slides };
|
|
394
|
+
}
|
|
395
|
+
function migrateInterimV12Sankey(input: unknown) {
|
|
396
|
+
if (!input || typeof input !== "object") return input;
|
|
397
|
+
const document = input as Record<string, unknown>;
|
|
398
|
+
if (document.schema !== compositionSchema || !Array.isArray(document.slides))
|
|
399
|
+
return input;
|
|
400
|
+
const slides = document.slides.map((value: unknown) => {
|
|
401
|
+
if (!value || typeof value !== "object") return value;
|
|
402
|
+
const slide = value as Record<string, unknown>;
|
|
403
|
+
if (!Array.isArray(slide.components)) return value;
|
|
404
|
+
const components = slide.components.map((componentValue: unknown) => {
|
|
405
|
+
if (!componentValue || typeof componentValue !== "object") return componentValue;
|
|
406
|
+
const component = componentValue as Record<string, unknown>;
|
|
407
|
+
const appearance = component.appearance && typeof component.appearance === "object"
|
|
408
|
+
? component.appearance as Record<string, unknown>
|
|
409
|
+
: {};
|
|
410
|
+
if (component.kind !== "diagram" || appearance.type !== "sankey")
|
|
411
|
+
return componentValue;
|
|
412
|
+
const { type: _type, ...sharedAppearance } = appearance;
|
|
413
|
+
return {
|
|
414
|
+
...component,
|
|
415
|
+
kind: "chart",
|
|
416
|
+
appearance: { ...sharedAppearance, template: "sankey" },
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
return { ...slide, components };
|
|
420
|
+
});
|
|
421
|
+
return { ...document, slides };
|
|
422
|
+
}
|
|
423
|
+
function preserveLegacyVisualizationChoices(input: unknown) {
|
|
424
|
+
if (!input || typeof input !== "object") return input;
|
|
425
|
+
const document = input as Record<string, unknown>;
|
|
426
|
+
if (!Array.isArray(document.slides)) return input;
|
|
427
|
+
return { ...document, slides: document.slides.map((value: unknown) => {
|
|
428
|
+
if (!value || typeof value !== "object") return value;
|
|
429
|
+
const slide = value as Record<string, unknown>;
|
|
430
|
+
if (!Array.isArray(slide.components)) return value;
|
|
431
|
+
return { ...slide, components: slide.components.map((value: unknown) => {
|
|
432
|
+
if (!value || typeof value !== "object") return value;
|
|
433
|
+
const component = value as Record<string, unknown>;
|
|
434
|
+
if ((component.kind !== "diagram" && component.kind !== "chart") || !component.appearance || typeof component.appearance !== "object") return value;
|
|
435
|
+
return { ...component, appearance: { ...component.appearance, selection: "explicit" } };
|
|
436
|
+
}) };
|
|
437
|
+
}) };
|
|
438
|
+
}
|
|
439
|
+
export function validateComposition(input: unknown): ValidationResult {
|
|
440
|
+
const legacy = Boolean(
|
|
441
|
+
input &&
|
|
442
|
+
typeof input === "object" &&
|
|
443
|
+
"schema" in input &&
|
|
444
|
+
legacyCompositionSchemas.includes(
|
|
445
|
+
input.schema as (typeof legacyCompositionSchemas)[number],
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
const legacyInput = input as Record<string, unknown>;
|
|
449
|
+
const migrated = legacy
|
|
450
|
+
? ["konpeki-composition/v12", "konpeki-composition/v13", "konpeki-composition/v14", "konpeki-composition/v15", "konpeki-composition/v16", "konpeki-composition/v17"].includes(
|
|
451
|
+
String(legacyInput.schema),
|
|
452
|
+
)
|
|
453
|
+
? { ...legacyInput, schema: compositionSchema }
|
|
454
|
+
: migrateV11Document(
|
|
455
|
+
legacyInput.schema === "konpeki-composition/v11"
|
|
456
|
+
? legacyInput
|
|
457
|
+
: migrateV10Document(
|
|
458
|
+
["konpeki-composition/v6", "konpeki-composition/v7", "konpeki-composition/v8", "konpeki-composition/v9", "konpeki-composition/v10"].includes(
|
|
459
|
+
String(legacyInput.schema),
|
|
460
|
+
)
|
|
461
|
+
? migrateV7Document(legacyInput)
|
|
462
|
+
: migrateV7Document(
|
|
463
|
+
migrateV5Document(
|
|
464
|
+
legacyInput.schema === "konpeki-composition/v5"
|
|
465
|
+
? legacyInput
|
|
466
|
+
: migratePreV5Document(legacyInput),
|
|
467
|
+
),
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
: input;
|
|
472
|
+
const normalized = migrateInterimV12Sankey(migrated);
|
|
473
|
+
const candidate = legacy ? preserveLegacyVisualizationChoices(normalized) : normalized;
|
|
474
|
+
if (!structural(candidate))
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
issues: (structural.errors ?? []).map((error) => ({
|
|
478
|
+
path: error.instancePath,
|
|
479
|
+
message: error.message ?? "Invalid value",
|
|
480
|
+
})),
|
|
481
|
+
};
|
|
482
|
+
const issues: ValidationIssue[] = [];
|
|
483
|
+
const fail = (path: string, message: string) =>
|
|
484
|
+
issues.push({ path, message });
|
|
485
|
+
const unique = (ids: string[], path: string) => {
|
|
486
|
+
if (new Set(ids).size !== ids.length) fail(path, "IDs must be unique");
|
|
487
|
+
};
|
|
488
|
+
unique(
|
|
489
|
+
candidate.slides.map((slide) => slide.id),
|
|
490
|
+
"/slides",
|
|
491
|
+
);
|
|
492
|
+
candidate.slides.forEach((slide, slideIndex) => {
|
|
493
|
+
const base = `/slides/${slideIndex}`;
|
|
494
|
+
const padding = slide.innerPadding;
|
|
495
|
+
if (padding && (padding.left + padding.right >= slide.canvas.width || padding.top + padding.bottom >= slide.canvas.height))
|
|
496
|
+
fail(`${base}/innerPadding`, "Padding leaves no page content area");
|
|
497
|
+
const components = new Map(slide.components.map((item) => [item.id, item]));
|
|
498
|
+
const slots = new Set(slide.contentSlots.map((item) => item.id));
|
|
499
|
+
const groups = new Map(slide.groups.map((item) => [item.id, item]));
|
|
500
|
+
unique([...components.keys(), ...groups.keys()], `${base}/groups`);
|
|
501
|
+
unique(
|
|
502
|
+
slide.components.map((item) => item.id),
|
|
503
|
+
`${base}/components`,
|
|
504
|
+
);
|
|
505
|
+
unique(
|
|
506
|
+
slide.contentSlots.map((item) => item.id),
|
|
507
|
+
`${base}/contentSlots`,
|
|
508
|
+
);
|
|
509
|
+
unique(
|
|
510
|
+
slide.groups.map((item) => item.id),
|
|
511
|
+
`${base}/groups`,
|
|
512
|
+
);
|
|
513
|
+
unique(
|
|
514
|
+
slide.relationships.map((item) => item.id),
|
|
515
|
+
`${base}/relationships`,
|
|
516
|
+
);
|
|
517
|
+
const assigned: string[] = [];
|
|
518
|
+
for (const component of slide.components) {
|
|
519
|
+
const path = `${base}/components/${component.id}`;
|
|
520
|
+
const r = component.preferredRect;
|
|
521
|
+
if (r.x + r.width > slide.canvas.width || r.y + r.height > slide.canvas.height)
|
|
522
|
+
fail(path, "Rectangle exceeds canvas");
|
|
523
|
+
assigned.push(...component.slotIds);
|
|
524
|
+
for (const id of component.slotIds)
|
|
525
|
+
if (!slots.has(id)) fail(path, `Unknown slot: ${id}`);
|
|
526
|
+
if (component.customVisual) {
|
|
527
|
+
if (component.customVisual.format === "svg") {
|
|
528
|
+
const source = component.customVisual.source;
|
|
529
|
+
if (
|
|
530
|
+
/<script\b|<foreignObject\b|<\?xml-stylesheet\b|<!DOCTYPE\b|\son[a-z]+\s*=|(?:href|src)\s*=\s*["'](?!data:|#)|url\(\s*["']?(?!data:|#)/i.test(
|
|
531
|
+
source,
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
fail(path, "Custom SVG must be self-contained and cannot include scripts, foreign objects, event handlers, document declarations, or external resources");
|
|
535
|
+
const sourceViewBox = source
|
|
536
|
+
.match(/<svg\b[^>]*\bviewBox\s*=\s*(["'])([^"']+)\1/i)?.[2]
|
|
537
|
+
?.trim()
|
|
538
|
+
.split(/[\s,]+/)
|
|
539
|
+
.map(Number);
|
|
540
|
+
const declaredViewBox = component.customVisual.viewBox;
|
|
541
|
+
if (
|
|
542
|
+
sourceViewBox?.length !== 4 ||
|
|
543
|
+
sourceViewBox.some((value) => !Number.isFinite(value)) ||
|
|
544
|
+
sourceViewBox[0] !== declaredViewBox.x ||
|
|
545
|
+
sourceViewBox[1] !== declaredViewBox.y ||
|
|
546
|
+
sourceViewBox[2] !== declaredViewBox.width ||
|
|
547
|
+
sourceViewBox[3] !== declaredViewBox.height
|
|
548
|
+
)
|
|
549
|
+
fail(path, "Custom SVG root viewBox must match the declared local viewport");
|
|
550
|
+
} else {
|
|
551
|
+
const known = new Set<string>();
|
|
552
|
+
const kinds = new Map(component.customVisual.elements.map((element) => [element.id, element.kind]));
|
|
553
|
+
for (const element of component.customVisual.elements) {
|
|
554
|
+
if (known.has(element.id)) fail(path, `Duplicate vector element ID: ${element.id}`);
|
|
555
|
+
if (element.parentId && !known.has(element.parentId))
|
|
556
|
+
fail(path, `Vector parent must precede its child: ${element.parentId}`);
|
|
557
|
+
const parentKind = element.parentId ? kinds.get(element.parentId) : undefined;
|
|
558
|
+
if (element.parentId && parentKind !== "g" &&
|
|
559
|
+
!(element.kind === "tspan" && (parentKind === "text" || parentKind === "tspan")))
|
|
560
|
+
fail(path, "Vector children require a group or a text/tspan parent for tspans");
|
|
561
|
+
if (element.kind === "tspan" && parentKind !== "text" && parentKind !== "tspan")
|
|
562
|
+
fail(path, "Vector tspans require a text or tspan parent");
|
|
563
|
+
if (element.text !== undefined && element.kind !== "text" && element.kind !== "tspan")
|
|
564
|
+
fail(path, "Only vector text and tspans may contain text");
|
|
565
|
+
known.add(element.id);
|
|
566
|
+
for (const [name, value] of Object.entries(element.attributes)) {
|
|
567
|
+
if (!validThemeBinding(name, value))
|
|
568
|
+
fail(path, `Invalid theme binding for vector attribute: ${name}`);
|
|
569
|
+
if (!vectorAttributeNames.has(name))
|
|
570
|
+
fail(path, `Unsupported vector attribute: ${name}`);
|
|
571
|
+
if (typeof value === "string" && /url\s*\(|javascript:|data:/i.test(value))
|
|
572
|
+
fail(path, `Unsafe vector attribute value: ${name}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (component.kind === "chart" && component.topology && component.appearance.template !== "sankey")
|
|
578
|
+
fail(path, "Only Sankey charts may define topology");
|
|
579
|
+
if ((component.kind === "diagram" || component.kind === "chart") && component.topology) {
|
|
580
|
+
const { nodes, edges } = component.topology;
|
|
581
|
+
unique(
|
|
582
|
+
nodes.map((node) => node.id),
|
|
583
|
+
path,
|
|
584
|
+
);
|
|
585
|
+
unique(
|
|
586
|
+
nodes.map((node) => node.slotId),
|
|
587
|
+
path,
|
|
588
|
+
);
|
|
589
|
+
unique(
|
|
590
|
+
edges.map((edge) =>
|
|
591
|
+
JSON.stringify([edge.from, edge.to, edge.label ?? ""]),
|
|
592
|
+
),
|
|
593
|
+
path,
|
|
594
|
+
);
|
|
595
|
+
const known = new Set(nodes.map((node) => node.id));
|
|
596
|
+
if (nodes.length !== component.slotIds.length)
|
|
597
|
+
fail(path, "Topology must represent every component slot");
|
|
598
|
+
for (const node of nodes) {
|
|
599
|
+
if (!component.slotIds.includes(node.slotId))
|
|
600
|
+
fail(path, "Topology slot must belong to its component");
|
|
601
|
+
const nodeRect = node.preferredRect;
|
|
602
|
+
if (
|
|
603
|
+
nodeRect &&
|
|
604
|
+
(nodeRect.x < r.x ||
|
|
605
|
+
nodeRect.y < r.y ||
|
|
606
|
+
nodeRect.x + nodeRect.width > r.x + r.width ||
|
|
607
|
+
nodeRect.y + nodeRect.height > r.y + r.height)
|
|
608
|
+
)
|
|
609
|
+
fail(path, "Topology node rectangle must stay within its component");
|
|
610
|
+
}
|
|
611
|
+
for (const edge of edges) {
|
|
612
|
+
if (!known.has(edge.from) || !known.has(edge.to))
|
|
613
|
+
fail(path, "Unknown topology endpoint");
|
|
614
|
+
if (edge.from === edge.to)
|
|
615
|
+
fail(path, "Topology self-edges are not supported");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const id of slots)
|
|
620
|
+
if (!assigned.includes(id))
|
|
621
|
+
fail(`${base}/contentSlots`, `Unassigned content slot: ${id}`);
|
|
622
|
+
const grouped: string[] = [];
|
|
623
|
+
for (const group of slide.groups)
|
|
624
|
+
for (const id of group.childIds) {
|
|
625
|
+
grouped.push(id);
|
|
626
|
+
if (!components.has(id))
|
|
627
|
+
fail(`${base}/groups`, "Groups may contain only components");
|
|
628
|
+
}
|
|
629
|
+
unique(grouped, `${base}/groups`);
|
|
630
|
+
const reading = slide.readingOrder.flatMap((entry) => {
|
|
631
|
+
if (entry.kind === "component") {
|
|
632
|
+
if (!components.has(entry.id))
|
|
633
|
+
fail(`${base}/readingOrder`, "Unknown component");
|
|
634
|
+
return [entry.id];
|
|
635
|
+
}
|
|
636
|
+
const group = groups.get(entry.id);
|
|
637
|
+
if (!group) fail(`${base}/readingOrder`, "Unknown group");
|
|
638
|
+
return group?.childIds ?? [];
|
|
639
|
+
});
|
|
640
|
+
for (const [path, order] of [
|
|
641
|
+
["readingOrder", reading],
|
|
642
|
+
["paintOrder", slide.paintOrder],
|
|
643
|
+
] as const) {
|
|
644
|
+
if (
|
|
645
|
+
order.length !== components.size ||
|
|
646
|
+
new Set(order).size !== components.size ||
|
|
647
|
+
order.some((id) => !components.has(id))
|
|
648
|
+
)
|
|
649
|
+
fail(`${base}/${path}`, "Must contain every component exactly once");
|
|
650
|
+
}
|
|
651
|
+
for (const slot of slide.contentSlots)
|
|
652
|
+
if ("targets" in slot)
|
|
653
|
+
for (const id of slot.targets) {
|
|
654
|
+
if (!components.has(id) && !slots.has(id))
|
|
655
|
+
fail(`${base}/contentSlots`, "Unknown qualification/source target");
|
|
656
|
+
}
|
|
657
|
+
for (const relationship of slide.relationships)
|
|
658
|
+
for (const endpoint of [relationship.from, relationship.to]) {
|
|
659
|
+
const component = components.get(endpoint.nodeId);
|
|
660
|
+
if (!component) fail(`${base}/relationships`, "Unknown relationship node");
|
|
661
|
+
if (endpoint.slotId && !component?.slotIds.includes(endpoint.slotId))
|
|
662
|
+
fail(
|
|
663
|
+
`${base}/relationships`,
|
|
664
|
+
"Relationship slot must belong to its endpoint",
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
return issues.length
|
|
669
|
+
? { ok: false, issues }
|
|
670
|
+
: { ok: true, document: legacy ? { ...candidate, slides: candidate.slides.map((slide) => ({
|
|
671
|
+
...slide, components: slide.components.map((component) => component.kind === "text-block" && !component.customVisual && component.content === undefined
|
|
672
|
+
? { ...component, content: component.intent ?? "" } : component),
|
|
673
|
+
})) } : candidate };
|
|
674
|
+
}
|
|
675
|
+
export function assertComposition(input: unknown): CompositionDocument {
|
|
676
|
+
const result = validateComposition(input);
|
|
677
|
+
if (!result.ok)
|
|
678
|
+
throw new Error(
|
|
679
|
+
result.issues
|
|
680
|
+
.map((issue) => `${issue.path}: ${issue.message}`)
|
|
681
|
+
.join("\n"),
|
|
682
|
+
);
|
|
683
|
+
return result.document;
|
|
684
|
+
}
|