capture-flow 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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,917 @@
1
+ /**
2
+ * Runtime validation of untrusted JSON (imported files, gallery downloads,
3
+ * files on disk). Returns cleaned, fully-typed objects or throws
4
+ * ValidationError naming the offending path. Owned by the model/store work package.
5
+ *
6
+ * Policy, applied the same way to every field:
7
+ * - A value of the wrong type throws (`slides[2].layouts.iphone.elements[0].width`).
8
+ * - A missing (or null) required value throws, unless the field has a natural
9
+ * default (rotation 0, opacity 1, ...), which is then used.
10
+ * - Numbers: NaN counts as missing, Infinity and out-of-range values are clamped.
11
+ * - Strings are length-limited (names 200, texts 2 000). Ids, colours, codes
12
+ * and enums must match their rules; slide/element ids that do not (or that
13
+ * repeat) are regenerated, short colours (#fff) are expanded.
14
+ * - Unknown keys are dropped; record entries with invalid keys (unknown
15
+ * families, malformed language codes, `__proto__`) are dropped.
16
+ * - Arrays are truncated to their limits, duplicate entries removed.
17
+ * - References to assets missing from `assets` are dropped: the image element
18
+ * is removed, an image fill falls back, the screenshot is unassigned.
19
+ * - Empty families / languages / slides are repaired.
20
+ */
21
+ import { adaptLayout } from './adapt';
22
+ import { FAMILIES, sizeById } from './catalog';
23
+ import { DEFAULT_FAMILY, DEFAULT_FONT, DEFAULT_LANGUAGE, DEFAULT_PROJECT_NAME, defaultBackground, defaultExportSettings, defaultTheme, newSlide } from './defaults';
24
+ import { newId } from './ids';
25
+ import {
26
+ LIMITS,
27
+ RANGES,
28
+ clampToRange,
29
+ cleanFontName,
30
+ cleanName,
31
+ cleanText,
32
+ isAssetId,
33
+ isFontFile,
34
+ isFamilyId,
35
+ isId,
36
+ isLangCode,
37
+ isSafeKey,
38
+ isSafeRelPath,
39
+ isTemplateId,
40
+ isWindowsDeviceName,
41
+ normalizeColor,
42
+ type Range,
43
+ } from './rules';
44
+ import type {
45
+ AssetInfo,
46
+ Background,
47
+ DeviceElement,
48
+ ProjectFont,
49
+ DeviceFrameId,
50
+ ElementKind,
51
+ ExportSettings,
52
+ ExportStructure,
53
+ FamilyId,
54
+ Fill,
55
+ FrameFinish,
56
+ GalleryEntry,
57
+ GalleryIndex,
58
+ GradientStop,
59
+ ImageElement,
60
+ LangCode,
61
+ LanguageTypography,
62
+ MeshPoint,
63
+ Pattern,
64
+ PatternKind,
65
+ Project,
66
+ Shadow,
67
+ ShapeElement,
68
+ ShapeKind,
69
+ ShotMap,
70
+ Slide,
71
+ SlideElement,
72
+ SlideLayout,
73
+ Template,
74
+ TemplateCategory,
75
+ TemplateSlide,
76
+ TextElement,
77
+ TextStyle,
78
+ Theme,
79
+ } from './types';
80
+
81
+ export class ValidationError extends Error {
82
+ readonly path: string;
83
+ constructor(path: string, message: string) {
84
+ super(`${path}: ${message}`);
85
+ this.name = 'ValidationError';
86
+ this.path = path;
87
+ }
88
+ }
89
+
90
+ /* Allowed values ----------------------------------------------------- */
91
+
92
+ export const ELEMENT_KINDS: readonly ElementKind[] = ['device', 'text', 'shape', 'image'];
93
+ export const DEVICE_FRAMES: readonly DeviceFrameId[] = ['iphone', 'ipad', 'android-phone', 'android-tablet', 'macbook', 'watch', 'tv', 'vision', 'browser', 'none'];
94
+ export const FRAME_FINISHES: readonly FrameFinish[] = ['metal', 'matte', 'clay'];
95
+ export const SHAPE_KINDS: readonly ShapeKind[] = ['circle', 'rect', 'blob', 'ring'];
96
+ export const PATTERN_KINDS: readonly PatternKind[] = ['dots', 'grid', 'diagonal', 'noise', 'waves', 'rings'];
97
+ export const FILL_TYPES: readonly Fill['type'][] = ['solid', 'linear', 'radial', 'mesh', 'image'];
98
+ export const IMAGE_FITS: readonly ('cover' | 'contain' | 'tile')[] = ['cover', 'contain', 'tile'];
99
+ export const TEMPLATE_CATEGORY_LIST: readonly TemplateCategory[] = ['minimal', 'bold', 'gradient', 'dark', 'playful', 'panorama', 'elegant'];
100
+ export const EXPORT_STRUCTURES: readonly ExportStructure[] = ['stores', 'fastlane', 'flat'];
101
+
102
+ const BEZELS = ['black', 'match'] as const;
103
+ const VALIGNS = ['top', 'middle', 'bottom'] as const;
104
+ const ALIGNS = ['start', 'center', 'end'] as const;
105
+ const TRANSFORMS = ['none', 'uppercase'] as const;
106
+ const HIGHLIGHT_MODES = ['color', 'marker'] as const;
107
+ const FORMATS = ['png', 'jpeg'] as const;
108
+
109
+ /** The only persisted format version so far. */
110
+ export const CURRENT_VERSION = 1;
111
+
112
+ /* Primitives --------------------------------------------------------- */
113
+
114
+ type Json = Record<string, unknown>;
115
+
116
+ /** Asset ids that exist, for reference checks. */
117
+ export interface ValidationContext {
118
+ assets: ReadonlySet<string>;
119
+ }
120
+
121
+ /** The reference-check context for a project's or template's asset map. */
122
+ export function contextFor(assets: Record<string, AssetInfo> | null | undefined): ValidationContext {
123
+ return { assets: new Set(assets ? Object.keys(assets) : []) };
124
+ }
125
+
126
+ function fail(path: string, message: string): never {
127
+ throw new ValidationError(path || '$', message);
128
+ }
129
+
130
+ /** JSON path segment: `a.b`, `a[3]`, `a["key with.dots"]`. */
131
+ function at(path: string, key: string | number): string {
132
+ if (typeof key === 'number') return `${path}[${key}]`;
133
+ if (/^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(key)) return path ? `${path}.${key}` : key;
134
+ const shown = key.length > 80 ? `${key.slice(0, 80)}…` : key;
135
+ return `${path}[${JSON.stringify(shown)}]`;
136
+ }
137
+
138
+ const hasOwn = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
139
+
140
+ /** Own property only: inherited values (prototype tricks) never count. */
141
+ function get(o: Json, key: string): unknown {
142
+ return hasOwn(o, key) ? o[key] : undefined;
143
+ }
144
+
145
+ const isMissing = (v: unknown) => v === undefined || v === null;
146
+
147
+ function isPlainObject(v: unknown): v is Json {
148
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
149
+ }
150
+
151
+ function asObject(v: unknown, path: string): Json {
152
+ if (!isPlainObject(v)) fail(path, 'must be an object');
153
+ return v;
154
+ }
155
+
156
+ function objectField(o: Json, key: string, path: string): Json {
157
+ const p = at(path, key);
158
+ const v = get(o, key);
159
+ if (isMissing(v)) fail(p, 'is required');
160
+ return asObject(v, p);
161
+ }
162
+
163
+ function arrayField(o: Json, key: string, path: string, max: number, required = false): unknown[] {
164
+ const p = at(path, key);
165
+ const v = get(o, key);
166
+ if (isMissing(v)) {
167
+ if (required) fail(p, 'is required');
168
+ return [];
169
+ }
170
+ if (!Array.isArray(v)) fail(p, 'must be an array');
171
+ return v.length > max ? v.slice(0, max) : v;
172
+ }
173
+
174
+ function numberField(o: Json, key: string, path: string, range: Range, fallback?: number): number {
175
+ const p = at(path, key);
176
+ const v = get(o, key);
177
+ if (isMissing(v) || (typeof v === 'number' && Number.isNaN(v))) {
178
+ if (fallback === undefined) fail(p, 'is required');
179
+ return fallback;
180
+ }
181
+ if (typeof v !== 'number') fail(p, 'must be a number');
182
+ return clampToRange(v, range);
183
+ }
184
+
185
+ function boolField(o: Json, key: string, path: string, fallback: boolean): boolean {
186
+ const v = get(o, key);
187
+ if (isMissing(v)) return fallback;
188
+ if (typeof v !== 'boolean') fail(at(path, key), 'must be true or false');
189
+ return v;
190
+ }
191
+
192
+ function rawString(o: Json, key: string, path: string, fallback?: string): string {
193
+ const p = at(path, key);
194
+ const v = get(o, key);
195
+ if (isMissing(v)) {
196
+ if (fallback === undefined) fail(p, 'is required');
197
+ return fallback;
198
+ }
199
+ if (typeof v !== 'string') fail(p, 'must be a string');
200
+ return v;
201
+ }
202
+
203
+ function nameField(o: Json, key: string, path: string, fallback: string, max: number = LIMITS.name): string {
204
+ return cleanName(rawString(o, key, path, fallback), max);
205
+ }
206
+
207
+ function enumField<T extends string>(o: Json, key: string, path: string, allowed: readonly T[], fallback?: T): T {
208
+ const p = at(path, key);
209
+ const v = get(o, key);
210
+ if (isMissing(v)) {
211
+ if (fallback === undefined) fail(p, 'is required');
212
+ return fallback;
213
+ }
214
+ if (typeof v !== 'string') fail(p, 'must be a string');
215
+ if (!(allowed as readonly string[]).includes(v)) fail(p, `must be one of ${allowed.join(', ')}`);
216
+ return v as T;
217
+ }
218
+
219
+ function colorValue(v: unknown, path: string): string {
220
+ if (typeof v !== 'string') fail(path, 'must be a colour string');
221
+ const c = normalizeColor(v);
222
+ if (!c) fail(path, 'must be a hex colour like #RRGGBB or #RRGGBBAA');
223
+ return c;
224
+ }
225
+
226
+ function colorField(o: Json, key: string, path: string, fallback?: string): string {
227
+ const v = get(o, key);
228
+ if (isMissing(v)) {
229
+ if (fallback === undefined) fail(at(path, key), 'is required');
230
+ return fallback;
231
+ }
232
+ return colorValue(v, at(path, key));
233
+ }
234
+
235
+ /** Own keys of a record, at most `max`, skipping keys `accept` rejects. */
236
+ function recordKeys(o: Json, max: number, accept: (k: string) => boolean): string[] {
237
+ const out: string[] = [];
238
+ for (const k of Object.keys(o)) {
239
+ if (out.length >= max) break;
240
+ if (accept(k)) out.push(k);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /* Paint -------------------------------------------------------------- */
246
+
247
+ /** A shadow, or null when absent. */
248
+ export function cleanShadow(v: unknown, path: string): Shadow | null {
249
+ if (isMissing(v)) return null;
250
+ const o = asObject(v, path);
251
+ return {
252
+ color: colorField(o, 'color', path),
253
+ blur: numberField(o, 'blur', path, RANGES.blur, 0.04),
254
+ x: numberField(o, 'x', path, RANGES.shadowOffset, 0),
255
+ y: numberField(o, 'y', path, RANGES.shadowOffset, 0.02),
256
+ };
257
+ }
258
+
259
+ function cleanStops(o: Json, path: string): GradientStop[] {
260
+ const raw = arrayField(o, 'stops', path, LIMITS.stops, true);
261
+ if (raw.length === 0) fail(at(path, 'stops'), 'needs at least one stop');
262
+ return raw.map((s, i) => {
263
+ const p = at(at(path, 'stops'), i);
264
+ const so = asObject(s, p);
265
+ return { offset: numberField(so, 'offset', p, RANGES.unit), color: colorField(so, 'color', p) };
266
+ });
267
+ }
268
+
269
+ /** null when it is an image fill whose asset is missing (callers fall back). */
270
+ export function cleanFill(v: unknown, path: string, ctx: ValidationContext): Fill | null {
271
+ const o = asObject(v, path);
272
+ const type = enumField(o, 'type', path, FILL_TYPES);
273
+ switch (type) {
274
+ case 'solid':
275
+ return { type, color: colorField(o, 'color', path) };
276
+ case 'linear':
277
+ return { type, angle: numberField(o, 'angle', path, RANGES.angle, 180), stops: cleanStops(o, path) };
278
+ case 'radial':
279
+ return {
280
+ type,
281
+ cx: numberField(o, 'cx', path, RANGES.gradientCenter, 0.5),
282
+ cy: numberField(o, 'cy', path, RANGES.gradientCenter, 0.5),
283
+ radius: numberField(o, 'radius', path, RANGES.gradientRadius, 0.75),
284
+ stops: cleanStops(o, path),
285
+ };
286
+ case 'mesh': {
287
+ const points: MeshPoint[] = arrayField(o, 'points', path, LIMITS.meshPoints).map((pt, i) => {
288
+ const p = at(at(path, 'points'), i);
289
+ const po = asObject(pt, p);
290
+ return {
291
+ x: numberField(po, 'x', p, RANGES.position),
292
+ y: numberField(po, 'y', p, RANGES.position),
293
+ color: colorField(po, 'color', p),
294
+ radius: numberField(po, 'radius', p, RANGES.gradientRadius, 0.5),
295
+ };
296
+ });
297
+ return { type, base: colorField(o, 'base', path), points };
298
+ }
299
+ case 'image': {
300
+ const assetId = rawString(o, 'assetId', path);
301
+ const tintRaw = get(o, 'tint');
302
+ const fill: Fill = {
303
+ type,
304
+ assetId,
305
+ fit: enumField(o, 'fit', path, IMAGE_FITS, 'cover'),
306
+ opacity: numberField(o, 'opacity', path, RANGES.unit, 1),
307
+ blur: numberField(o, 'blur', path, RANGES.imageBlur, 0),
308
+ tint: isMissing(tintRaw) ? null : colorValue(tintRaw, at(path, 'tint')),
309
+ };
310
+ return isAssetId(assetId) && ctx.assets.has(assetId) ? fill : null;
311
+ }
312
+ }
313
+ }
314
+
315
+ /** A background pattern, or null when absent. */
316
+ export function cleanPattern(v: unknown, path: string): Pattern | null {
317
+ if (isMissing(v)) return null;
318
+ const o = asObject(v, path);
319
+ return {
320
+ kind: enumField(o, 'kind', path, PATTERN_KINDS),
321
+ color: colorField(o, 'color', path),
322
+ opacity: numberField(o, 'opacity', path, RANGES.unit, 0.15),
323
+ scale: numberField(o, 'scale', path, RANGES.patternScale, 0.05),
324
+ };
325
+ }
326
+
327
+ /** null when the value is null, or when its image fill points at a missing asset. */
328
+ export function cleanBackground(v: unknown, path: string, ctx: ValidationContext): Background | null {
329
+ if (isMissing(v)) return null;
330
+ const o = asObject(v, path);
331
+ const fillRaw = get(o, 'fill');
332
+ if (isMissing(fillRaw)) fail(at(path, 'fill'), 'is required');
333
+ const fill = cleanFill(fillRaw, at(path, 'fill'), ctx);
334
+ const pattern = cleanPattern(get(o, 'pattern'), at(path, 'pattern'));
335
+ return fill ? { fill, pattern } : null;
336
+ }
337
+
338
+ /* Elements ----------------------------------------------------------- */
339
+
340
+ const DANGLING_SHAPE_FILL: Fill = { type: 'solid', color: '#808080' };
341
+
342
+ /** Text style; `fallbackFont` replaces a missing or unusable font name. */
343
+ export function cleanTextStyle(v: unknown, path: string, fallbackFont = DEFAULT_FONT): TextStyle {
344
+ const o = asObject(v, path);
345
+ const color = colorField(o, 'color', path);
346
+ return {
347
+ font: cleanFontName(rawString(o, 'font', path, fallbackFont), fallbackFont),
348
+ weight: numberField(o, 'weight', path, RANGES.fontWeight, 400),
349
+ size: numberField(o, 'size', path, RANGES.fontSize),
350
+ lineHeight: numberField(o, 'lineHeight', path, RANGES.lineHeight, 1.2),
351
+ letterSpacing: numberField(o, 'letterSpacing', path, RANGES.letterSpacing, 0),
352
+ color,
353
+ highlight: colorField(o, 'highlight', path, color),
354
+ highlightMode: enumField(o, 'highlightMode', path, HIGHLIGHT_MODES, 'color'),
355
+ align: enumField(o, 'align', path, ALIGNS, 'center'),
356
+ transform: enumField(o, 'transform', path, TRANSFORMS, 'none'),
357
+ maxLines: numberField(o, 'maxLines', path, RANGES.maxLines, 0),
358
+ shadow: cleanShadow(get(o, 'shadow'), at(path, 'shadow')),
359
+ };
360
+ }
361
+
362
+ function keyField(o: Json, key: string, path: string, fallback?: string): string {
363
+ const v = rawString(o, key, path, fallback);
364
+ if (!isSafeKey(v)) fail(at(path, key), 'must be 1-40 letters, digits, ".", "_" or "-", and not a reserved name such as "toString"');
365
+ return v;
366
+ }
367
+
368
+ /**
369
+ * Validates one layout element. `ids` collects the ids already used in the
370
+ * layout: a missing, malformed or repeated id is replaced by a fresh one.
371
+ * Returns null for an image element whose asset is missing.
372
+ */
373
+ export function cleanElement(v: unknown, path: string, ctx: ValidationContext, ids: Set<string> = new Set()): SlideElement | null {
374
+ const o = asObject(v, path);
375
+ const kind = enumField(o, 'kind', path, ELEMENT_KINDS);
376
+ const rawId = get(o, 'id');
377
+ const id = isId(rawId) && !ids.has(rawId) ? rawId : newId('e');
378
+
379
+ const base: { id: string; name?: string; x: number; y: number; rotation: number; opacity: number; hidden?: boolean; locked?: boolean } = {
380
+ id,
381
+ x: numberField(o, 'x', path, RANGES.position),
382
+ y: numberField(o, 'y', path, RANGES.position),
383
+ rotation: numberField(o, 'rotation', path, RANGES.rotation, 0),
384
+ opacity: numberField(o, 'opacity', path, RANGES.unit, 1),
385
+ };
386
+ const name = get(o, 'name');
387
+ if (!isMissing(name)) {
388
+ if (typeof name !== 'string') fail(at(path, 'name'), 'must be a string');
389
+ const clean = cleanName(name);
390
+ if (clean) base.name = clean;
391
+ }
392
+ if (boolField(o, 'hidden', path, false)) base.hidden = true;
393
+ if (boolField(o, 'locked', path, false)) base.locked = true;
394
+
395
+ let el: SlideElement | null = null;
396
+ switch (kind) {
397
+ case 'device': {
398
+ const sp = at(path, 'style');
399
+ const so = objectField(o, 'style', path);
400
+ const device: DeviceElement = {
401
+ ...base,
402
+ kind,
403
+ frame: enumField(o, 'frame', path, DEVICE_FRAMES),
404
+ style: {
405
+ color: colorField(so, 'color', sp),
406
+ finish: enumField(so, 'finish', sp, FRAME_FINISHES, 'metal'),
407
+ bezel: enumField(so, 'bezel', sp, BEZELS, 'black'),
408
+ },
409
+ width: numberField(o, 'width', path, RANGES.deviceWidth),
410
+ slot: keyField(o, 'slot', path, 'main'),
411
+ cornerRadius: numberField(o, 'cornerRadius', path, RANGES.cornerRadius, 0),
412
+ shadow: cleanShadow(get(o, 'shadow'), at(path, 'shadow')),
413
+ };
414
+ el = device;
415
+ break;
416
+ }
417
+ case 'text': {
418
+ const styleRaw = get(o, 'style');
419
+ if (isMissing(styleRaw)) fail(at(path, 'style'), 'is required');
420
+ const text: TextElement = {
421
+ ...base,
422
+ kind,
423
+ key: keyField(o, 'key', path),
424
+ width: numberField(o, 'width', path, RANGES.textWidth),
425
+ valign: enumField(o, 'valign', path, VALIGNS, 'top'),
426
+ style: cleanTextStyle(styleRaw, at(path, 'style')),
427
+ };
428
+ el = text;
429
+ break;
430
+ }
431
+ case 'shape': {
432
+ const fillRaw = get(o, 'fill');
433
+ if (isMissing(fillRaw)) fail(at(path, 'fill'), 'is required');
434
+ const shape: ShapeElement = {
435
+ ...base,
436
+ kind,
437
+ shape: enumField(o, 'shape', path, SHAPE_KINDS),
438
+ width: numberField(o, 'width', path, RANGES.shapeSize),
439
+ height: numberField(o, 'height', path, RANGES.shapeSize),
440
+ fill: cleanFill(fillRaw, at(path, 'fill'), ctx) ?? { ...DANGLING_SHAPE_FILL },
441
+ radius: numberField(o, 'radius', path, RANGES.shapeRadius, 0),
442
+ stroke: numberField(o, 'stroke', path, RANGES.stroke, 0),
443
+ blur: numberField(o, 'blur', path, RANGES.blur, 0),
444
+ seed: numberField(o, 'seed', path, RANGES.seed, 0),
445
+ };
446
+ el = shape;
447
+ break;
448
+ }
449
+ case 'image': {
450
+ const assetId = rawString(o, 'assetId', path);
451
+ const image: ImageElement = {
452
+ ...base,
453
+ kind,
454
+ assetId,
455
+ width: numberField(o, 'width', path, RANGES.imageWidth),
456
+ cornerRadius: numberField(o, 'cornerRadius', path, RANGES.cornerRadius, 0),
457
+ shadow: cleanShadow(get(o, 'shadow'), at(path, 'shadow')),
458
+ };
459
+ el = isAssetId(assetId) && ctx.assets.has(assetId) ? image : null;
460
+ break;
461
+ }
462
+ }
463
+ if (el) ids.add(el.id);
464
+ return el;
465
+ }
466
+
467
+ /** A slide layout: at most 200 elements with unique ids; dangling images are dropped. */
468
+ export function cleanLayout(v: unknown, path: string, ctx: ValidationContext): SlideLayout {
469
+ const o = asObject(v, path);
470
+ const ids = new Set<string>();
471
+ const elements: SlideElement[] = [];
472
+ arrayField(o, 'elements', path, LIMITS.elements).forEach((e, i) => {
473
+ const el = cleanElement(e, at(at(path, 'elements'), i), ctx, ids);
474
+ if (el) elements.push(el);
475
+ });
476
+ return { background: cleanBackground(get(o, 'background'), at(path, 'background'), ctx), elements };
477
+ }
478
+
479
+ /* Theme, assets, export ----------------------------------------------- */
480
+
481
+ /** A theme; a missing or dangling background becomes the default one. */
482
+ export function cleanTheme(v: unknown, path: string, ctx: ValidationContext): Theme {
483
+ const o = asObject(v, path);
484
+ const bgRaw = get(o, 'background');
485
+ const background = isMissing(bgRaw) ? defaultBackground() : cleanBackground(bgRaw, at(path, 'background'), ctx) ?? defaultBackground();
486
+ const fontsRaw = get(o, 'fonts');
487
+ let fonts = { heading: DEFAULT_FONT, body: DEFAULT_FONT };
488
+ if (!isMissing(fontsRaw)) {
489
+ const fp = at(path, 'fonts');
490
+ const fo = asObject(fontsRaw, fp);
491
+ fonts = {
492
+ heading: cleanFontName(rawString(fo, 'heading', fp, DEFAULT_FONT), DEFAULT_FONT),
493
+ body: cleanFontName(rawString(fo, 'body', fp, DEFAULT_FONT), DEFAULT_FONT),
494
+ };
495
+ }
496
+ const palette: string[] = [];
497
+ arrayField(o, 'palette', path, LIMITS.palette).forEach((c, i) => {
498
+ const color = colorValue(c, at(at(path, 'palette'), i));
499
+ if (!palette.includes(color)) palette.push(color);
500
+ });
501
+ return {
502
+ background,
503
+ panorama: boolField(o, 'panorama', path, false),
504
+ fonts,
505
+ palette,
506
+ mockDark: boolField(o, 'mockDark', path, false),
507
+ };
508
+ }
509
+
510
+ /** One AssetInfo; its id is the record key it was stored under. */
511
+ export function cleanAssetInfo(v: unknown, path: string, id: string): AssetInfo {
512
+ const o = asObject(v, path);
513
+ return {
514
+ id,
515
+ name: nameField(o, 'name', path, id) || id,
516
+ width: numberField(o, 'width', path, RANGES.pixels),
517
+ height: numberField(o, 'height', path, RANGES.pixels),
518
+ bytes: numberField(o, 'bytes', path, RANGES.bytes, 0),
519
+ addedAt: numberField(o, 'addedAt', path, RANGES.timestamp, 0),
520
+ };
521
+ }
522
+
523
+ /** An asset map; entries whose key is not an asset id are dropped. */
524
+ export function cleanAssets(v: unknown, path: string): Record<string, AssetInfo> {
525
+ const out: Record<string, AssetInfo> = {};
526
+ if (isMissing(v)) return out;
527
+ const o = asObject(v, path);
528
+ for (const k of recordKeys(o, LIMITS.assets, isAssetId)) out[k] = cleanAssetInfo(o[k], at(path, k), k);
529
+ return out;
530
+ }
531
+
532
+ /**
533
+ * A project's font list: entries whose file name is not a font file are
534
+ * dropped, and so is a second entry for a family or file already listed.
535
+ */
536
+ export function cleanFonts(v: unknown, path: string): ProjectFont[] {
537
+ if (isMissing(v)) return [];
538
+ const out: ProjectFont[] = [];
539
+ const families = new Set<string>();
540
+ const files = new Set<string>();
541
+ if (!Array.isArray(v)) return out;
542
+ v.slice(0, LIMITS.fonts).forEach((raw: unknown, i) => {
543
+ if (!isPlainObject(raw)) return;
544
+ const file = raw.file;
545
+ if (!isFontFile(file) || files.has(file)) return;
546
+ const family = nameField(raw, 'family', at(path, i), '');
547
+ if (!family || families.has(family.toLowerCase())) return;
548
+ families.add(family.toLowerCase());
549
+ files.add(file);
550
+ out.push({ family, file, name: nameField(raw, 'name', at(path, i), file) || file });
551
+ });
552
+ return out;
553
+ }
554
+
555
+ /** Export settings; `languages` lists the project languages export may pick from. */
556
+ export function cleanExportSettings(v: unknown, path: string, languages: readonly LangCode[]): ExportSettings {
557
+ const d = defaultExportSettings();
558
+ if (isMissing(v)) return d;
559
+ const o = asObject(v, path);
560
+ const sizes: string[] = [];
561
+ arrayField(o, 'sizes', path, 64).forEach((s, i) => {
562
+ if (typeof s !== 'string') fail(at(at(path, 'sizes'), i), 'must be a string');
563
+ // Sizes the catalog no longer knows are dropped, not fatal: catalogs change with the stores.
564
+ if (sizeById(s) && !sizes.includes(s)) sizes.push(s);
565
+ });
566
+ const langs: LangCode[] = [];
567
+ arrayField(o, 'languages', path, LIMITS.languages).forEach((l, i) => {
568
+ if (typeof l !== 'string') fail(at(at(path, 'languages'), i), 'must be a string');
569
+ if (languages.includes(l) && !langs.includes(l)) langs.push(l);
570
+ });
571
+ return {
572
+ sizes,
573
+ languages: langs,
574
+ format: enumField(o, 'format', path, FORMATS, d.format),
575
+ jpegQuality: numberField(o, 'jpegQuality', path, RANGES.jpegQuality, d.jpegQuality),
576
+ structure: enumField(o, 'structure', path, EXPORT_STRUCTURES, d.structure),
577
+ };
578
+ }
579
+
580
+ /* Slides ------------------------------------------------------------- */
581
+
582
+ /** lang -> key -> text. Invalid language codes and keys are dropped. */
583
+ export function cleanTexts(v: unknown, path: string): Record<LangCode, Record<string, string>> {
584
+ const out: Record<LangCode, Record<string, string>> = {};
585
+ if (isMissing(v)) return out;
586
+ const o = asObject(v, path);
587
+ for (const lang of recordKeys(o, LIMITS.languages, isLangCode)) {
588
+ const lp = at(path, lang);
589
+ const byKey = asObject(o[lang], lp);
590
+ const texts: Record<string, string> = {};
591
+ for (const key of recordKeys(byKey, LIMITS.textKeys, isSafeKey)) {
592
+ const t = byKey[key];
593
+ if (isMissing(t)) continue;
594
+ if (typeof t !== 'string') fail(at(lp, key), 'must be a string');
595
+ texts[key] = cleanText(t);
596
+ }
597
+ out[lang] = texts;
598
+ }
599
+ return out;
600
+ }
601
+
602
+ function cleanShots(v: unknown, path: string, ctx: ValidationContext): Slide['shots'] {
603
+ const out: Slide['shots'] = {};
604
+ if (isMissing(v)) return out;
605
+ const o = asObject(v, path);
606
+ for (const family of recordKeys(o, 16, isFamilyId)) {
607
+ const fp = at(path, family);
608
+ const bySlot = asObject(o[family], fp);
609
+ const map: ShotMap = {};
610
+ for (const slot of recordKeys(bySlot, LIMITS.slots, isSafeKey)) {
611
+ const sp = at(fp, slot);
612
+ const byLang = asObject(bySlot[slot], sp);
613
+ const langs: Record<LangCode, string> = {};
614
+ for (const lang of recordKeys(byLang, LIMITS.languages, isLangCode)) {
615
+ const id = byLang[lang];
616
+ if (isMissing(id)) continue;
617
+ if (typeof id !== 'string') fail(at(sp, lang), 'must be an asset id string');
618
+ if (isAssetId(id) && ctx.assets.has(id)) langs[lang] = id;
619
+ }
620
+ if (Object.keys(langs).length) map[slot] = langs;
621
+ }
622
+ if (Object.keys(map).length) out[family as FamilyId] = map;
623
+ }
624
+ return out;
625
+ }
626
+
627
+ function cleanLayouts(v: unknown, path: string, ctx: ValidationContext): Slide['layouts'] {
628
+ const out: Slide['layouts'] = {};
629
+ if (isMissing(v)) return out;
630
+ const o = asObject(v, path);
631
+ for (const family of recordKeys(o, 16, isFamilyId)) {
632
+ out[family as FamilyId] = cleanLayout(o[family], at(path, family), ctx);
633
+ }
634
+ return out;
635
+ }
636
+
637
+ function cleanSlide(v: unknown, path: string, ctx: ValidationContext, ids: Set<string>): Slide {
638
+ const o = asObject(v, path);
639
+ const rawId = get(o, 'id');
640
+ const id = isId(rawId) && !ids.has(rawId) ? rawId : newId('s');
641
+ ids.add(id);
642
+ return {
643
+ id,
644
+ texts: cleanTexts(get(o, 'texts'), at(path, 'texts')),
645
+ shots: cleanShots(get(o, 'shots'), at(path, 'shots'), ctx),
646
+ layouts: cleanLayouts(get(o, 'layouts'), at(path, 'layouts'), ctx),
647
+ };
648
+ }
649
+
650
+ function cleanFamilyList(o: Json, path: string): FamilyId[] {
651
+ const out: FamilyId[] = [];
652
+ arrayField(o, 'families', path, 64).forEach((f, i) => {
653
+ if (typeof f !== 'string') fail(at(at(path, 'families'), i), 'must be a string');
654
+ if (isFamilyId(f) && !out.includes(f)) out.push(f);
655
+ });
656
+ return out.length ? out : [DEFAULT_FAMILY];
657
+ }
658
+
659
+ function cleanLanguageList(o: Json, path: string): LangCode[] {
660
+ const out: LangCode[] = [];
661
+ arrayField(o, 'languages', path, LIMITS.languages * 2).forEach((l, i) => {
662
+ if (typeof l !== 'string') fail(at(at(path, 'languages'), i), 'must be a string');
663
+ if (out.length < LIMITS.languages && isLangCode(l) && !out.includes(l)) out.push(l);
664
+ });
665
+ return out.length ? out : [DEFAULT_LANGUAGE];
666
+ }
667
+
668
+ /* Versions ----------------------------------------------------------- */
669
+
670
+ type Migration = (o: Json) => Json;
671
+
672
+ /**
673
+ * Upgrades from version N to N + 1, keyed by N. Only version 1 exists; a
674
+ * future format change adds `1: (o) => ({ ...o, version: 2, ... })` here.
675
+ */
676
+ const MIGRATIONS: Record<string, Partial<Record<number, Migration>>> = {
677
+ project: {},
678
+ template: {},
679
+ gallery: {},
680
+ };
681
+
682
+ /** Brings a parsed file up to CURRENT_VERSION, or throws when it cannot. */
683
+ export function migrate(kind: 'project' | 'template' | 'gallery', json: Json): Json {
684
+ let o = json;
685
+ for (let guard = 0; guard < 100; guard++) {
686
+ const v = get(o, 'version');
687
+ if (isMissing(v)) fail('version', 'is required');
688
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) fail('version', 'must be a positive integer');
689
+ if (v === CURRENT_VERSION) return o;
690
+ if (v > CURRENT_VERSION) fail('version', `${v} is newer than this app understands (${CURRENT_VERSION}); update Wajha to open it`);
691
+ const step = MIGRATIONS[kind][v];
692
+ if (!step) fail('version', `no upgrade from version ${v}`);
693
+ o = step(o);
694
+ }
695
+ return fail('version', 'migration did not converge');
696
+ }
697
+
698
+ function checkFormat(o: Json, expected: string): void {
699
+ const f = get(o, 'format');
700
+ if (f !== expected) fail('format', `must be "${expected}"`);
701
+ }
702
+
703
+ /* Project ------------------------------------------------------------ */
704
+
705
+ /** Type settings of the project's languages; other languages and empty settings are dropped. */
706
+ function cleanTypography(v: unknown, path: string, languages: readonly LangCode[]): Partial<Record<LangCode, LanguageTypography>> {
707
+ const out: Partial<Record<LangCode, LanguageTypography>> = {};
708
+ if (isMissing(v)) return out;
709
+ const o = asObject(v, path);
710
+ for (const lang of languages) {
711
+ const raw = get(o, lang);
712
+ if (isMissing(raw)) continue;
713
+ const p = at(path, lang);
714
+ const lo = asObject(raw, p);
715
+ const entry: LanguageTypography = {};
716
+ const font = get(lo, 'font');
717
+ if (!isMissing(font)) {
718
+ if (typeof font !== 'string') fail(at(p, 'font'), 'must be a string');
719
+ const name = cleanFontName(font, '');
720
+ if (name) entry.font = name;
721
+ }
722
+ const spacing = Math.round(numberField(lo, 'lineSpacing', p, RANGES.lineSpacing, 1) * 100) / 100;
723
+ if (spacing !== 1) entry.lineSpacing = spacing;
724
+ if (entry.font || entry.lineSpacing) out[lang] = entry;
725
+ }
726
+ return out;
727
+ }
728
+
729
+ /** A parsed project.json (or archive manifest) as a well-formed Project. */
730
+ export function validateProject(json: unknown): Project {
731
+ const root = asObject(json, '$');
732
+ checkFormat(root, 'wajha.project');
733
+ const o = migrate('project', root);
734
+
735
+ const id = rawString(o, 'id', '');
736
+ if (!isId(id)) fail('id', 'must be 1-64 letters, digits, "_" or "-"');
737
+ const now = Date.now();
738
+ const createdAt = numberField(o, 'createdAt', '', RANGES.timestamp, now);
739
+ const updatedAt = numberField(o, 'updatedAt', '', RANGES.timestamp, createdAt);
740
+ const tid = get(o, 'templateId');
741
+ if (!isMissing(tid) && typeof tid !== 'string') fail('templateId', 'must be a string or null');
742
+
743
+ const assets = cleanAssets(get(o, 'assets'), 'assets');
744
+ const ctx = contextFor(assets);
745
+ const themeRaw = get(o, 'theme');
746
+ const theme = isMissing(themeRaw) ? defaultTheme() : cleanTheme(themeRaw, 'theme', ctx);
747
+ const families = cleanFamilyList(o, '');
748
+ const languages = cleanLanguageList(o, '');
749
+
750
+ const ids = new Set<string>();
751
+ const slides = arrayField(o, 'slides', '', LIMITS.slides).map((s, i) => cleanSlide(s, at('slides', i), ctx, ids));
752
+ if (!slides.length) slides.push(newSlide(families, theme, languages));
753
+ const fonts = cleanFonts(get(o, 'fonts'), 'fonts');
754
+ const mirrorRtl = boolField(o, 'mirrorRtl', '', true);
755
+ const typography = cleanTypography(get(o, 'typography'), 'typography', languages);
756
+
757
+ return {
758
+ format: 'wajha.project',
759
+ version: 1,
760
+ id,
761
+ name: nameField(o, 'name', '', DEFAULT_PROJECT_NAME) || DEFAULT_PROJECT_NAME,
762
+ createdAt,
763
+ updatedAt,
764
+ templateId: isTemplateId(tid) ? tid : null,
765
+ theme,
766
+ families,
767
+ languages,
768
+ ...(mirrorRtl ? {} : { mirrorRtl: false }),
769
+ ...(Object.keys(typography).length ? { typography } : {}),
770
+ slides,
771
+ assets,
772
+ ...(fonts.length ? { fonts } : {}),
773
+ export: cleanExportSettings(get(o, 'export'), 'export', languages),
774
+ };
775
+ }
776
+
777
+ /* Template ----------------------------------------------------------- */
778
+
779
+ const REFERENCE_CANVAS = { tall: FAMILIES.iphone.design, tablet: FAMILIES.ipad.design, wide: FAMILIES.mac.design };
780
+
781
+ function cleanTemplateSlide(v: unknown, path: string, ctx: ValidationContext): TemplateSlide {
782
+ const o = asObject(v, path);
783
+ const texts = cleanTexts(get(o, 'texts'), at(path, 'texts'));
784
+ if (!texts[DEFAULT_LANGUAGE]) {
785
+ // Every template must show something in English; borrow the first language's copy.
786
+ const first = Object.keys(texts)[0];
787
+ texts[DEFAULT_LANGUAGE] = first ? { ...texts[first] } : {};
788
+ }
789
+ const lp = at(path, 'layouts');
790
+ const lo = objectField(o, 'layouts', path);
791
+ const read = (cls: 'tall' | 'tablet' | 'wide') => (isMissing(get(lo, cls)) ? undefined : cleanLayout(lo[cls], at(lp, cls), ctx));
792
+ const tablet = read('tablet');
793
+ const wide = read('wide');
794
+ let tall = read('tall');
795
+ if (!tall) {
796
+ if (tablet) tall = adaptLayout(tablet, REFERENCE_CANVAS.tablet, REFERENCE_CANVAS.tall, 'iphone');
797
+ else if (wide) tall = adaptLayout(wide, REFERENCE_CANVAS.wide, REFERENCE_CANVAS.tall, 'iphone');
798
+ else fail(at(lp, 'tall'), 'is required');
799
+ }
800
+ const layouts: TemplateSlide['layouts'] = { tall };
801
+ if (tablet) layouts.tablet = tablet;
802
+ if (wide) layouts.wide = wide;
803
+ return { texts, layouts };
804
+ }
805
+
806
+ function cleanTags(o: Json, path: string): string[] {
807
+ const tags: string[] = [];
808
+ arrayField(o, 'tags', path, LIMITS.tags * 4).forEach((t, i) => {
809
+ if (typeof t !== 'string') fail(at(at(path, 'tags'), i), 'must be a string');
810
+ const tag = cleanName(t, LIMITS.tag);
811
+ if (tag && !tags.includes(tag) && tags.length < LIMITS.tags) tags.push(tag);
812
+ });
813
+ return tags;
814
+ }
815
+
816
+ function templateIdField(o: Json, path: string): string {
817
+ const id = rawString(o, 'id', path);
818
+ if (!isTemplateId(id)) {
819
+ fail(at(path, 'id'), isWindowsDeviceName(id) ? 'must not be a name Windows reserves (con, prn, aux, nul, com1-9, lpt1-9)' : 'must be kebab-case (a-z, 0-9 and "-"), at most 64 characters');
820
+ }
821
+ return id;
822
+ }
823
+
824
+ /** A parsed template.json as a well-formed Template (1..10 slides, a tall layout each). */
825
+ export function validateTemplate(json: unknown): Template {
826
+ const root = asObject(json, '$');
827
+ checkFormat(root, 'wajha.template');
828
+ const o = migrate('template', root);
829
+ const id = templateIdField(o, '');
830
+ const assets = cleanAssets(get(o, 'assets'), 'assets');
831
+ const ctx = contextFor(assets);
832
+ const themeRaw = get(o, 'theme');
833
+ const theme = isMissing(themeRaw) ? defaultTheme() : cleanTheme(themeRaw, 'theme', ctx);
834
+ const slides = arrayField(o, 'slides', '', LIMITS.templateSlides).map((s, i) => cleanTemplateSlide(s, at('slides', i), ctx));
835
+ if (!slides.length) {
836
+ const blank = newSlide(['iphone'], theme, [DEFAULT_LANGUAGE]);
837
+ slides.push({ texts: blank.texts, layouts: { tall: blank.layouts.iphone as SlideLayout } });
838
+ }
839
+ return {
840
+ format: 'wajha.template',
841
+ version: 1,
842
+ id,
843
+ name: nameField(o, 'name', '', id) || id,
844
+ author: nameField(o, 'author', '', ''),
845
+ description: cleanText(rawString(o, 'description', '', ''), LIMITS.text),
846
+ category: enumField(o, 'category', '', TEMPLATE_CATEGORY_LIST, 'minimal'),
847
+ tags: cleanTags(o, ''),
848
+ theme,
849
+ slides,
850
+ assets,
851
+ };
852
+ }
853
+
854
+ /* Gallery ------------------------------------------------------------ */
855
+
856
+ function pathField(o: Json, key: string, path: string): string {
857
+ const v = rawString(o, key, path);
858
+ if (!isSafeRelPath(v)) fail(at(path, key), 'must be a relative path without ".." segments');
859
+ return v;
860
+ }
861
+
862
+ function cleanGalleryEntry(v: unknown, path: string): GalleryEntry {
863
+ const o = asObject(v, path);
864
+ const id = templateIdField(o, path);
865
+ const assets: string[] = [];
866
+ arrayField(o, 'assets', path, LIMITS.galleryAssets).forEach((a, i) => {
867
+ const p = at(at(path, 'assets'), i);
868
+ if (!isSafeRelPath(a)) fail(p, 'must be a relative path without ".." segments');
869
+ if (!assets.includes(a)) assets.push(a);
870
+ });
871
+ return {
872
+ id,
873
+ name: nameField(o, 'name', path, id) || id,
874
+ author: nameField(o, 'author', path, ''),
875
+ description: cleanText(rawString(o, 'description', path, ''), LIMITS.text),
876
+ category: enumField(o, 'category', path, TEMPLATE_CATEGORY_LIST, 'minimal'),
877
+ tags: cleanTags(o, path),
878
+ version: numberField(o, 'version', path, RANGES.galleryVersion, 1),
879
+ template: pathField(o, 'template', path),
880
+ preview: pathField(o, 'preview', path),
881
+ assets,
882
+ bytes: numberField(o, 'bytes', path, RANGES.bytes, 0),
883
+ };
884
+ }
885
+
886
+ /**
887
+ * The community gallery index; duplicate ids keep their first entry. An
888
+ * invalid entry is skipped (with a warning) rather than failing the index:
889
+ * one broken community template must not take the whole gallery down.
890
+ */
891
+ export function validateGalleryIndex(json: unknown): GalleryIndex {
892
+ const root = asObject(json, '$');
893
+ checkFormat(root, 'wajha.gallery');
894
+ const o = migrate('gallery', root);
895
+ const seen = new Set<string>();
896
+ const templates: GalleryEntry[] = [];
897
+ arrayField(o, 'templates', '', LIMITS.galleryTemplates).forEach((t, i) => {
898
+ let entry: GalleryEntry;
899
+ try {
900
+ entry = cleanGalleryEntry(t, at('templates', i));
901
+ } catch (e) {
902
+ if (!(e instanceof ValidationError)) throw e;
903
+ console.warn(`[gallery] skipped an invalid template: ${e.message}`);
904
+ return;
905
+ }
906
+ if (!seen.has(entry.id)) {
907
+ seen.add(entry.id);
908
+ templates.push(entry);
909
+ }
910
+ });
911
+ return {
912
+ format: 'wajha.gallery',
913
+ version: 1,
914
+ updatedAt: nameField(o, 'updatedAt', '', '', LIMITS.date),
915
+ templates,
916
+ };
917
+ }