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.
- package/README.md +270 -0
- package/components/DebugGrid.vue +50 -0
- package/components/DecorLibrary.vue +1117 -0
- package/components/Header.vue +124 -0
- package/components/Image.vue +82 -0
- package/components/ImageRenderer.vue +126 -0
- package/components/Logo.vue +142 -0
- package/components/Person.vue +89 -0
- package/components/Slide.vue +324 -0
- package/components/Slot.vue +419 -0
- package/components/Text.vue +216 -0
- package/components/Timeline.vue +202 -0
- package/composables/decor-catalog.mjs +339 -0
- package/composables/decor-config.mjs +488 -0
- package/composables/decor-file-store.ts +192 -0
- package/composables/decor-http-store.ts +25 -0
- package/composables/decor-store.ts +39 -0
- package/composables/decor-tuning-overrides.mjs +2 -0
- package/composables/decor-workbench.ts +799 -0
- package/composables/grid-placement.ts +396 -0
- package/composables/image-config.mjs +484 -0
- package/composables/layout-authoring.ts +372 -0
- package/composables/layout-markdown.mjs +56 -0
- package/composables/layout-recipes.ts +347 -0
- package/composables/layout-registry.ts +47 -0
- package/composables/layout-shorthands.ts +891 -0
- package/composables/layout-vnode.ts +43 -0
- package/composables/logo-mark.mjs +5 -0
- package/composables/slide-layout.ts +43 -0
- package/composables/slot-placement.ts +195 -0
- package/composables/slot-rules.ts +147 -0
- package/composables/text-fit-runtime.ts +577 -0
- package/composables/text-priority-fit.mjs +166 -0
- package/composables/text-typography.mjs +138 -0
- package/composables/theme-foundation.ts +192 -0
- package/composables/theme-media.mjs +202 -0
- package/composables/use-theme-config.ts +71 -0
- package/decor-library.md +10 -0
- package/env.d.ts +6 -0
- package/example.md +786 -0
- package/layouts/collection.vue +5 -0
- package/layouts/cover.vue +5 -0
- package/layouts/default.vue +5 -0
- package/layouts/explainer.vue +5 -0
- package/layouts/message.vue +5 -0
- package/layouts/none.vue +5 -0
- package/layouts/section.vue +5 -0
- package/layouts/two-cols.vue +11 -0
- package/package.json +84 -0
- package/public/decor/decor-1.svg +83 -0
- package/public/decor/decor-10.svg +40 -0
- package/public/decor/decor-11.svg +162 -0
- package/public/decor/decor-12.svg +83 -0
- package/public/decor/decor-13.svg +5 -0
- package/public/decor/decor-14.svg +7 -0
- package/public/decor/decor-15.svg +10 -0
- package/public/decor/decor-16.svg +9 -0
- package/public/decor/decor-17.png +0 -0
- package/public/decor/decor-18.png +0 -0
- package/public/decor/decor-19.svg +5 -0
- package/public/decor/decor-2.png +0 -0
- package/public/decor/decor-3.svg +9 -0
- package/public/decor/decor-4.svg +31 -0
- package/public/decor/decor-5.png +0 -0
- package/public/decor/decor-6.png +0 -0
- package/public/decor/decor-7.png +0 -0
- package/public/decor/decor-8.svg +21 -0
- package/public/decor/decor-9.svg +38 -0
- package/public/favicon.svg +11 -0
- package/public/photos/photo-1.png +0 -0
- package/public/photos/photo-10.png +0 -0
- package/public/photos/photo-2.png +0 -0
- package/public/photos/photo-3.png +0 -0
- package/public/photos/photo-4.png +0 -0
- package/public/photos/photo-5.png +0 -0
- package/public/photos/photo-6.png +0 -0
- package/public/photos/photo-7.png +0 -0
- package/public/photos/photo-8.png +0 -0
- package/public/photos/photo-9.png +0 -0
- package/setup/main.ts +3 -0
- package/setup/shiki.ts +10 -0
- package/setup/vite-plugins.ts +106 -0
- package/slidev-theme-practicum.png +0 -0
- package/slidev-theme-practicum.svg +102 -0
- package/styles/example.css +0 -0
- package/styles/index.css +192 -0
- package/styles/vars.css +119 -0
- 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
|
+
}
|