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,166 @@
1
+ const TEXT_PRIORITY_MIN = 1
2
+
3
+ /**
4
+ * @typedef {{
5
+ * minSize: number
6
+ * maxSize: number
7
+ * priority?: number
8
+ * }} PriorityTextSizeItemInput
9
+ * @typedef {{
10
+ * minSize: number
11
+ * maxSize: number
12
+ * priority: number
13
+ * }} PriorityTextSizeItem
14
+ */
15
+
16
+ /**
17
+ * @param {string} message
18
+ */
19
+ function failTextPriorityFit(message) {
20
+ throw new Error(`[Text] ${message}`)
21
+ }
22
+
23
+ /**
24
+ * @param {number[]} left
25
+ * @param {number[]} right
26
+ * @param {PriorityTextSizeItem[]} items
27
+ */
28
+ function compareCandidatesByPriority(left, right, items) {
29
+ const order = items
30
+ .map((item, index) => ({ index, priority: item.priority }))
31
+ .sort((leftItem, rightItem) => leftItem.priority - rightItem.priority || leftItem.index - rightItem.index)
32
+
33
+ for (const { index } of order) {
34
+ if (left[index] !== right[index])
35
+ return right[index] - left[index]
36
+ }
37
+
38
+ return right.reduce((sum, size) => sum + size, 0) - left.reduce((sum, size) => sum + size, 0)
39
+ }
40
+
41
+ /**
42
+ * @param {number[]} candidate
43
+ * @param {PriorityTextSizeItem[]} items
44
+ */
45
+ function satisfiesPriority(candidate, items) {
46
+ for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
47
+ for (let rightIndex = 0; rightIndex < items.length; rightIndex += 1) {
48
+ if (leftIndex === rightIndex)
49
+ continue
50
+
51
+ const priorityGap = items[rightIndex].priority - items[leftIndex].priority
52
+ if (priorityGap <= 0)
53
+ continue
54
+
55
+ if (candidate[leftIndex] - candidate[rightIndex] < priorityGap)
56
+ return false
57
+ }
58
+ }
59
+
60
+ return true
61
+ }
62
+
63
+ /**
64
+ * @param {number[]} candidate
65
+ * @param {PriorityTextSizeItem[]} items
66
+ */
67
+ function satisfiesAssignedPriority(candidate, items) {
68
+ for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
69
+ if (!Number.isInteger(candidate[leftIndex]))
70
+ continue
71
+
72
+ for (let rightIndex = 0; rightIndex < items.length; rightIndex += 1) {
73
+ if (leftIndex === rightIndex || !Number.isInteger(candidate[rightIndex]))
74
+ continue
75
+
76
+ const priorityGap = items[rightIndex].priority - items[leftIndex].priority
77
+ if (priorityGap <= 0)
78
+ continue
79
+
80
+ if (candidate[leftIndex] - candidate[rightIndex] < priorityGap)
81
+ return false
82
+ }
83
+ }
84
+
85
+ return true
86
+ }
87
+
88
+ /**
89
+ * @param {PriorityTextSizeItemInput[]} items
90
+ * @returns {PriorityTextSizeItem[]}
91
+ */
92
+ function normalizePriorityFitItems(items) {
93
+ return items.map((item, index) => {
94
+ const minSize = Number(item.minSize),
95
+ maxSize = Number(item.maxSize),
96
+ priority = Number(item.priority ?? TEXT_PRIORITY_MIN)
97
+
98
+ if (!Number.isInteger(minSize) || !Number.isInteger(maxSize) || minSize > maxSize)
99
+ failTextPriorityFit(`item ${index + 1} has invalid size range.`)
100
+
101
+ if (!Number.isInteger(priority) || priority < TEXT_PRIORITY_MIN)
102
+ failTextPriorityFit(`item ${index + 1} has invalid priority.`)
103
+
104
+ return {
105
+ minSize,
106
+ maxSize,
107
+ priority,
108
+ }
109
+ })
110
+ }
111
+
112
+ /**
113
+ * @param {PriorityTextSizeItem[]} normalizedItems
114
+ * @returns {Generator<number[]>}
115
+ */
116
+ function createPriorityTextSizeCandidatesFromNormalized(normalizedItems) {
117
+ const order = normalizedItems
118
+ .map((item, index) => ({ item, index }))
119
+ .sort((left, right) => left.item.priority - right.item.priority || left.index - right.index)
120
+
121
+ /**
122
+ * @param {number} orderIndex
123
+ * @param {number[]} candidate
124
+ * @returns {Generator<number[]>}
125
+ */
126
+ function* visit(orderIndex, candidate) {
127
+ if (orderIndex === order.length) {
128
+ yield candidate.slice()
129
+ return
130
+ }
131
+
132
+ const { item, index } = order[orderIndex]
133
+ for (let size = item.maxSize; size >= item.minSize; size -= 1) {
134
+ candidate[index] = size
135
+ if (satisfiesAssignedPriority(candidate, normalizedItems))
136
+ yield* visit(orderIndex + 1, candidate)
137
+ }
138
+ }
139
+
140
+ return visit(0, [])
141
+ }
142
+
143
+ /**
144
+ * @param {PriorityTextSizeItemInput[]} items
145
+ * @returns {Generator<number[]>}
146
+ */
147
+ export function createPriorityTextSizeCandidates(items) {
148
+ return createPriorityTextSizeCandidatesFromNormalized(normalizePriorityFitItems(items))
149
+ }
150
+
151
+ /**
152
+ * @param {PriorityTextSizeItemInput[]} items
153
+ * @returns {number[][]}
154
+ */
155
+ export function resolvePriorityTextSizeCandidates(items) {
156
+ const normalizedItems = normalizePriorityFitItems(items)
157
+ const candidates = [...createPriorityTextSizeCandidatesFromNormalized(normalizedItems)]
158
+
159
+ if (!candidates.length)
160
+ failTextPriorityFit('size ranges cannot satisfy priority hierarchy.')
161
+
162
+ if (!candidates.every(candidate => satisfiesPriority(candidate, normalizedItems)))
163
+ failTextPriorityFit('size ranges cannot satisfy priority hierarchy.')
164
+
165
+ return candidates.sort((left, right) => compareCandidatesByPriority(left, right, normalizedItems))
166
+ }
@@ -0,0 +1,138 @@
1
+ const NON_BREAKING_SPACE = '\u00a0'
2
+ const RUSSIAN_SPACED_HYPHEN = /(\p{sc=Cyrillic}) - (?=\p{sc=Cyrillic})/gu
3
+ const TEXT_NODE_FILTER = 4
4
+ const FILTER_ACCEPT = 1
5
+ const FILTER_REJECT = 2
6
+ const TYPOGRAPHY_SKIP_SELECTOR = [
7
+ 'script',
8
+ 'style',
9
+ 'pre',
10
+ 'code',
11
+ 'kbd',
12
+ 'samp',
13
+ 'textarea',
14
+ '.shiki',
15
+ '.katex',
16
+ '.mermaid',
17
+ '[data-no-typography]',
18
+ '[data-typography="off"]',
19
+ ].join(',')
20
+
21
+ const RUSSIAN_SERVICE_WORDS = [
22
+ 'благодаря',
23
+ 'вместо',
24
+ 'вокруг',
25
+ 'вдоль',
26
+ 'между',
27
+ 'около',
28
+ 'перед',
29
+ 'после',
30
+ 'прежде',
31
+ 'против',
32
+ 'сквозь',
33
+ 'спустя',
34
+ 'среди',
35
+ 'через',
36
+ 'возле',
37
+ 'из-за',
38
+ 'из-под',
39
+ 'кроме',
40
+ 'мимо',
41
+ 'ради',
42
+ 'или',
43
+ 'без',
44
+ 'для',
45
+ 'над',
46
+ 'под',
47
+ 'при',
48
+ 'про',
49
+ 'как',
50
+ 'обо',
51
+ 'во',
52
+ 'до',
53
+ 'за',
54
+ 'из',
55
+ 'ко',
56
+ 'на',
57
+ 'не',
58
+ 'ни',
59
+ 'но',
60
+ 'об',
61
+ 'от',
62
+ 'по',
63
+ 'со',
64
+ 'а',
65
+ 'в',
66
+ 'да',
67
+ 'и',
68
+ 'к',
69
+ 'ли',
70
+ 'о',
71
+ 'с',
72
+ 'у',
73
+ ]
74
+ const RUSSIAN_COMPACT_SERVICE_WORDS = RUSSIAN_SERVICE_WORDS.filter(word => word.length <= 2)
75
+ const RUSSIAN_SERVICE_WORD_WITH_SPACE = createServiceWordPattern(RUSSIAN_SERVICE_WORDS)
76
+ const RUSSIAN_COMPACT_SERVICE_WORD_WITH_SPACE = createServiceWordPattern(RUSSIAN_COMPACT_SERVICE_WORDS)
77
+
78
+ /**
79
+ * @param {string[]} words
80
+ */
81
+ function createServiceWordPattern(words) {
82
+ return new RegExp(`(?<=^|[\\s\\p{Pi}\\p{Ps}])(${words.join('|')})[ \\t]+(?=[\\p{L}\\p{N}\\p{Pi}\\p{Ps}])`, 'giu')
83
+ }
84
+
85
+ /**
86
+ * @param {string} value
87
+ * @param {{ mode?: 'default' | 'compact' }} [options]
88
+ */
89
+ export function typographText(value, options = {}) {
90
+ const serviceWordPattern = options.mode === 'compact'
91
+ ? RUSSIAN_COMPACT_SERVICE_WORD_WITH_SPACE
92
+ : RUSSIAN_SERVICE_WORD_WITH_SPACE
93
+
94
+ return value
95
+ .replace(serviceWordPattern, `$1${NON_BREAKING_SPACE}`)
96
+ .replace(RUSSIAN_SPACED_HYPHEN, `$1${NON_BREAKING_SPACE}— `)
97
+ }
98
+
99
+ /**
100
+ * @param {Element | null | undefined} root
101
+ */
102
+ export function typographElement(root) {
103
+ if (!root)
104
+ return 0
105
+
106
+ const ownerDocument = root.ownerDocument ?? globalThis.document
107
+ if (!ownerDocument?.createTreeWalker)
108
+ return 0
109
+
110
+ const nodeFilter = ownerDocument.defaultView?.NodeFilter ?? globalThis.NodeFilter
111
+ const walker = ownerDocument.createTreeWalker(root, nodeFilter?.SHOW_TEXT ?? TEXT_NODE_FILTER, {
112
+ acceptNode(node) {
113
+ const parent = node.parentElement
114
+ if (!parent || parent.closest(TYPOGRAPHY_SKIP_SELECTOR))
115
+ return nodeFilter?.FILTER_REJECT ?? FILTER_REJECT
116
+
117
+ return nodeFilter?.FILTER_ACCEPT ?? FILTER_ACCEPT
118
+ },
119
+ })
120
+ let changed = 0
121
+
122
+ while (walker.nextNode()) {
123
+ const node = walker.currentNode
124
+ const parent = node.parentElement
125
+ const text = node.nodeValue ?? ''
126
+ const typographedText = typographText(text, {
127
+ mode: parent?.closest('[data-typography="compact"]') ? 'compact' : 'default',
128
+ })
129
+
130
+ if (typographedText === text)
131
+ continue
132
+
133
+ node.nodeValue = typographedText
134
+ changed += 1
135
+ }
136
+
137
+ return changed
138
+ }
@@ -0,0 +1,192 @@
1
+ export const THEME_SLIDE_MODES = ['light', 'dark', 'color'] as const
2
+ export const THEME_TONES = ['blue', 'orange', 'green', 'red', 'yellow'] as const
3
+ export const THEME_SLOT_SURFACES = ['none', 'light', 'dark', 'color', 'tint'] as const
4
+
5
+ export type ThemeSlideMode = typeof THEME_SLIDE_MODES[number]
6
+ export type ThemeTone = typeof THEME_TONES[number]
7
+ export type ThemeSlotSurface = typeof THEME_SLOT_SURFACES[number]
8
+
9
+ export type ResolvedThemeMode = {
10
+ mode: ThemeSlideMode
11
+ tone: ThemeTone
12
+ background: string
13
+ text: string
14
+ muted: string
15
+ contrast: boolean
16
+ }
17
+
18
+ export type ResolvedSlideTheme = ResolvedThemeMode
19
+
20
+ export type ResolvedSlotTheme = {
21
+ surface: ThemeSlotSurface
22
+ tone: ThemeTone
23
+ }
24
+
25
+ export type ResolveThemeModeOptions = {
26
+ mode?: unknown
27
+ tone?: unknown
28
+ defaultTone?: ThemeTone
29
+ label?: string
30
+ }
31
+
32
+ export type ResolveSurfaceToneOptions = {
33
+ surface?: unknown
34
+ tone?: unknown
35
+ defaultTone?: ThemeTone
36
+ label?: string
37
+ }
38
+
39
+ export type ResolveSlideThemeOptions = ResolveThemeModeOptions
40
+ export type ResolveSlotThemeOptions = ResolveSurfaceToneOptions
41
+
42
+ function fail(label: string, message: string): never {
43
+ throw new Error(`[${label}] ${message}`)
44
+ }
45
+
46
+ function normalizeString(value: unknown) {
47
+ if (typeof value !== 'string')
48
+ return null
49
+
50
+ const normalized = value.trim().toLowerCase()
51
+ return normalized || null
52
+ }
53
+
54
+ function resolveEnum<T extends string>(
55
+ value: unknown,
56
+ allowed: readonly T[],
57
+ label: string,
58
+ fallback?: T,
59
+ ) {
60
+ const normalized = normalizeString(value)
61
+ if (normalized == null) {
62
+ if (fallback)
63
+ return fallback
64
+
65
+ fail(label, `Значение обязательно. Допустимые варианты: ${allowed.join(', ')}.`)
66
+ }
67
+
68
+ if ((allowed as readonly string[]).includes(normalized))
69
+ return normalized as T
70
+
71
+ fail(label, `Недопустимое значение "${normalized}". Разрешены только: ${allowed.join(', ')}.`)
72
+ }
73
+
74
+ export function resolveSlideMode(
75
+ value: unknown,
76
+ label = 'Slide.mode',
77
+ fallback: ThemeSlideMode = 'light',
78
+ ) {
79
+ return resolveEnum(value, THEME_SLIDE_MODES, label, fallback)
80
+ }
81
+
82
+ export function resolveTone(
83
+ value: unknown,
84
+ label = 'tone',
85
+ fallback: ThemeTone = 'blue',
86
+ ) {
87
+ return resolveEnum(value, THEME_TONES, label, fallback)
88
+ }
89
+
90
+ export function resolveSlotSurface(
91
+ value: unknown,
92
+ label = 'surface',
93
+ fallback: ThemeSlotSurface = 'none',
94
+ ) {
95
+ return resolveEnum(value, THEME_SLOT_SURFACES, label, fallback)
96
+ }
97
+
98
+ export function isContrastSlideMode(mode: ThemeSlideMode) {
99
+ return mode !== 'light'
100
+ }
101
+
102
+ export function resolveThemeMode(options: ResolveThemeModeOptions = {}): ResolvedThemeMode {
103
+ const label = options.label ?? 'Slide'
104
+ const mode = resolveSlideMode(options.mode, `${label}.mode`, 'light')
105
+ const tone = resolveTone(options.tone, `${label}.tone`, options.defaultTone ?? 'blue')
106
+
107
+ if (mode === 'light') {
108
+ return {
109
+ mode,
110
+ tone,
111
+ background: 'var(--theme-color-light-0)',
112
+ text: 'var(--theme-color-dark-0)',
113
+ muted: 'var(--theme-color-dark-2)',
114
+ contrast: false,
115
+ }
116
+ }
117
+
118
+ if (mode === 'dark') {
119
+ return {
120
+ mode,
121
+ tone,
122
+ background: 'var(--theme-color-dark-0)',
123
+ text: 'var(--theme-color-light-0)',
124
+ muted: 'var(--theme-text-muted-on-contrast)',
125
+ contrast: true,
126
+ }
127
+ }
128
+
129
+ return {
130
+ mode,
131
+ tone,
132
+ background: `var(--theme-color-${tone}-0)`,
133
+ text: 'var(--theme-color-light-0)',
134
+ muted: 'var(--theme-text-muted-on-contrast)',
135
+ contrast: true,
136
+ }
137
+ }
138
+
139
+ export function resolveSurfaceTone(options: ResolveSurfaceToneOptions = {}): ResolvedSlotTheme {
140
+ const label = options.label ?? 'Slot'
141
+ const surface = resolveSlotSurface(options.surface, `${label}.surface`, 'none')
142
+ const rawTone = normalizeString(options.tone)
143
+
144
+ if (surface !== 'color' && surface !== 'tint') {
145
+ if (rawTone) {
146
+ fail(label, `tone разрешён только для surface="color" или surface="tint". Получено: "${rawTone}".`)
147
+ }
148
+
149
+ return {
150
+ surface,
151
+ tone: options.defaultTone ?? 'blue',
152
+ }
153
+ }
154
+
155
+ return {
156
+ surface,
157
+ tone: resolveTone(rawTone, `${label}.tone`, options.defaultTone ?? 'blue'),
158
+ }
159
+ }
160
+
161
+ export const resolveSlideTheme = resolveThemeMode
162
+ export const resolveSlotTheme = resolveSurfaceTone
163
+
164
+ export function themeVars(scope: 'slide', theme: ResolvedSlideTheme): Record<string, string>
165
+ export function themeVars(scope: 'slot', theme: ResolvedSlotTheme): Record<string, string>
166
+ export function themeVars(
167
+ scope: 'slide' | 'slot',
168
+ theme: ResolvedSlideTheme | ResolvedSlotTheme,
169
+ ): Record<string, string> {
170
+ if (scope === 'slide') {
171
+ const slideTheme = theme as ResolvedSlideTheme
172
+
173
+ return {
174
+ '--theme-bg': slideTheme.background,
175
+ '--theme-text': slideTheme.text,
176
+ '--theme-text-muted': slideTheme.muted,
177
+ '--theme-inline-code-text': slideTheme.text,
178
+ '--theme-text-on-dark': 'var(--theme-color-light-0)',
179
+ '--theme-link': slideTheme.contrast ? 'var(--theme-color-light-0)' : 'var(--theme-current-color)',
180
+ '--theme-current-color': `var(--theme-color-${slideTheme.tone}-0)`,
181
+ }
182
+ }
183
+
184
+ const slotTheme = theme as ResolvedSlotTheme
185
+ if (slotTheme.surface !== 'color' && slotTheme.surface !== 'tint')
186
+ return {}
187
+
188
+ return {
189
+ '--theme-slot-color': `var(--theme-color-${slotTheme.tone}-0)`,
190
+ '--theme-slot-tint': `var(--theme-color-${slotTheme.tone}-1, color-mix(in srgb, var(--theme-color-${slotTheme.tone}-0) 40%, var(--theme-color-light-0)))`,
191
+ }
192
+ }
@@ -0,0 +1,202 @@
1
+ import {
2
+ DEFAULT_DECOR_CATALOG,
3
+ hasDecorInput,
4
+ normalizeDecorCatalog,
5
+ resolveDecorImage,
6
+ } from './decor-config.mjs'
7
+ import {
8
+ isSvgImageSource,
9
+ parseImageBackgrounds,
10
+ parseImageConfig,
11
+ resolveImageMaskSize,
12
+ } from './image-config.mjs'
13
+
14
+ /**
15
+ * @typedef {Record<string, unknown>} PlainRecord
16
+ * @typedef {import('./image-config.mjs').NormalizedImageData} NormalizedImageData
17
+ * @typedef {import('./decor-config.mjs').NormalizedDecorCatalog} NormalizedDecorCatalog
18
+ * @typedef {{ cols?: number, rows?: number, label?: string }} GridFootprint
19
+ * @typedef {{ key: string, image: NormalizedImageData, render: { kind: 'img' | 'mask', decorative: boolean, alt: string, cssVars: Record<string, string> } }} ThemeMediaLayer
20
+ * @typedef {{ background?: unknown, decor?: unknown }} ThemeSlotLayerInput
21
+ * @typedef {{ label?: string, footprint?: GridFootprint | null, seed?: string, tone?: string, contrast?: boolean }} ThemeSlotMediaContext
22
+ * @typedef {{ baseCatalog?: readonly object[] | NormalizedDecorCatalog, themeCatalog?: readonly object[], warn?: (message: string) => void }} ThemeMediaInput
23
+ * @typedef {{ key?: string, alt?: string, decorative?: boolean }} LayerOptions
24
+ */
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @returns {value is PlainRecord}
29
+ */
30
+ function isRecord(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
32
+ }
33
+
34
+ /**
35
+ * @param {unknown} value
36
+ * @returns {value is NormalizedDecorCatalog}
37
+ */
38
+ function isNormalizedDecorCatalog(value) {
39
+ return isRecord(value)
40
+ && Array.isArray(value.variants)
41
+ && value.byId instanceof Map
42
+ }
43
+
44
+ /**
45
+ * @param {unknown} value
46
+ */
47
+ function hasBackgroundInput(value) {
48
+ if (value === undefined || value === null || value === '')
49
+ return false
50
+
51
+ if (Array.isArray(value))
52
+ return value.some(item => item !== undefined && item !== null && item !== '')
53
+
54
+ return true
55
+ }
56
+
57
+ /**
58
+ * @param {readonly object[] | NormalizedDecorCatalog | undefined} baseCatalog
59
+ * @param {readonly object[] | undefined} themeCatalog
60
+ * @returns {NormalizedDecorCatalog}
61
+ */
62
+ function resolveCatalog(baseCatalog, themeCatalog) {
63
+ const base = isNormalizedDecorCatalog(baseCatalog)
64
+ ? baseCatalog
65
+ : normalizeDecorCatalog(Array.isArray(baseCatalog) ? baseCatalog : [])
66
+
67
+ if (!Array.isArray(themeCatalog) || themeCatalog.length === 0)
68
+ return base
69
+
70
+ return normalizeDecorCatalog([...base.variants], themeCatalog)
71
+ }
72
+
73
+ /**
74
+ * @param {NormalizedImageData} image
75
+ * @param {'img' | 'mask'} kind
76
+ * @returns {Record<string, string>}
77
+ */
78
+ function cssVarsForImage(image, kind) {
79
+ return {
80
+ '--theme-image-background': image.backgroundColor || 'transparent',
81
+ '--theme-image-fit': image.fit,
82
+ '--theme-image-position': image.position,
83
+ '--theme-image-origin': image.anchor,
84
+ '--theme-image-x': image.x,
85
+ '--theme-image-y': image.y,
86
+ '--theme-image-zoom': String(image.zoom),
87
+ '--theme-image-rotate': image.rotate,
88
+ '--theme-image-opacity': String(image.opacity),
89
+ ...(kind === 'mask'
90
+ ? {
91
+ '--theme-image-color': image.color || 'currentColor',
92
+ '--theme-image-mask': `url("${image.src}")`,
93
+ '--theme-image-mask-size': resolveImageMaskSize(image.fit),
94
+ }
95
+ : {}),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {NormalizedImageData} image
101
+ * @param {LayerOptions} [options]
102
+ * @returns {ThemeMediaLayer}
103
+ */
104
+ function createLayer(image, options = {}) {
105
+ const kind = image.color && isSvgImageSource(image.src) ? 'mask' : 'img'
106
+ const alt = typeof options.alt === 'string' ? options.alt : ''
107
+ const decorative = options.decorative ?? !alt
108
+
109
+ return {
110
+ key: options.key || `${kind}:${image.src}:${image.fit}:${image.position}:${image.zoom}:${image.color}:${image.opacity}`,
111
+ image,
112
+ render: {
113
+ kind,
114
+ decorative,
115
+ alt,
116
+ cssVars: cssVarsForImage(image, kind),
117
+ },
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @param {ThemeMediaInput} [input]
123
+ */
124
+ export function createThemeMedia(input = {}) {
125
+ const catalog = resolveCatalog(input.baseCatalog ?? DEFAULT_DECOR_CATALOG, input.themeCatalog)
126
+ const warn = typeof input.warn === 'function' ? input.warn : undefined
127
+
128
+ return {
129
+ /**
130
+ * @param {ThemeSlotLayerInput} [layerInput]
131
+ * @param {ThemeSlotMediaContext} [context]
132
+ * @returns {ThemeMediaLayer[]}
133
+ */
134
+ resolveSlotLayers(layerInput = {}, context = {}) {
135
+ const hasBackground = hasBackgroundInput(layerInput.background)
136
+ const hasDecor = layerInput.decor ? hasDecorInput(layerInput.decor) : false
137
+ const label = context.label || 'slot'
138
+
139
+ if (hasBackground && hasDecor)
140
+ throw new Error(`[${label}] background and decor cannot be used together.`)
141
+
142
+ if (hasDecor) {
143
+ if (!context.footprint)
144
+ return []
145
+
146
+ const image = resolveDecorImage(layerInput.decor, {
147
+ catalog: catalog.variants,
148
+ footprint: context.footprint,
149
+ seed: context.seed,
150
+ warn,
151
+ label: `${label}.decor`,
152
+ })
153
+
154
+ return image
155
+ ? [createLayer(image, { key: `${label}.decor:${image.src}:${context.seed ?? ''}`, decorative: true })]
156
+ : []
157
+ }
158
+
159
+ if (!hasBackground)
160
+ return []
161
+
162
+ return parseImageBackgrounds(
163
+ /** @type {Parameters<typeof parseImageBackgrounds>[0]} */ (layerInput.background),
164
+ {},
165
+ `${label}.background`,
166
+ )
167
+ .map((image, index) =>
168
+ createLayer(image, { key: `${label}.background:${index}:${image.src}`, decorative: true }),
169
+ )
170
+ },
171
+
172
+ /**
173
+ * @param {unknown} imageInput
174
+ * @param {string} [label]
175
+ * @returns {ThemeMediaLayer | null}
176
+ */
177
+ resolveImage(imageInput, label = 'image') {
178
+ if (!imageInput)
179
+ return null
180
+
181
+ if (typeof imageInput === 'string' && !imageInput.trim())
182
+ return null
183
+
184
+ if (isRecord(imageInput) && typeof imageInput.src === 'string' && !imageInput.src.trim())
185
+ return null
186
+
187
+ const image = parseImageConfig(
188
+ /** @type {Parameters<typeof parseImageConfig>[0]} */ (imageInput),
189
+ {},
190
+ label,
191
+ )
192
+ if (!image)
193
+ return null
194
+
195
+ return createLayer(image, {
196
+ key: `${label}:${image.src}`,
197
+ alt: image.alt,
198
+ decorative: !image.alt,
199
+ })
200
+ },
201
+ }
202
+ }