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,891 @@
1
+ import { Text as NativeText, cloneVNode, h, isVNode, withCtx, type Component, type VNode } from 'vue'
2
+ import { extractMarkdownHeading, extractMarkdownQuote } from './layout-markdown.mjs'
3
+ import type { ThemeLayoutRole } from './layout-registry'
4
+ import { isBlankTextVNode, isNamedComponentVNode, readVNodeProp, readVNodeTypeName } from './layout-vnode'
5
+ import type { ThemeTextSizeInput } from './text-fit-runtime'
6
+
7
+ export type LayoutShorthandComponents = {
8
+ Image: Component
9
+ Person: Component
10
+ Slot: Component
11
+ Text: Component
12
+ Timeline: Component
13
+ }
14
+
15
+ export type LayoutShorthandContext = {
16
+ variant: string
17
+ readFrontmatterValue: (key: string) => unknown
18
+ readFrontmatterText: (key: string) => string
19
+ readFrontmatterArray: (key: string) => unknown[]
20
+ readFrontmatterObject: (key: string) => Record<string, unknown> | null
21
+ frontmatterDecor: Record<string, unknown> | null
22
+ agendaDecor: Record<string, unknown> | null
23
+ components: LayoutShorthandComponents
24
+ }
25
+
26
+ type LayoutShorthandBuilder = (children: VNode[], context: LayoutShorthandContext) => VNode[]
27
+
28
+ function failMarkdownContract(label: string, message: string): never {
29
+ throw new Error(`[Slide] ${label} ${message}`)
30
+ }
31
+
32
+ function isHeadingNode(node: VNode) {
33
+ return typeof node.type === 'string' && /^h[1-6]$/.test(node.type)
34
+ }
35
+
36
+ function isTextNode(node: VNode) {
37
+ return node.type === NativeText
38
+ }
39
+
40
+ function isListNode(node: VNode) {
41
+ return node.type === 'ul' || node.type === 'ol'
42
+ }
43
+
44
+ function isParagraphNode(node: VNode) {
45
+ return node.type === 'p'
46
+ }
47
+
48
+ function isBlockquoteNode(node: VNode) {
49
+ return node.type === 'blockquote'
50
+ }
51
+
52
+ function readTextNode(node: VNode) {
53
+ return typeof node.children === 'string' ? node.children : ''
54
+ }
55
+
56
+ function normalizeInlineText(value: string) {
57
+ return value.replace(/\s+/g, ' ').trim()
58
+ }
59
+
60
+ function readInlineText(node: VNode): string {
61
+ if (node.type === NativeText)
62
+ return normalizeInlineText(readTextNode(node))
63
+
64
+ if (typeof node.children === 'string')
65
+ return normalizeInlineText(node.children)
66
+
67
+ if (Array.isArray(node.children))
68
+ return normalizeInlineText(node.children.filter(isVNode).map(readInlineText).join(' '))
69
+
70
+ return ''
71
+ }
72
+
73
+ function isBlankTextNode(node: VNode) {
74
+ return isBlankTextVNode(node)
75
+ }
76
+
77
+ function ensureOnlySupportedChildren(label: string, children: VNode[], allow: (node: VNode) => boolean) {
78
+ const unsupportedNodes = children.filter(node => !allow(node))
79
+ if (!unsupportedNodes.length)
80
+ return
81
+
82
+ const unsupportedLabels = unsupportedNodes.map(readVNodeTypeName).join(', ')
83
+ failMarkdownContract(label, `поддерживает только каноническую markdown-структуру. Неподдерживаемые children: ${unsupportedLabels}.`)
84
+ }
85
+
86
+ function readHeadingText(children: VNode[], label: string) {
87
+ const heading = children.find(isHeadingNode)
88
+ if (heading)
89
+ return { node: heading, text: readInlineText(heading) }
90
+
91
+ const headingTextNode = children.find((node) => {
92
+ if (!isTextNode(node))
93
+ return false
94
+
95
+ return extractMarkdownHeading(readTextNode(node)) != null
96
+ })
97
+ const parsedHeading = headingTextNode ? extractMarkdownHeading(readTextNode(headingTextNode)) : null
98
+
99
+ if (parsedHeading)
100
+ return { node: headingTextNode, text: parsedHeading.text }
101
+
102
+ failMarkdownContract(label, 'ожидает один markdown heading внутри default slot.')
103
+ }
104
+
105
+ function readParagraphText(children: VNode[]) {
106
+ const paragraph = children.find(isParagraphNode)
107
+ if (paragraph)
108
+ return { node: paragraph, text: readInlineText(paragraph) }
109
+
110
+ return null
111
+ }
112
+
113
+ function hText(
114
+ context: LayoutShorthandContext,
115
+ as: string,
116
+ size: ThemeTextSizeInput,
117
+ content: string,
118
+ options: {
119
+ muted?: boolean
120
+ maxSize?: boolean
121
+ priority?: 1 | 2 | 3
122
+ align?: 'start' | 'middle' | 'end'
123
+ className?: string
124
+ typography?: 'compact'
125
+ } = {},
126
+ ) {
127
+ const textProps: {
128
+ as: string
129
+ size: ThemeTextSizeInput
130
+ muted?: boolean
131
+ maxSize?: boolean
132
+ priority?: 1 | 2 | 3
133
+ align?: 'start' | 'middle' | 'end'
134
+ class?: string
135
+ } & {
136
+ 'data-typography'?: 'compact'
137
+ } = {
138
+ as,
139
+ size,
140
+ }
141
+
142
+ if (options.muted !== undefined)
143
+ textProps.muted = options.muted
144
+ if (options.maxSize !== undefined)
145
+ textProps.maxSize = options.maxSize
146
+ if (options.priority !== undefined)
147
+ textProps.priority = options.priority
148
+ if (options.align !== undefined)
149
+ textProps.align = options.align
150
+ if (options.className)
151
+ textProps.class = options.className
152
+ if (options.typography)
153
+ textProps['data-typography'] = options.typography
154
+
155
+ return h(context.components.Text, {
156
+ ...textProps,
157
+ }, {
158
+ default: withCtx(() => [content]),
159
+ })
160
+ }
161
+
162
+ function hSlot(
163
+ context: LayoutShorthandContext,
164
+ role: ThemeLayoutRole,
165
+ label: string,
166
+ slotProps: Record<string, unknown>,
167
+ children: VNode[],
168
+ ) {
169
+ return h(context.components.Slot, {
170
+ role,
171
+ label,
172
+ ...slotProps,
173
+ }, {
174
+ default: withCtx(() => children),
175
+ })
176
+ }
177
+
178
+ function readRecordText(record: Record<string, unknown>, keys: readonly string[]) {
179
+ for (const key of keys) {
180
+ const value = record[key]
181
+
182
+ if (typeof value === 'string' && value.trim())
183
+ return value.trim()
184
+
185
+ if (typeof value === 'number')
186
+ return String(value)
187
+ }
188
+
189
+ return ''
190
+ }
191
+
192
+ function readRecordInput(record: Record<string, unknown>, keys: readonly string[]) {
193
+ for (const key of keys) {
194
+ const value = record[key]
195
+
196
+ if (typeof value === 'string' && value.trim())
197
+ return value.trim()
198
+
199
+ if (typeof value === 'number')
200
+ return value
201
+ }
202
+
203
+ return undefined
204
+ }
205
+
206
+ function readListItems(list: VNode, label: string) {
207
+ if (!Array.isArray(list.children))
208
+ failMarkdownContract(label, 'ожидает list items с текстом.')
209
+
210
+ return list.children
211
+ .filter(isVNode)
212
+ .filter(node => node.type === 'li')
213
+ .map(readInlineText)
214
+ .filter(Boolean)
215
+ }
216
+
217
+ type NestedListItem = { value: string, label: string }
218
+
219
+ function readNestedListItems(list: VNode, label: string): NestedListItem[] {
220
+ if (!Array.isArray(list.children))
221
+ failMarkdownContract(label, 'ожидает list items с текстом.')
222
+
223
+ return list.children
224
+ .filter(isVNode)
225
+ .filter(node => node.type === 'li')
226
+ .map((li) => {
227
+ if (!Array.isArray(li.children)) {
228
+ const text = typeof li.children === 'string' ? normalizeInlineText(li.children) : ''
229
+ return { value: text, label: '' }
230
+ }
231
+
232
+ const childNodes = li.children.filter(isVNode)
233
+ const nestedList = childNodes.find(node => node.type === 'ul' || node.type === 'ol')
234
+ const valueNodes = childNodes.filter(node => node !== nestedList)
235
+ const value = normalizeInlineText(valueNodes.map(readInlineText).join(' '))
236
+
237
+ let label = ''
238
+ if (nestedList && Array.isArray(nestedList.children)) {
239
+ const firstNestedLi = nestedList.children
240
+ .filter(isVNode)
241
+ .find(node => node.type === 'li')
242
+
243
+ if (firstNestedLi)
244
+ label = readInlineText(firstNestedLi)
245
+ }
246
+
247
+ return { value, label }
248
+ })
249
+ .filter(item => item.value)
250
+ }
251
+
252
+ export function shouldUseExplicitRoleChildren(
253
+ children: VNode[],
254
+ shorthandKey: string,
255
+ readRole: (node: VNode) => string,
256
+ ) {
257
+ const roles = children
258
+ .map(readRole)
259
+ .filter(Boolean)
260
+
261
+ if (!roles.length)
262
+ return false
263
+
264
+ return shorthandKey !== 'collection:agenda' || roles.some(role => role !== 'media')
265
+ }
266
+
267
+ function buildAgendaChildren(children: VNode[], context: LayoutShorthandContext) {
268
+ const title = children.find(isHeadingNode)
269
+ const headingTextNode = title
270
+ ? null
271
+ : children.find((node) => {
272
+ if (!isTextNode(node))
273
+ return false
274
+
275
+ return extractMarkdownHeading(readTextNode(node)) != null
276
+ })
277
+ const list = children.find(isListNode)
278
+ const mediaOverrides = children.filter(node => contextReadRole(context, node) === 'media')
279
+
280
+ const parsedHeading = headingTextNode ? extractMarkdownHeading(readTextNode(headingTextNode)) : null
281
+ const renderedTitle = title ?? (parsedHeading ? h(`h${parsedHeading.level}`, parsedHeading.text) : null)
282
+
283
+ if (!renderedTitle)
284
+ failMarkdownContract('collection:agenda', 'ожидает один markdown heading внутри default slot.')
285
+
286
+ if (!list)
287
+ failMarkdownContract('collection:agenda', 'ожидает один markdown list (`*`, `-` или `1.`) внутри default slot.')
288
+
289
+ if (mediaOverrides.length > 1)
290
+ failMarkdownContract('collection:agenda', 'поддерживает не больше одного override для `role="media"`.')
291
+
292
+ const mediaOverride = mediaOverrides[0]
293
+
294
+ ensureOnlySupportedChildren('collection:agenda', children, node =>
295
+ node === title
296
+ || node === headingTextNode
297
+ || node === list
298
+ || node === mediaOverride
299
+ || isBlankTextNode(node),
300
+ )
301
+
302
+ return [
303
+ h(context.components.Slot, {
304
+ role: 'primary',
305
+ label: `slide-collection-${context.variant}-primary`,
306
+ }, {
307
+ default: withCtx(() => [
308
+ h('div', { class: 'Slide-AgendaContent' }, [
309
+ isVNode(renderedTitle)
310
+ ? cloneVNode(renderedTitle, { class: 'Slide-AgendaTitle' })
311
+ : renderedTitle,
312
+ cloneVNode(list, { class: 'Slide-AgendaList' }),
313
+ ]),
314
+ ]),
315
+ }),
316
+ mediaOverride ?? h(context.components.Slot, {
317
+ role: 'media',
318
+ label: `slide-collection-${context.variant}-media`,
319
+ decor: context.agendaDecor,
320
+ }),
321
+ ]
322
+ }
323
+
324
+ function contextReadRole(context: LayoutShorthandContext, node: VNode) {
325
+ if (!isNamedComponentVNode(node, 'Slot', context.components.Slot))
326
+ return ''
327
+
328
+ const role = readVNodeProp<string>(node, 'role')
329
+ return typeof role === 'string' ? role.trim() : ''
330
+ }
331
+
332
+ function normalizeQuotePersonProps(context: LayoutShorthandContext) {
333
+ const person = context.readFrontmatterObject('person')
334
+ if (!person)
335
+ failMarkdownContract('message:quote', 'ожидает frontmatter person.')
336
+
337
+ const name = readRecordText(person, ['name'])
338
+ if (!name)
339
+ failMarkdownContract('message:quote', 'ожидает person.name во frontmatter.')
340
+
341
+ const personProps: Record<string, string | number> = { name }
342
+ const textProps = [
343
+ ['title', ['title']],
344
+ ['avatar', ['avatar']],
345
+ ['avatarFit', ['avatarFit', 'avatar-fit']],
346
+ ['avatarPosition', ['avatarPosition', 'avatar-position']],
347
+ ['avatarAnchor', ['avatarAnchor', 'avatar-anchor']],
348
+ ] as const
349
+
350
+ for (const [prop, keys] of textProps) {
351
+ const value = readRecordText(person, keys)
352
+ if (value)
353
+ personProps[prop] = value
354
+ }
355
+
356
+ const numericInputProps = [
357
+ ['avatarX', ['avatarX', 'avatar-x']],
358
+ ['avatarY', ['avatarY', 'avatar-y']],
359
+ ['avatarZoom', ['avatarZoom', 'avatar-zoom']],
360
+ ] as const
361
+
362
+ for (const [prop, keys] of numericInputProps) {
363
+ const value = readRecordInput(person, keys)
364
+ if (value !== undefined)
365
+ personProps[prop] = value
366
+ }
367
+
368
+ return personProps
369
+ }
370
+
371
+ function buildQuoteChildren(children: VNode[], context: LayoutShorthandContext) {
372
+ const quote = children.find(isBlockquoteNode)
373
+ const quoteTextNode = quote
374
+ ? null
375
+ : children.find((node) => {
376
+ if (!isTextNode(node))
377
+ return false
378
+
379
+ return extractMarkdownQuote(readTextNode(node)) != null
380
+ })
381
+ const personProps = normalizeQuotePersonProps(context)
382
+
383
+ const parsedQuote = quoteTextNode ? extractMarkdownQuote(readTextNode(quoteTextNode)) : null
384
+ const renderedQuote = quote ?? (parsedQuote
385
+ ? h('blockquote', [h('p', parsedQuote)])
386
+ : null)
387
+
388
+ if (!renderedQuote)
389
+ failMarkdownContract('message:quote', 'ожидает один blockquote (`> ...`) внутри default slot.')
390
+
391
+ ensureOnlySupportedChildren('message:quote', children, node =>
392
+ node === quote
393
+ || node === quoteTextNode
394
+ || isBlankTextNode(node),
395
+ )
396
+
397
+ return [
398
+ h(context.components.Slot, {
399
+ role: 'primary',
400
+ label: `slide-message-${context.variant}-primary`,
401
+ }, {
402
+ default: withCtx(() => [
403
+ isVNode(renderedQuote)
404
+ ? cloneVNode(renderedQuote, { class: 'Slide-Quote' })
405
+ : renderedQuote,
406
+ ]),
407
+ }),
408
+ h(context.components.Slot, {
409
+ role: 'support',
410
+ label: `slide-message-${context.variant}-support`,
411
+ }, {
412
+ default: withCtx(() => [
413
+ h(context.components.Person, {
414
+ ...personProps,
415
+ align: 'end',
416
+ }),
417
+ ]),
418
+ }),
419
+ ]
420
+ }
421
+
422
+ function buildCenteredChildren(children: VNode[], context: LayoutShorthandContext) {
423
+ const title = readHeadingText(children, 'message:centered')
424
+
425
+ ensureOnlySupportedChildren('message:centered', children, node =>
426
+ node === title.node
427
+ || isBlankTextNode(node),
428
+ )
429
+
430
+ return [
431
+ hSlot(context, 'primary', 'slide-message-centered-primary', { centered: true }, [
432
+ hText(context, 'h1', '7', title.text, { align: 'middle' }),
433
+ ]),
434
+ ]
435
+ }
436
+
437
+ function buildClosingChildren(children: VNode[], context: LayoutShorthandContext) {
438
+ const title = readHeadingText(children, 'message:closing')
439
+
440
+ ensureOnlySupportedChildren('message:closing', children, node =>
441
+ node === title.node
442
+ || isBlankTextNode(node),
443
+ )
444
+
445
+ return [
446
+ hSlot(context, 'primary', 'slide-message-closing-primary', { centered: true }, [
447
+ hText(context, 'h1', '7', title.text, { align: 'middle' }),
448
+ ]),
449
+ ]
450
+ }
451
+
452
+ function buildDefinitionChildren(children: VNode[], context: LayoutShorthandContext) {
453
+ const title = readHeadingText(children, 'explainer:definition')
454
+ const paragraph = readParagraphText(children)
455
+ const body = context.readFrontmatterText('body') || paragraph?.text || ''
456
+ const label = context.readFrontmatterText('label')
457
+
458
+ if (!body)
459
+ failMarkdownContract('explainer:definition', 'ожидает markdown paragraph или frontmatter body.')
460
+
461
+ ensureOnlySupportedChildren('explainer:definition', children, node =>
462
+ node === title.node
463
+ || node === paragraph?.node
464
+ || isBlankTextNode(node),
465
+ )
466
+
467
+ return [
468
+ hSlot(context, 'primary', 'slide-explainer-definition-primary', {}, [
469
+ ...(label ? [hText(context, 'div', '3', label, { muted: true, className: 'Slide-DefinitionLabel' })] : []),
470
+ hText(context, 'h1', '6', title.text, { priority: 1 }),
471
+ hText(context, 'div', '6', body, { muted: true, priority: 1 }),
472
+ ]),
473
+ ]
474
+ }
475
+
476
+ function buildTitleSupportsChildren(
477
+ children: VNode[],
478
+ context: LayoutShorthandContext,
479
+ options: { placement?: 'side' | 'bottom', muted?: boolean, supportSize?: ThemeTextSizeInput } = {},
480
+ ) {
481
+ const title = readHeadingText(children, 'explainer:title-supports')
482
+ const list = children.find(isListNode)
483
+ const placement = options.placement ?? 'side'
484
+ const muted = options.muted ?? true
485
+ const supportSize = options.supportSize ?? '4'
486
+
487
+ if (!list)
488
+ failMarkdownContract('explainer:title-supports', 'ожидает ненумерованный markdown list под heading.')
489
+
490
+ if (list.type !== 'ul')
491
+ failMarkdownContract('explainer:title-supports', 'ожидает ненумерованный markdown list (`-` или `*`).')
492
+
493
+ const supports = readListItems(list, 'explainer:title-supports')
494
+ if (!supports.length)
495
+ failMarkdownContract('explainer:title-supports', 'ожидает list items с текстом.')
496
+
497
+ if (placement === 'bottom' && supports.length !== 2)
498
+ failMarkdownContract('explainer:title-supports', 'ожидает ровно два list items для нижнего 50/50 layout.')
499
+
500
+ ensureOnlySupportedChildren('explainer:title-supports', children, node =>
501
+ node === title.node
502
+ || node === list
503
+ || isBlankTextNode(node),
504
+ )
505
+
506
+ if (placement === 'bottom') {
507
+ return [
508
+ hSlot(context, 'primary', 'slide-explainer-title-supports-bottom-primary', {}, [
509
+ hText(context, 'h1', '6', title.text),
510
+ ]),
511
+ ...supports.map((item, index) =>
512
+ hSlot(context, 'support', `slide-explainer-title-supports-bottom-support-${index + 1}`, {}, [
513
+ hText(context, 'div', supportSize, item, { muted }),
514
+ ]),
515
+ ),
516
+ ]
517
+ }
518
+
519
+ return [
520
+ hSlot(context, 'primary', 'slide-explainer-title-supports-primary', {}, [
521
+ hText(context, 'h1', '6', title.text),
522
+ ]),
523
+ hSlot(context, 'secondary', 'slide-explainer-title-supports-secondary', {}, [
524
+ cloneVNode(list, { class: 'Slide-TitleSupportsList Slide-TitleSupportsList_size_large' }),
525
+ ]),
526
+ ]
527
+ }
528
+
529
+ function isRichTextBodyNode(node: VNode) {
530
+ return isParagraphNode(node) || isListNode(node) || isBlockquoteNode(node)
531
+ }
532
+
533
+ function buildTitleBodyChildren(children: VNode[], context: LayoutShorthandContext) {
534
+ const title = readHeadingText(children, 'explainer:title-body')
535
+ const bodyNodes = children.filter(node =>
536
+ node !== title.node
537
+ && !isBlankTextNode(node),
538
+ )
539
+
540
+ if (!bodyNodes.length)
541
+ failMarkdownContract('explainer:title-body', 'ожидает markdown paragraph, list или blockquote под heading.')
542
+
543
+ ensureOnlySupportedChildren('explainer:title-body', children, node =>
544
+ node === title.node
545
+ || isRichTextBodyNode(node)
546
+ || isBlankTextNode(node),
547
+ )
548
+
549
+ return [
550
+ hSlot(context, 'primary', 'slide-explainer-title-body-primary', {}, [
551
+ hText(context, 'h1', '6', title.text),
552
+ ]),
553
+ hSlot(context, 'secondary', 'slide-explainer-title-body-secondary', {}, [
554
+ h('div', { class: 'Slide-TitleBody' }, bodyNodes.map(node => cloneVNode(node))),
555
+ ]),
556
+ ]
557
+ }
558
+
559
+ function buildPointsChildren(children: VNode[], context: LayoutShorthandContext) {
560
+ const title = readHeadingText(children, 'collection:points')
561
+ const list = children.find(isListNode)
562
+
563
+ if (!list)
564
+ failMarkdownContract('collection:points', 'ожидает markdown list с тремя пунктами.')
565
+
566
+ const items = readListItems(list, 'collection:points')
567
+ if (items.length !== 3)
568
+ failMarkdownContract('collection:points', 'ожидает ровно три пункта для arrangement: trio.')
569
+
570
+ ensureOnlySupportedChildren('collection:points', children, node =>
571
+ node === title.node
572
+ || node === list
573
+ || isBlankTextNode(node),
574
+ )
575
+
576
+ return [
577
+ hSlot(context, 'primary', 'slide-collection-points-primary', {
578
+ ...(context.frontmatterDecor ? { decor: context.frontmatterDecor } : {}),
579
+ }, [
580
+ hText(context, 'h1', '7', title.text, { className: 'Slide-PointsTitle' }),
581
+ ]),
582
+ ...items.map((item, index) =>
583
+ hSlot(context, 'support', `slide-collection-points-support-${index + 1}`, {}, [
584
+ hText(context, 'div', '4', String(index + 1)),
585
+ hText(context, 'div', '5', item, { align: 'end' }),
586
+ ]),
587
+ ),
588
+ ]
589
+ }
590
+
591
+ function normalizeTimelineItems(context: LayoutShorthandContext) {
592
+ const items = context.readFrontmatterArray('items')
593
+
594
+ if (!items.length)
595
+ failMarkdownContract('collection:timeline', 'ожидает frontmatter items.')
596
+
597
+ return items.map((item, index) => {
598
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
599
+ failMarkdownContract('collection:timeline', `ожидает object item в items[${index}].`)
600
+
601
+ const record = item as Record<string, unknown>
602
+ const year = readRecordText(record, ['year', 'label'])
603
+ const label = readRecordText(record, ['body', 'label', 'title'])
604
+
605
+ if (!year || !label)
606
+ failMarkdownContract('collection:timeline', `ожидает year и body/label в items[${index}].`)
607
+
608
+ return {
609
+ year,
610
+ label,
611
+ active: record.active === true,
612
+ }
613
+ })
614
+ }
615
+
616
+ function buildTimelineChildren(children: VNode[], context: LayoutShorthandContext) {
617
+ const title = readHeadingText(children, 'collection:timeline')
618
+ const paragraph = readParagraphText(children)
619
+ const body = context.readFrontmatterText('body') || paragraph?.text || ''
620
+
621
+ ensureOnlySupportedChildren('collection:timeline', children, node =>
622
+ node === title.node
623
+ || node === paragraph?.node
624
+ || isBlankTextNode(node),
625
+ )
626
+
627
+ return [
628
+ hSlot(context, 'primary', 'slide-collection-timeline-primary', {}, [
629
+ hText(context, 'h1', '6', title.text),
630
+ ]),
631
+ ...(body
632
+ ? [
633
+ hSlot(context, 'secondary', 'slide-collection-timeline-secondary', {}, [
634
+ hText(context, 'div', '3', body, { muted: true }),
635
+ ]),
636
+ ]
637
+ : []),
638
+ hSlot(context, 'support', 'slide-collection-timeline-support', {}, [
639
+ h(context.components.Timeline, { items: normalizeTimelineItems(context) }),
640
+ ]),
641
+ ]
642
+ }
643
+
644
+ function normalizeMetricItems(context: LayoutShorthandContext) {
645
+ const items = context.readFrontmatterArray('metrics')
646
+
647
+ if (items.length < 3)
648
+ failMarkdownContract('collection:metrics', 'ожидает минимум три элемента во frontmatter metrics.')
649
+
650
+ return items.map((item, index) => {
651
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
652
+ failMarkdownContract('collection:metrics', `ожидает object item в metrics[${index}].`)
653
+
654
+ const record = item as Record<string, unknown>
655
+ const value = readRecordText(record, ['value'])
656
+ const label = readRecordText(record, ['body', 'label', 'title'])
657
+
658
+ if (!value || !label)
659
+ failMarkdownContract('collection:metrics', `ожидает value и body/label в metrics[${index}].`)
660
+
661
+ return {
662
+ value,
663
+ label,
664
+ featured: record.featured === true,
665
+ }
666
+ })
667
+ }
668
+
669
+ function normalizeMetricItemsFromList(list: VNode) {
670
+ const entries = readNestedListItems(list, 'collection:metrics')
671
+
672
+ if (entries.length < 3)
673
+ failMarkdownContract('collection:metrics', 'ожидает минимум три пункта в markdown-списке.')
674
+
675
+ return entries.map((entry, index) => {
676
+ if (!entry.label)
677
+ failMarkdownContract('collection:metrics', `ожидает вложенный пункт с подписью у metric[${index}].`)
678
+
679
+ return {
680
+ value: entry.value,
681
+ label: entry.label,
682
+ featured: index === 0,
683
+ }
684
+ })
685
+ }
686
+
687
+ function normalizeMediaItems(context: LayoutShorthandContext) {
688
+ const value = context.readFrontmatterValue('media')
689
+
690
+ if (typeof value === 'object' && value !== null && !Array.isArray(value))
691
+ return [value as Record<string, unknown>]
692
+
693
+ if (Array.isArray(value)) {
694
+ return value.map((item, index) => {
695
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
696
+ failMarkdownContract('collection:metrics', `ожидает object item в media[${index}].`)
697
+
698
+ return item as Record<string, unknown>
699
+ })
700
+ }
701
+
702
+ return []
703
+ }
704
+
705
+ function buildMetricsChildren(children: VNode[], context: LayoutShorthandContext) {
706
+ const list = children.find(isListNode)
707
+
708
+ ensureOnlySupportedChildren('collection:metrics', children, node =>
709
+ node === list
710
+ || isBlankTextNode(node),
711
+ )
712
+
713
+ const isFeaturedCopy = context.variant === 'metrics-featured-copy' || context.variant === 'metrics-featured-copy-split-media'
714
+ const isSplitMedia = context.variant === 'metrics-featured-copy-split-media'
715
+ const mediaItems = normalizeMediaItems(context)
716
+
717
+ if (!mediaItems.length)
718
+ failMarkdownContract('collection:metrics', 'ожидает frontmatter media.')
719
+
720
+ if (isSplitMedia && mediaItems.length < 2)
721
+ failMarkdownContract('collection:metrics', 'ожидает два frontmatter media item для arrangement: featured-copy-split-media.')
722
+
723
+ const metrics = list
724
+ ? normalizeMetricItemsFromList(list)
725
+ : normalizeMetricItems(context)
726
+ const featuredIndex = metrics.findIndex(metric => metric.featured)
727
+ const featured = metrics[featuredIndex >= 0 ? featuredIndex : 0]
728
+ const supportMetrics = metrics.filter(metric => metric !== featured).slice(0, 2)
729
+
730
+ if (supportMetrics.length < 2)
731
+ failMarkdownContract('collection:metrics', 'ожидает два вторичных metric item.')
732
+
733
+ const primaryChildren = isFeaturedCopy
734
+ ? [
735
+ h('div', { class: 'Slide-MetricsFeaturedCopy' }, [
736
+ h('div', { class: 'Slide-MetricsFeaturedCopyValue' }, [
737
+ hText(context, 'div', '7-12', featured.value, { priority: 1, align: 'middle' }),
738
+ ]),
739
+ h('div', { class: 'Slide-MetricsFeaturedCopyLabel' }, [
740
+ hText(context, 'div', '5-7', featured.label, { priority: 1, align: 'middle', muted: true }),
741
+ ]),
742
+ ]),
743
+ ]
744
+ : [
745
+ hText(context, 'div', '7-12', featured.value, { priority: 1, className: 'Slide-MetricsFeaturedText' }),
746
+ hText(context, 'div', '2-7', featured.label, { priority: 2, muted: true, className: 'Slide-MetricsFeaturedText' }),
747
+ ]
748
+ const mediaCount = isSplitMedia ? 2 : 1
749
+
750
+ return [
751
+ hSlot(context, 'primary', 'slide-collection-metrics-primary', {
752
+ ...(!isFeaturedCopy && context.frontmatterDecor ? { decor: context.frontmatterDecor } : {}),
753
+ gap: '3',
754
+ }, primaryChildren),
755
+ ...mediaItems.slice(0, mediaCount).map((media, index) =>
756
+ hSlot(context, 'media', index === 0 ? 'slide-collection-metrics-media' : `slide-collection-metrics-media-${index + 1}`, {}, [
757
+ h(context.components.Image, media),
758
+ ]),
759
+ ),
760
+ ...supportMetrics.map((metric, index) =>
761
+ hSlot(context, 'support', `slide-collection-metrics-support-${index + 1}`, { gap: '2' }, [
762
+ hText(context, 'div', '7', metric.value, { priority: 1 }),
763
+ hText(context, 'div', '2-6', metric.label, { priority: 2, muted: true, typography: 'compact' }),
764
+ ]),
765
+ ),
766
+ ]
767
+ }
768
+
769
+ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext) {
770
+ const heading = children.find(isHeadingNode)
771
+ const list = children.find(isListNode)
772
+ const paragraphs = children.filter(isParagraphNode)
773
+
774
+ if (!heading)
775
+ failMarkdownContract('collection:facts', 'ожидает один markdown heading внутри default slot.')
776
+
777
+ if (!list)
778
+ failMarkdownContract('collection:facts', 'ожидает один markdown список с фактами внутри default slot.')
779
+
780
+ const headingIndex = children.indexOf(heading)
781
+ const listIndex = children.indexOf(list)
782
+ const description = paragraphs.find((node) => {
783
+ const index = children.indexOf(node)
784
+ return index > headingIndex && index < listIndex
785
+ })
786
+ const footnote = paragraphs.find((node) => {
787
+ const index = children.indexOf(node)
788
+ return index > listIndex
789
+ })
790
+
791
+ ensureOnlySupportedChildren('collection:facts', children, node =>
792
+ node === heading
793
+ || node === list
794
+ || node === description
795
+ || node === footnote
796
+ || isBlankTextNode(node),
797
+ )
798
+
799
+ const facts = readNestedListItems(list, 'collection:facts')
800
+
801
+ if (facts.length < 3)
802
+ failMarkdownContract('collection:facts', 'ожидает минимум три факта в markdown-списке.')
803
+
804
+ facts.forEach((fact, index) => {
805
+ if (!fact.label)
806
+ failMarkdownContract('collection:facts', `ожидает вложенный пункт с подписью у facts[${index}].`)
807
+ })
808
+
809
+ const [featured, ...rest] = facts
810
+ const supportFacts = rest.slice(0, 2)
811
+
812
+ if (supportFacts.length < 2)
813
+ failMarkdownContract('collection:facts', 'ожидает минимум два дополнительных факта в markdown-списке.')
814
+
815
+ const isFeatured = context.variant === 'facts-featured'
816
+ const isStacked = context.variant === 'facts-stacked'
817
+ const headingText = readInlineText(heading)
818
+ const descriptionText = description ? readInlineText(description) : ''
819
+ const footnoteText = footnote ? readInlineText(footnote) : ''
820
+
821
+ const primaryChildren: VNode[] = [
822
+ hText(context, 'h2', isFeatured ? '5-7' : '6-8', headingText, { priority: 1 }),
823
+ ]
824
+
825
+ if (descriptionText)
826
+ primaryChildren.push(hText(context, 'p', isStacked ? '4-5' : '3-4', descriptionText, { priority: 2, muted: true }))
827
+
828
+ const featuredChildren: VNode[] = [
829
+ hText(context, 'div', isFeatured ? '9-12' : '7-10', featured.value, { priority: 1 }),
830
+ hText(context, 'div', isFeatured ? '4-6' : '3-5', featured.label, { priority: 2, muted: true }),
831
+ ]
832
+
833
+ if (footnoteText)
834
+ featuredChildren.push(hText(context, 'div', '2-3', footnoteText, { priority: 3, muted: true }))
835
+
836
+ return [
837
+ hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, { gap: '3' }, primaryChildren),
838
+ hSlot(context, 'support', `slide-collection-${context.variant}-support-1`, {
839
+ gap: '2',
840
+ centered: false,
841
+ }, featuredChildren),
842
+ ...supportFacts.map((fact, index) =>
843
+ hSlot(context, 'support', `slide-collection-${context.variant}-support-${index + 2}`, { gap: '2' }, [
844
+ hText(context, 'div', isFeatured ? '5-7' : '7-9', fact.value, { priority: 1 }),
845
+ hText(context, 'div', '2-4', fact.label, { priority: 2, muted: true }),
846
+ ]),
847
+ ),
848
+ ]
849
+ }
850
+
851
+ const LAYOUT_SHORTHANDS = {
852
+ 'collection:agenda': buildAgendaChildren,
853
+ 'collection:facts-stacked': buildFactsChildren,
854
+ 'collection:facts-trio': buildFactsChildren,
855
+ 'collection:facts-featured': buildFactsChildren,
856
+ 'collection:points-trio': buildPointsChildren,
857
+ 'collection:timeline': buildTimelineChildren,
858
+ 'collection:metrics-featured-media': buildMetricsChildren,
859
+ 'collection:metrics-featured-copy': buildMetricsChildren,
860
+ 'collection:metrics-featured-copy-split-media': buildMetricsChildren,
861
+ 'message:centered': buildCenteredChildren,
862
+ 'message:quote': buildQuoteChildren,
863
+ 'message:closing': buildClosingChildren,
864
+ 'explainer:definition': buildDefinitionChildren,
865
+ 'explainer:title-supports': buildTitleSupportsChildren,
866
+ 'explainer:title-supports-bottom-muted': (children: VNode[], context: LayoutShorthandContext) =>
867
+ buildTitleSupportsChildren(children, context, { placement: 'bottom', muted: true, supportSize: '5' }),
868
+ 'explainer:title-supports-bottom-plain': (children: VNode[], context: LayoutShorthandContext) =>
869
+ buildTitleSupportsChildren(children, context, { placement: 'bottom', muted: false, supportSize: '3' }),
870
+ 'explainer:title-body': buildTitleBodyChildren,
871
+ } satisfies Record<string, LayoutShorthandBuilder>
872
+
873
+ type LayoutShorthandKey = keyof typeof LAYOUT_SHORTHANDS
874
+
875
+ function isLayoutShorthandKey(key: string): key is LayoutShorthandKey {
876
+ return key in LAYOUT_SHORTHANDS
877
+ }
878
+
879
+ export function hasLayoutShorthand(key: string) {
880
+ return isLayoutShorthandKey(key)
881
+ }
882
+
883
+ export function buildLayoutShorthandChildren(
884
+ key: string,
885
+ children: VNode[],
886
+ context: LayoutShorthandContext,
887
+ ) {
888
+ return isLayoutShorthandKey(key)
889
+ ? LAYOUT_SHORTHANDS[key](children, context)
890
+ : null
891
+ }