slidev-theme-practicum 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 (88) hide show
  1. package/README.md +270 -0
  2. package/components/DebugGrid.vue +50 -0
  3. package/components/DecorLibrary.vue +1117 -0
  4. package/components/Header.vue +124 -0
  5. package/components/Image.vue +82 -0
  6. package/components/ImageRenderer.vue +126 -0
  7. package/components/Logo.vue +142 -0
  8. package/components/Person.vue +89 -0
  9. package/components/Slide.vue +324 -0
  10. package/components/Slot.vue +419 -0
  11. package/components/Text.vue +216 -0
  12. package/components/Timeline.vue +202 -0
  13. package/composables/decor-catalog.mjs +339 -0
  14. package/composables/decor-config.mjs +488 -0
  15. package/composables/decor-file-store.ts +192 -0
  16. package/composables/decor-http-store.ts +25 -0
  17. package/composables/decor-store.ts +39 -0
  18. package/composables/decor-tuning-overrides.mjs +2 -0
  19. package/composables/decor-workbench.ts +799 -0
  20. package/composables/grid-placement.ts +396 -0
  21. package/composables/image-config.mjs +484 -0
  22. package/composables/layout-authoring.ts +372 -0
  23. package/composables/layout-markdown.mjs +56 -0
  24. package/composables/layout-recipes.ts +347 -0
  25. package/composables/layout-registry.ts +47 -0
  26. package/composables/layout-shorthands.ts +891 -0
  27. package/composables/layout-vnode.ts +43 -0
  28. package/composables/logo-mark.mjs +5 -0
  29. package/composables/slide-layout.ts +43 -0
  30. package/composables/slot-placement.ts +195 -0
  31. package/composables/slot-rules.ts +147 -0
  32. package/composables/text-fit-runtime.ts +577 -0
  33. package/composables/text-priority-fit.mjs +166 -0
  34. package/composables/text-typography.mjs +138 -0
  35. package/composables/theme-foundation.ts +192 -0
  36. package/composables/theme-media.mjs +202 -0
  37. package/composables/use-theme-config.ts +71 -0
  38. package/decor-library.md +10 -0
  39. package/env.d.ts +6 -0
  40. package/example.md +786 -0
  41. package/layouts/collection.vue +5 -0
  42. package/layouts/cover.vue +5 -0
  43. package/layouts/default.vue +5 -0
  44. package/layouts/explainer.vue +5 -0
  45. package/layouts/message.vue +5 -0
  46. package/layouts/none.vue +5 -0
  47. package/layouts/section.vue +5 -0
  48. package/layouts/two-cols.vue +11 -0
  49. package/package.json +84 -0
  50. package/public/decor/decor-1.svg +83 -0
  51. package/public/decor/decor-10.svg +40 -0
  52. package/public/decor/decor-11.svg +162 -0
  53. package/public/decor/decor-12.svg +83 -0
  54. package/public/decor/decor-13.svg +5 -0
  55. package/public/decor/decor-14.svg +7 -0
  56. package/public/decor/decor-15.svg +10 -0
  57. package/public/decor/decor-16.svg +9 -0
  58. package/public/decor/decor-17.png +0 -0
  59. package/public/decor/decor-18.png +0 -0
  60. package/public/decor/decor-19.svg +5 -0
  61. package/public/decor/decor-2.png +0 -0
  62. package/public/decor/decor-3.svg +9 -0
  63. package/public/decor/decor-4.svg +31 -0
  64. package/public/decor/decor-5.png +0 -0
  65. package/public/decor/decor-6.png +0 -0
  66. package/public/decor/decor-7.png +0 -0
  67. package/public/decor/decor-8.svg +21 -0
  68. package/public/decor/decor-9.svg +38 -0
  69. package/public/favicon.svg +11 -0
  70. package/public/photos/photo-1.png +0 -0
  71. package/public/photos/photo-10.png +0 -0
  72. package/public/photos/photo-2.png +0 -0
  73. package/public/photos/photo-3.png +0 -0
  74. package/public/photos/photo-4.png +0 -0
  75. package/public/photos/photo-5.png +0 -0
  76. package/public/photos/photo-6.png +0 -0
  77. package/public/photos/photo-7.png +0 -0
  78. package/public/photos/photo-8.png +0 -0
  79. package/public/photos/photo-9.png +0 -0
  80. package/setup/main.ts +3 -0
  81. package/setup/shiki.ts +10 -0
  82. package/setup/vite-plugins.ts +106 -0
  83. package/slidev-theme-practicum.png +0 -0
  84. package/slidev-theme-practicum.svg +102 -0
  85. package/styles/example.css +0 -0
  86. package/styles/index.css +192 -0
  87. package/styles/vars.css +119 -0
  88. package/types/slidev-client.d.ts +15 -0
@@ -0,0 +1,488 @@
1
+ import { DEFAULT_DECOR_DATA } from './decor-catalog.mjs'
2
+ import {
3
+ IMAGE_DATA_FIELDS,
4
+ normalizeImageData,
5
+ pickImageDataFields,
6
+ } from './image-config.mjs'
7
+
8
+ /**
9
+ * @typedef {Record<string, unknown>} PlainRecord
10
+ * @typedef {{ cols?: number | [number, number], rows?: number | [number, number], ratio?: number | [number, number], aspectRatio?: number | [number, number] }} DecorSize
11
+ * @typedef {PlainRecord & {
12
+ * id: string
13
+ * src: string
14
+ * sizes?: DecorSize[]
15
+ * cols?: number | [number, number]
16
+ * rows?: number | [number, number]
17
+ * ratio?: number | [number, number]
18
+ * aspectRatio?: number | [number, number]
19
+ * meaning?: string
20
+ * meanings?: string[]
21
+ * tone?: string
22
+ * tones?: string[]
23
+ * tags?: string[]
24
+ * image?: Partial<import('./image-config.mjs').ImageDataInput>
25
+ * }} DecorCatalogVariant
26
+ * @typedef {{ variants: readonly DecorCatalogVariant[], byId: Map<string, DecorCatalogVariant> }} NormalizedDecorCatalog
27
+ * @typedef {{ cols?: number, rows?: number, label?: string }} DecorFootprint
28
+ * @typedef {{ meaning: string, tone: string }} DecorQuery
29
+ * @typedef {{ variant: DecorCatalogVariant, size: number, tone: number, meaning: number }} DecorScore
30
+ * @typedef {{
31
+ * catalog?: readonly object[] | NormalizedDecorCatalog
32
+ * themeDecors?: readonly object[]
33
+ * footprint?: DecorFootprint
34
+ * seed?: string
35
+ * warn?: (message: string) => void
36
+ * label?: string
37
+ * }} DecorResolveOptions
38
+ */
39
+
40
+ const DECOR_FIELDS = new Set(['src', 'id', 'meaning', 'tone', 'tags', ...IMAGE_DATA_FIELDS])
41
+
42
+ /**
43
+ * @param {unknown} value
44
+ * @returns {value is PlainRecord}
45
+ */
46
+ function isPlainObject(value) {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
48
+ }
49
+
50
+ /**
51
+ * @param {unknown} value
52
+ * @returns {string[]}
53
+ */
54
+ function normalizeList(value) {
55
+ return Array.isArray(value)
56
+ ? value.filter(item => typeof item === 'string').map(item => item.trim()).filter(Boolean)
57
+ : []
58
+ }
59
+
60
+ /**
61
+ * @param {unknown} input
62
+ */
63
+ export function hasDecorInput(input) {
64
+ if (!isPlainObject(input))
65
+ return false
66
+
67
+ return Object.entries(input).some(([key, value]) => {
68
+ if (!DECOR_FIELDS.has(key))
69
+ return false
70
+
71
+ if (key === 'src')
72
+ return true
73
+
74
+ if (key === 'id' || key === 'meaning' || key === 'tone')
75
+ return typeof value === 'string' && Boolean(value.trim())
76
+
77
+ if (value === undefined || value === null)
78
+ return false
79
+
80
+ return typeof value === 'string' ? Boolean(value.trim()) : true
81
+ })
82
+ }
83
+
84
+ /**
85
+ * @param {{
86
+ * decor?: object | null
87
+ * tone: string
88
+ * contrast: boolean
89
+ * }} input
90
+ * @returns {PlainRecord & { meaning: string, tone: string, color: string, opacity: number }}
91
+ */
92
+ export function resolveAgendaDecor({ decor, tone, contrast }) {
93
+ return {
94
+ meaning: 'agenda',
95
+ tone,
96
+ color: contrast ? 'var(--theme-color-light-0)' : 'var(--theme-color-dark-0)',
97
+ opacity: 0.85,
98
+ ...(isPlainObject(decor) ? decor : {}),
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @param {PlainRecord} variant
104
+ * @param {string} id
105
+ * @returns {DecorCatalogVariant}
106
+ */
107
+ function normalizeCatalogVariant(variant, id) {
108
+ const normalized = /** @type {PlainRecord} */ ({ ...variant, id })
109
+
110
+ if ((!Array.isArray(normalized.sizes) || normalized.sizes.length === 0)
111
+ && normalized.cols !== undefined
112
+ && normalized.rows !== undefined) {
113
+ normalized.sizes = [{
114
+ cols: normalized.cols,
115
+ rows: normalized.rows,
116
+ ratio: normalized.ratio ?? normalized.aspectRatio,
117
+ }]
118
+ }
119
+
120
+ if (Array.isArray(normalized.sizes)) {
121
+ normalized.sizes = normalized.sizes
122
+ .filter(isPlainObject)
123
+ .map((size) => {
124
+ const normalizedSize = { ...size }
125
+ normalizedSize.ratio ??= normalizedSize.aspectRatio
126
+ delete normalizedSize.aspectRatio
127
+
128
+ return normalizedSize
129
+ })
130
+ }
131
+
132
+ if (normalizeList(normalized.meanings).length === 0 && typeof normalized.meaning === 'string' && normalized.meaning.trim())
133
+ normalized.meanings = [normalized.meaning.trim()]
134
+
135
+ if (normalizeList(normalized.tones).length === 0 && typeof normalized.tone === 'string' && normalized.tone.trim())
136
+ normalized.tones = [normalized.tone.trim()]
137
+
138
+ const toneTags = normalizeList(normalized.tones)
139
+ const explicitTags = normalizeList(normalized.tags)
140
+ normalized.tags = [...new Set([...explicitTags, ...toneTags])]
141
+
142
+ return /** @type {DecorCatalogVariant} */ (normalized)
143
+ }
144
+
145
+ /**
146
+ * @param {number} value
147
+ * @param {unknown} range
148
+ */
149
+ function sizeRangeDistance(value, range) {
150
+ if (typeof range === 'number')
151
+ return Math.abs(value - range)
152
+
153
+ if (!Array.isArray(range) || range.length !== 2)
154
+ return Number.POSITIVE_INFINITY
155
+
156
+ const [min, max] = range.map(Number)
157
+
158
+ if (!Number.isFinite(min) || !Number.isFinite(max))
159
+ return Number.POSITIVE_INFINITY
160
+
161
+ if (value >= min && value <= max) {
162
+ const center = (min + max) / 2
163
+ return Math.abs(value - center)
164
+ }
165
+
166
+ return Math.min(Math.abs(value - min), Math.abs(value - max))
167
+ }
168
+
169
+ /**
170
+ * @param {number} value
171
+ * @param {unknown} range
172
+ */
173
+ function ratioRangeDistance(value, range) {
174
+ if (range === undefined || range === null || range === '')
175
+ return 0
176
+
177
+ if (typeof range === 'number')
178
+ return Number.isFinite(range) && range > 0 ? Math.abs(value - range) : Number.POSITIVE_INFINITY
179
+
180
+ if (!Array.isArray(range) || range.length !== 2)
181
+ return Number.POSITIVE_INFINITY
182
+
183
+ const [first, second] = range.map(Number)
184
+
185
+ if (!Number.isFinite(first) || !Number.isFinite(second) || first <= 0 || second <= 0)
186
+ return Number.POSITIVE_INFINITY
187
+
188
+ const min = Math.min(first, second)
189
+ const max = Math.max(first, second)
190
+
191
+ if (value < min || value > max)
192
+ return Number.POSITIVE_INFINITY
193
+
194
+ return 0
195
+ }
196
+
197
+ /**
198
+ * @param {DecorCatalogVariant} variant
199
+ * @param {DecorFootprint | undefined} footprint
200
+ */
201
+ function sizeDistance(variant, footprint) {
202
+ const cols = Number(footprint?.cols)
203
+ const rows = Number(footprint?.rows)
204
+
205
+ if (!Number.isFinite(cols) || !Number.isFinite(rows))
206
+ return 0
207
+
208
+ const sizes = Array.isArray(variant?.sizes) ? variant.sizes : []
209
+
210
+ if (sizes.length === 0)
211
+ return Number.POSITIVE_INFINITY
212
+
213
+ return Math.min(
214
+ ...sizes.map((size) => {
215
+ const ratio = cols / rows
216
+
217
+ return sizeRangeDistance(cols, size?.cols)
218
+ + sizeRangeDistance(rows, size?.rows)
219
+ + ratioRangeDistance(ratio, size?.ratio)
220
+ }),
221
+ )
222
+ }
223
+
224
+ /**
225
+ * @param {DecorCatalogVariant} variant
226
+ * @param {DecorQuery} query
227
+ * @param {DecorFootprint | undefined} footprint
228
+ * @returns {DecorScore}
229
+ */
230
+ function scoreVariant(variant, query, footprint) {
231
+ const tags = normalizeList(variant?.tags)
232
+ const tones = normalizeList(variant?.tones)
233
+ const searchableToneTags = tags.length ? tags : tones
234
+ const meanings = normalizeList(variant?.meanings)
235
+
236
+ return {
237
+ variant,
238
+ size: sizeDistance(variant, footprint),
239
+ tone: query.tone && searchableToneTags.includes(query.tone) ? 1 : 0,
240
+ meaning: query.meaning && meanings.includes(query.meaning) ? 1 : 0,
241
+ }
242
+ }
243
+
244
+ /**
245
+ * @param {DecorScore} a
246
+ * @param {DecorScore} b
247
+ */
248
+ function compareScores(a, b) {
249
+ return a.size - b.size || b.tone - a.tone || b.meaning - a.meaning
250
+ }
251
+
252
+ /**
253
+ * @param {DecorScore} left
254
+ * @param {DecorScore} right
255
+ */
256
+ function sameScore(left, right) {
257
+ return left.size === right.size
258
+ && left.tone === right.tone
259
+ && left.meaning === right.meaning
260
+ }
261
+
262
+ /**
263
+ * @param {unknown} value
264
+ */
265
+ function hashSeed(value) {
266
+ const normalized = String(value ?? '')
267
+ let hash = 2166136261
268
+
269
+ for (let index = 0; index < normalized.length; index += 1) {
270
+ hash ^= normalized.charCodeAt(index)
271
+ hash = Math.imul(hash, 16777619)
272
+ }
273
+
274
+ return hash >>> 0
275
+ }
276
+
277
+ /**
278
+ * @param {DecorScore[]} candidates
279
+ * @param {unknown} seed
280
+ */
281
+ function selectCandidate(candidates, seed) {
282
+ const finiteCandidates = candidates.filter(candidate => Number.isFinite(candidate.size))
283
+ const pool = finiteCandidates.length ? finiteCandidates : candidates
284
+ /** @type {DecorScore | null} */
285
+ let best = null
286
+ /** @type {DecorScore[]} */
287
+ let ties = []
288
+
289
+ for (const candidate of pool) {
290
+ if (!best || compareScores(candidate, best) < 0) {
291
+ best = candidate
292
+ ties = [candidate]
293
+ continue
294
+ }
295
+
296
+ if (sameScore(candidate, best))
297
+ ties.push(candidate)
298
+ }
299
+
300
+ if (!best)
301
+ return null
302
+
303
+ return ties[hashSeed(seed) % ties.length].variant
304
+ }
305
+
306
+ /**
307
+ * @param {DecorCatalogVariant} variant
308
+ * @param {Partial<import('./image-config.mjs').ImageDataInput>} overrides
309
+ * @param {string} label
310
+ */
311
+ function normalizeVariantImage(variant, overrides, label) {
312
+ return normalizeImageData(
313
+ {
314
+ src: variant.src,
315
+ ...(isPlainObject(variant.image) ? variant.image : {}),
316
+ ...overrides,
317
+ },
318
+ {},
319
+ label,
320
+ )
321
+ }
322
+
323
+ /**
324
+ * @param {DecorFootprint | undefined} footprint
325
+ */
326
+ function formatFootprint(footprint) {
327
+ const label = footprint?.label ?? 'unknown'
328
+ const cols = Number(footprint?.cols)
329
+ const rows = Number(footprint?.rows)
330
+ const dimensions = Number.isFinite(cols) && Number.isFinite(rows) ? `${cols}x${rows}` : 'unknown size'
331
+
332
+ return `${label} (${dimensions})`
333
+ }
334
+
335
+ /**
336
+ * @param {DecorQuery} query
337
+ * @param {DecorResolveOptions} options
338
+ * @param {DecorCatalogVariant} selected
339
+ */
340
+ function warnFallback(query, options, selected) {
341
+ const warn = typeof options.warn === 'function' ? options.warn : null
342
+
343
+ if (!warn)
344
+ return
345
+
346
+ const label = options.label ?? 'decor'
347
+ const parts = [query.meaning, query.tone].filter(Boolean).join(', ') || 'no semantic query'
348
+ const selectedId = typeof selected?.id === 'string' ? selected.id : 'unknown'
349
+
350
+ warn(`[${label}] using fallback decor "${selectedId}" for ${parts} in ${formatFootprint(options.footprint)}.`)
351
+ }
352
+
353
+ /**
354
+ * @param {string} id
355
+ * @param {DecorResolveOptions} options
356
+ */
357
+ function warnMissingPinnedId(id, options) {
358
+ const warn = typeof options.warn === 'function' ? options.warn : null
359
+
360
+ if (!warn)
361
+ return
362
+
363
+ const label = options.label ?? 'decor'
364
+
365
+ warn(`[${label}] missing pinned decor id "${id}" in ${formatFootprint(options.footprint)}; using fallback decor.`)
366
+ }
367
+
368
+ /**
369
+ * @param {readonly object[]} [baseCatalog]
370
+ * @param {readonly object[]} [extensionCatalog]
371
+ * @returns {DecorCatalogVariant[]}
372
+ */
373
+ export function mergeDecorCatalogs(baseCatalog = [], extensionCatalog = []) {
374
+ /** @type {Map<string, DecorCatalogVariant>} */
375
+ const variantsById = new Map()
376
+
377
+ for (const variant of [...baseCatalog, ...extensionCatalog]) {
378
+ if (!isPlainObject(variant) || typeof variant.id !== 'string' || !variant.id.trim())
379
+ continue
380
+
381
+ const id = variant.id.trim()
382
+ variantsById.set(id, normalizeCatalogVariant(variant, id))
383
+ }
384
+
385
+ return [...variantsById.values()]
386
+ }
387
+
388
+ /**
389
+ * @param {readonly object[]} [baseCatalog]
390
+ * @param {readonly object[]} [extensionCatalog]
391
+ * @returns {NormalizedDecorCatalog}
392
+ */
393
+ export function normalizeDecorCatalog(baseCatalog = [], extensionCatalog = []) {
394
+ const variants = Object.freeze(mergeDecorCatalogs(baseCatalog, extensionCatalog))
395
+
396
+ return Object.freeze({
397
+ variants,
398
+ byId: new Map(variants.filter(item => item.id).map(item => [item.id, item])),
399
+ })
400
+ }
401
+
402
+ /**
403
+ * @param {unknown} value
404
+ * @returns {value is NormalizedDecorCatalog}
405
+ */
406
+ function isNormalizedDecorCatalog(value) {
407
+ return isPlainObject(value)
408
+ && Array.isArray(value.variants)
409
+ && value.byId instanceof Map
410
+ }
411
+
412
+ /**
413
+ * @param {DecorResolveOptions} options
414
+ * @returns {NormalizedDecorCatalog}
415
+ */
416
+ function resolveCatalog(options) {
417
+ const baseCatalog = isNormalizedDecorCatalog(options.catalog)
418
+ ? options.catalog
419
+ : normalizeDecorCatalog(Array.isArray(options.catalog) ? options.catalog : [])
420
+
421
+ if (!Array.isArray(options.themeDecors) || options.themeDecors.length === 0)
422
+ return baseCatalog
423
+
424
+ return normalizeDecorCatalog([...baseCatalog.variants], options.themeDecors)
425
+ }
426
+
427
+ export const DEFAULT_DECOR_CATALOG = normalizeDecorCatalog(DEFAULT_DECOR_DATA)
428
+
429
+ /**
430
+ * @param {unknown} input
431
+ * @param {DecorResolveOptions} [options]
432
+ * @returns {import('./image-config.mjs').NormalizedImageData | null}
433
+ */
434
+ export function resolveDecorImage(input, options = {}) {
435
+ const label = options.label ?? 'decor'
436
+
437
+ if (!isPlainObject(input))
438
+ throw new Error(`[${label}] decor must be a plain object.`)
439
+
440
+ if (!hasDecorInput(input))
441
+ return null
442
+
443
+ if (Object.prototype.hasOwnProperty.call(input, 'src')) {
444
+ return normalizeImageData(
445
+ /** @type {import('./image-config.mjs').ImageDataInput} */ (input),
446
+ {},
447
+ label,
448
+ )
449
+ }
450
+
451
+ const catalog = resolveCatalog(options)
452
+ const overrides = pickImageDataFields(input)
453
+
454
+ if (typeof input.id === 'string' && input.id.trim()) {
455
+ const id = input.id.trim()
456
+ const variant = catalog.byId.get(id)
457
+
458
+ if (variant)
459
+ return normalizeVariantImage(variant, overrides, label)
460
+
461
+ warnMissingPinnedId(id, options)
462
+ }
463
+
464
+ if (catalog.variants.length === 0)
465
+ throw new Error(`[${label}] decor catalog is empty.`)
466
+
467
+ const query = {
468
+ meaning: typeof input.meaning === 'string' ? input.meaning.trim() : '',
469
+ tone: typeof input.tone === 'string' ? input.tone.trim() : '',
470
+ }
471
+ const candidates = catalog.variants.map(variant => scoreVariant(variant, query, options.footprint))
472
+ const hasSemanticQuery = Boolean(query.meaning || query.tone)
473
+ const hasMeaningMatch = Boolean(query.meaning) && candidates.some(candidate => candidate.meaning)
474
+ const hasToneMatch = Boolean(query.tone) && candidates.some(candidate => candidate.tone)
475
+ const hasSemanticMatch = hasMeaningMatch || hasToneMatch
476
+ const selectableCandidates = hasSemanticQuery && hasSemanticMatch
477
+ ? candidates.filter(candidate => hasMeaningMatch ? candidate.meaning : candidate.tone)
478
+ : candidates
479
+ const selected = selectCandidate(selectableCandidates, options.seed)
480
+
481
+ if (!selected)
482
+ throw new Error(`[${label}] decor catalog is empty.`)
483
+
484
+ if (hasSemanticQuery && !hasSemanticMatch)
485
+ warnFallback(query, options, selected)
486
+
487
+ return normalizeVariantImage(selected, overrides, label)
488
+ }
@@ -0,0 +1,192 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+ import type { DecorStore, DecorVariant } from './decor-store'
3
+
4
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
5
+
6
+ const STRING_LIST_FIELDS = new Set(['meanings', 'tones', 'tags'])
7
+ const IMAGE_FIT_VALUES = new Set(['cover', 'contain', 'fill', 'none', 'scale-down'])
8
+ const IMAGE_FIELD_VALIDATORS: Record<string, (value: unknown) => boolean> = {
9
+ fit: value => typeof value === 'string' && IMAGE_FIT_VALUES.has(value),
10
+ position: value => typeof value === 'string',
11
+ anchor: value => typeof value === 'string',
12
+ x: isFiniteNumberOrString,
13
+ y: isFiniteNumberOrString,
14
+ zoom: isFiniteNumberOrString,
15
+ rotate: isFiniteNumberOrString,
16
+ color: value => typeof value === 'string',
17
+ opacity: isFiniteNumberOrString,
18
+ backgroundColor: value => typeof value === 'string',
19
+ }
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
23
+ }
24
+
25
+ function isFiniteNumberOrString(value: unknown) {
26
+ return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))
27
+ }
28
+
29
+ function sanitizeStringList(value: unknown) {
30
+ if (!Array.isArray(value))
31
+ return []
32
+
33
+ return value.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim())
34
+ }
35
+
36
+ function sanitizeRange(value: unknown) {
37
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 12)
38
+ return value
39
+
40
+ if (!Array.isArray(value) || value.length !== 2)
41
+ return null
42
+
43
+ const [from, to] = value
44
+ if (!Number.isInteger(from) || !Number.isInteger(to))
45
+ return null
46
+
47
+ if (from < 1 || to > 12 || from > to)
48
+ return null
49
+
50
+ return from === to ? from : [from, to]
51
+ }
52
+
53
+ function sanitizeRatioRange(value: unknown) {
54
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0)
55
+ return Number(value.toFixed(2))
56
+
57
+ if (!Array.isArray(value) || value.length !== 2)
58
+ return null
59
+
60
+ const [from, to] = value.map(Number)
61
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from <= 0 || to <= 0)
62
+ return null
63
+
64
+ const min = Number(Math.min(from, to).toFixed(2))
65
+ const max = Number(Math.max(from, to).toFixed(2))
66
+
67
+ return min === max ? min : [min, max]
68
+ }
69
+
70
+ function sanitizeSizes(value: unknown) {
71
+ if (!Array.isArray(value))
72
+ return []
73
+
74
+ return value.flatMap((item) => {
75
+ if (!isRecord(item))
76
+ return []
77
+
78
+ const cols = sanitizeRange(item.cols)
79
+ const rows = sanitizeRange(item.rows)
80
+ const ratio = sanitizeRatioRange(item.ratio ?? item.aspectRatio)
81
+
82
+ if (!cols || !rows)
83
+ return []
84
+
85
+ return ratio ? [{ cols, rows, ratio }] : [{ cols, rows }]
86
+ })
87
+ }
88
+
89
+ function sanitizeImage(value: unknown) {
90
+ if (!isRecord(value))
91
+ return {}
92
+
93
+ return Object.fromEntries(
94
+ Object.entries(value).filter(([key, item]) => {
95
+ return IMAGE_FIELD_VALIDATORS[key]?.(item) ?? false
96
+ }),
97
+ )
98
+ }
99
+
100
+ function sanitizeVariant(value: unknown) {
101
+ if (!isRecord(value) || typeof value.id !== 'string' || typeof value.src !== 'string')
102
+ return null
103
+
104
+ const variant: Record<string, JsonValue> = {
105
+ id: value.id.trim(),
106
+ src: value.src.trim(),
107
+ }
108
+
109
+ if (!variant.id || !variant.src)
110
+ return null
111
+
112
+ for (const key of STRING_LIST_FIELDS) {
113
+ const list = sanitizeStringList(value[key])
114
+ if (list.length)
115
+ variant[key] = list
116
+ }
117
+
118
+ const sizes = sanitizeSizes(value.sizes)
119
+ if (sizes.length)
120
+ variant.sizes = sizes
121
+
122
+ const image = sanitizeImage(value.image)
123
+ if (Object.keys(image).length)
124
+ variant.image = image as Record<string, JsonValue>
125
+
126
+ return variant as DecorVariant
127
+ }
128
+
129
+ function sanitizeVariants(overrides: readonly DecorVariant[]) {
130
+ return overrides.flatMap((item) => {
131
+ const variant = sanitizeVariant(item)
132
+ return variant ? [variant] : []
133
+ })
134
+ }
135
+
136
+ function quoteString(value: string) {
137
+ return `'${value
138
+ .replace(/\\/g, '\\\\')
139
+ .replace(/'/g, '\\\'')
140
+ .replace(/\n/g, '\\n')
141
+ .replace(/\r/g, '\\r')}'`
142
+ }
143
+
144
+ function serializeValue(value: JsonValue, depth = 0): string {
145
+ const indent = ' '.repeat(depth)
146
+ const nestedIndent = ' '.repeat(depth + 2)
147
+
148
+ if (typeof value === 'string')
149
+ return quoteString(value)
150
+
151
+ if (typeof value === 'number' || typeof value === 'boolean')
152
+ return String(value)
153
+
154
+ if (value === null)
155
+ return 'null'
156
+
157
+ if (Array.isArray(value)) {
158
+ if (!value.length)
159
+ return '[]'
160
+
161
+ return `[\n${nestedIndent}${value.map(item => serializeValue(item, depth + 2)).join(`,\n${nestedIndent}`)},\n${indent}]`
162
+ }
163
+
164
+ const entries = Object.entries(value)
165
+ if (!entries.length)
166
+ return '{}'
167
+
168
+ return `{\n${nestedIndent}${entries
169
+ .map(([key, item]) => `${key}: ${serializeValue(item, depth + 2)}`)
170
+ .join(`,\n${nestedIndent}`)},\n${indent}}`
171
+ }
172
+
173
+ function createOverridesModule(overrides: readonly DecorVariant[]) {
174
+ const sanitized = sanitizeVariants(overrides)
175
+
176
+ return {
177
+ count: sanitized.length,
178
+ source: `/** @type {readonly Record<string, unknown>[]} */\nexport const DECOR_TUNING_OVERRIDES = Object.freeze(${serializeValue(sanitized as unknown as JsonValue)})\n`,
179
+ }
180
+ }
181
+
182
+ export function createFileDecorStore(input: { output: string }): DecorStore {
183
+ return {
184
+ async save(overrides) {
185
+ const module = createOverridesModule(overrides)
186
+
187
+ await writeFile(input.output, module.source, 'utf8')
188
+
189
+ return { ok: true, count: module.count }
190
+ },
191
+ }
192
+ }
@@ -0,0 +1,25 @@
1
+ import type { DecorStore } from './decor-store'
2
+
3
+ const DEFAULT_SAVE_PATH = '/__decor-library/save'
4
+
5
+ export function createHttpDecorStore(input: { url?: string, fetcher?: typeof fetch } = {}): DecorStore {
6
+ return {
7
+ async save(overrides) {
8
+ const fetcher = input.fetcher ?? globalThis.fetch
9
+ if (!fetcher)
10
+ throw new Error('Fetch API is unavailable')
11
+
12
+ const response = await fetcher(input.url ?? DEFAULT_SAVE_PATH, {
13
+ method: 'POST',
14
+ headers: { 'content-type': 'application/json' },
15
+ body: JSON.stringify({ overrides }),
16
+ })
17
+ const result = await response.json()
18
+
19
+ if (!response.ok || !result?.ok)
20
+ throw new Error(result?.error || `Save failed with HTTP ${response.status}`)
21
+
22
+ return { ok: true, count: Number(result.count) || 0 }
23
+ },
24
+ }
25
+ }