slidev-theme-practicum 0.2.0 → 0.4.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 (50) hide show
  1. package/README.md +225 -22
  2. package/components/Slide.vue +12 -58
  3. package/components/Slot.vue +4 -3
  4. package/components/StepsGrid.vue +117 -0
  5. package/components/Text.vue +5 -0
  6. package/composables/deck-decors.ts +74 -0
  7. package/composables/deck-slot-markup.cjs +19 -5
  8. package/composables/decor-sources.ts +43 -0
  9. package/composables/layout-authoring.ts +34 -9
  10. package/composables/layout-recipes.ts +16 -2
  11. package/composables/layout-shorthands.ts +157 -83
  12. package/composables/local-layout-variant-files.ts +106 -0
  13. package/composables/local-layout-variants.ts +73 -0
  14. package/composables/slide-layout.ts +3 -0
  15. package/composables/text-fit-runtime.ts +7 -3
  16. package/composables/theme-foundation.ts +7 -3
  17. package/composables/typography-guard.cjs +59 -0
  18. package/composables/use-theme-config.ts +17 -10
  19. package/composables/validate-deck-layouts.cjs +95 -7
  20. package/composables/validate-deck-typography.cjs +101 -0
  21. package/env.d.ts +18 -0
  22. package/example.md +1 -1
  23. package/package.json +18 -6
  24. package/scripts/browser-smoke.mjs +85 -23
  25. package/scripts/check-accessibility.mjs +364 -0
  26. package/scripts/check-consumer.mjs +159 -0
  27. package/scripts/check-local-layout-variant-build.mjs +189 -0
  28. package/scripts/check-package.mjs +18 -2
  29. package/scripts/check-pixels.mjs +251 -0
  30. package/scripts/check-typography.mjs +108 -0
  31. package/scripts/requirements-illustrations.txt +1 -0
  32. package/scripts/test-typography.mjs +89 -0
  33. package/scripts/trace-line-art.py +422 -0
  34. package/scripts/typography-browser.mjs +134 -0
  35. package/scripts/validate-deck.cjs +23 -3
  36. package/setup/vite-plugins.ts +109 -2
  37. package/skills/slidev-practicum/SKILL.md +19 -4
  38. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  39. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  40. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  41. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  42. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  43. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  44. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  45. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  46. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  47. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  48. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
  49. package/styles/index.css +20 -27
  50. package/styles/vars.css +6 -2
@@ -8,6 +8,7 @@ export type LayoutShorthandComponents = {
8
8
  Image: Component
9
9
  Person: Component
10
10
  Slot: Component
11
+ StepsGrid: Component
11
12
  Text: Component
12
13
  Timeline: Component
13
14
  }
@@ -117,10 +118,64 @@ function readHeadingText(children: VNode[], label: string) {
117
118
  failMarkdownContract(label, 'ожидает один markdown heading внутри default slot.')
118
119
  }
119
120
 
121
+ function readRichHeadingChildren(node: VNode | null | undefined): Array<string | VNode> | null {
122
+ if (!node || !isHeadingNode(node))
123
+ return null
124
+
125
+ if (typeof node.children === 'string')
126
+ return [node.children]
127
+ if (!Array.isArray(node.children))
128
+ return null
129
+
130
+ return node.children.filter(child => isVNode(child) || typeof child === 'string') as Array<string | VNode>
131
+ }
132
+
133
+ function readRichNodeChildren(node: VNode | null | undefined): Array<string | VNode> | null {
134
+ if (!node || typeof node.children === 'string')
135
+ return null
136
+
137
+ if (!Array.isArray(node.children))
138
+ return null
139
+
140
+ return node.children.filter(child => isVNode(child) || typeof child === 'string') as Array<string | VNode>
141
+ }
142
+
143
+ function readHeadingSlotContent(node: VNode | null | undefined, textFallback: string) {
144
+ const richChildren = readRichHeadingChildren(node) ?? readRichNodeChildren(node)
145
+ return richChildren ?? readRichTextWithLineBreaks(textFallback)
146
+ }
147
+
148
+ function readParagraphSlotContent(node: VNode | null | undefined, textFallback: string) {
149
+ const richChildren = readRichNodeChildren(node)
150
+ return richChildren ?? readRichTextWithLineBreaks(textFallback)
151
+ }
152
+
153
+ function readRichTextWithLineBreaks(text: string): string | Array<string | VNode> {
154
+ const matches = text.split(/(<br\s*\/?>)/i)
155
+ if (matches.length === 1)
156
+ return text
157
+
158
+ return matches.flatMap<string | VNode>((value): Array<string | VNode> => {
159
+ if (!value.trim())
160
+ return []
161
+ if (/^<br\s*\/?>$/i.test(value))
162
+ return [h('br')]
163
+ return [value]
164
+ })
165
+ }
166
+
167
+ function hasRichSlotContent(content: Array<string | VNode>) {
168
+ return content.some(item => typeof item === 'string' ? item.trim() : isVNode(item))
169
+ }
170
+
120
171
  function readParagraphText(children: VNode[]) {
121
172
  const paragraph = children.find(isParagraphNode)
122
173
  if (paragraph)
123
- return { node: paragraph, text: readInlineText(paragraph) }
174
+ return {
175
+ node: paragraph,
176
+ text: readInlineText(paragraph),
177
+ content: readParagraphSlotContent(paragraph, readInlineText(paragraph)),
178
+ }
124
179
 
125
180
  return null
126
181
  }
@@ -129,7 +184,7 @@ function hText(
129
184
  context: LayoutShorthandContext,
130
185
  as: string,
131
186
  size: ThemeTextSizeInput,
132
- content: string,
187
+ content: string | VNode | Array<string | VNode>,
133
188
  options: {
134
189
  muted?: boolean
135
190
  maxSize?: boolean
@@ -171,10 +226,12 @@ function hText(
171
226
  if (options.typography)
172
227
  textProps['data-typography'] = options.typography
173
228
 
229
+ const slotChildren = Array.isArray(content) ? content : [content]
230
+
174
231
  return h(context.components.Text, {
175
232
  ...textProps,
176
233
  }, {
177
- default: withCtx(() => [content]),
234
+ default: withCtx(() => slotChildren),
178
235
  })
179
236
  }
180
237
 
@@ -250,15 +307,26 @@ function readListItemText(li: VNode) {
250
307
  return normalizeInlineText(parts.join(' '))
251
308
  }
252
309
 
253
- function readListItems(list: VNode, label: string) {
254
- if (!Array.isArray(list.children))
255
- failMarkdownContract(label, 'ожидает list items с текстом.')
310
+ function readListItemSlotContent(li: VNode): Array<string | VNode> {
311
+ if (typeof li.children === 'string') {
312
+ const text = li.children
313
+ return text.trim() ? [text] : []
314
+ }
256
315
 
257
- return list.children
258
- .filter(isVNode)
259
- .filter(node => node.type === 'li')
260
- .map(readListItemText)
261
- .filter(Boolean)
316
+ if (!Array.isArray(li.children))
317
+ return []
318
+
319
+ return li.children.flatMap<string | VNode>((child): Array<string | VNode> => {
320
+ if (!isVNode(child)) {
321
+ const text = String(child)
322
+ return text.trim() ? [text] : []
323
+ }
324
+
325
+ if (child.type === 'ul' || child.type === 'ol')
326
+ return []
327
+
328
+ return [cloneVNode(child)]
329
+ })
262
330
  }
263
331
 
264
332
  function readListItemNodes(list: VNode, label: string) {
@@ -295,7 +363,7 @@ function readListItemContent(li: VNode): VNode[] {
295
363
  if (!Array.isArray(li.children))
296
364
  return []
297
365
 
298
- return li.children.flatMap((child) => {
366
+ return li.children.flatMap<VNode>((child): VNode[] => {
299
367
  if (!isVNode(child)) {
300
368
  const text = String(child).trim()
301
369
  return text ? [h('p', text)] : []
@@ -305,7 +373,10 @@ function readListItemContent(li: VNode): VNode[] {
305
373
  })
306
374
  }
307
375
 
308
- type NestedListItem = { value: string, label: string }
376
+ type NestedListItem = {
377
+ value: Array<string | VNode>
378
+ label: Array<string | VNode>
379
+ }
309
380
 
310
381
  function readNestedListItems(list: VNode, label: string): NestedListItem[] {
311
382
  if (!Array.isArray(list.children))
@@ -316,46 +387,38 @@ function readNestedListItems(list: VNode, label: string): NestedListItem[] {
316
387
  .filter(node => node.type === 'li')
317
388
  .map((li) => {
318
389
  if (!Array.isArray(li.children)) {
319
- const text = typeof li.children === 'string' ? normalizeInlineText(li.children) : ''
320
- return { value: text, label: '' }
390
+ const text = typeof li.children === 'string' ? li.children : ''
391
+ const value = text.trim() ? [text] : []
392
+ return { value, label: [] }
321
393
  }
322
394
 
323
- const valueParts: string[] = []
324
395
  let nestedList: VNode | undefined
325
-
326
- for (const child of li.children) {
396
+ const value = li.children.flatMap<string | VNode>((child): Array<string | VNode> => {
327
397
  if (!isVNode(child)) {
328
- const text = String(child ?? '').trim()
329
- if (text)
330
- valueParts.push(text)
331
- continue
398
+ const text = String(child)
399
+ return text.trim() ? [text] : []
332
400
  }
333
401
 
334
402
  if (child.type === 'ul' || child.type === 'ol') {
335
403
  nestedList = child
336
- continue
404
+ return []
337
405
  }
338
406
 
339
- const text = readInlineText(child).trim()
340
- if (text)
341
- valueParts.push(text)
342
- }
343
-
344
- const value = normalizeInlineText(valueParts.join(' '))
407
+ return [cloneVNode(child)]
408
+ })
345
409
 
346
- let label = ''
347
- if (nestedList && Array.isArray(nestedList.children)) {
348
- const firstNestedLi = nestedList.children
410
+ const nestedLabel = nestedList && Array.isArray(nestedList.children)
411
+ ? nestedList.children
349
412
  .filter(isVNode)
350
413
  .find(node => node.type === 'li')
414
+ : null
351
415
 
352
- if (firstNestedLi)
353
- label = readListItemText(firstNestedLi)
416
+ return {
417
+ value,
418
+ label: nestedLabel ? readListItemSlotContent(nestedLabel) : [],
354
419
  }
355
-
356
- return { value, label }
357
420
  })
358
- .filter(item => item.value)
421
+ .filter(item => hasRichSlotContent(item.value))
359
422
  }
360
423
 
361
424
  const FACTS_VARIANT_COUNTS: Record<string, number> = {
@@ -539,6 +602,7 @@ function buildQuoteChildren(children: VNode[], context: LayoutShorthandContext)
539
602
 
540
603
  function buildCenteredChildren(children: VNode[], context: LayoutShorthandContext) {
541
604
  const title = readHeadingText(children, 'message:centered')
605
+ const titleContent = readHeadingSlotContent(title.node, title.text)
542
606
 
543
607
  ensureOnlySupportedChildren('message:centered', children, node =>
544
608
  node === title.node
@@ -547,7 +611,7 @@ function buildCenteredChildren(children: VNode[], context: LayoutShorthandContex
547
611
 
548
612
  return [
549
613
  hSlot(context, 'primary', 'slide-message-centered-primary', { centered: true }, [
550
- hText(context, 'h1', '7', title.text, { align: 'middle' }),
614
+ hText(context, 'h1', '7-12', titleContent, { align: 'middle' }),
551
615
  ]),
552
616
  ]
553
617
  }
@@ -555,6 +619,7 @@ function buildCenteredChildren(children: VNode[], context: LayoutShorthandContex
555
619
  function buildClosingChildren(children: VNode[], context: LayoutShorthandContext) {
556
620
  const title = readHeadingText(children, 'message:closing')
557
621
  const paragraph = readParagraphText(children)
622
+ const titleContent = readHeadingSlotContent(title.node, title.text)
558
623
 
559
624
  ensureOnlySupportedChildren('message:closing', children, node =>
560
625
  node === title.node
@@ -564,9 +629,9 @@ function buildClosingChildren(children: VNode[], context: LayoutShorthandContext
564
629
 
565
630
  return [
566
631
  hSlot(context, 'primary', 'slide-message-closing-primary', { centered: true }, [
567
- hText(context, 'h1', '7', title.text, { align: 'middle' }),
632
+ hText(context, 'h1', '7', titleContent, { align: 'middle' }),
568
633
  ...(paragraph
569
- ? [hText(context, 'p', '3', paragraph.text, { muted: true })]
634
+ ? [hText(context, 'p', '3', paragraph.content, { muted: true })]
570
635
  : []),
571
636
  ]),
572
637
  ]
@@ -574,8 +639,9 @@ function buildClosingChildren(children: VNode[], context: LayoutShorthandContext
574
639
 
575
640
  function buildDefinitionChildren(children: VNode[], context: LayoutShorthandContext) {
576
641
  const title = readHeadingText(children, 'explainer:definition')
642
+ const titleContent = readHeadingSlotContent(title.node, title.text)
577
643
  const paragraph = readParagraphText(children)
578
- const body = context.readFrontmatterText('body') || paragraph?.text || ''
644
+ const body = context.readFrontmatterText('body') || paragraph?.content || ''
579
645
  const label = context.readFrontmatterText('label')
580
646
 
581
647
  if (!body)
@@ -590,7 +656,7 @@ function buildDefinitionChildren(children: VNode[], context: LayoutShorthandCont
590
656
  return [
591
657
  hSlot(context, 'primary', 'slide-explainer-definition-primary', {}, [
592
658
  ...(label ? [hText(context, 'div', '3', label, { muted: true, className: 'Slide-DefinitionLabel' })] : []),
593
- hText(context, 'h1', '6', title.text, { priority: 1 }),
659
+ hText(context, 'h1', '6', titleContent, { priority: 1 }),
594
660
  hText(context, 'div', '6', body, { muted: true, priority: 1 }),
595
661
  ]),
596
662
  ]
@@ -607,6 +673,7 @@ function buildTitleRichSideChildren(
607
673
  options: { muted?: boolean } = {},
608
674
  ) {
609
675
  const title = readHeadingText(children, contract)
676
+ const titleContent = readHeadingSlotContent(title.node, title.text)
610
677
  const bodyNodes = children.filter(node =>
611
678
  node !== title.node
612
679
  && !isBlankTextNode(node),
@@ -626,7 +693,7 @@ function buildTitleRichSideChildren(
626
693
 
627
694
  return [
628
695
  hSlot(context, 'primary', `slide-explainer-${slotPrefix}-primary`, {}, [
629
- hText(context, 'h1', '6', title.text),
696
+ hText(context, 'h1', '6', titleContent),
630
697
  ]),
631
698
  hSlot(context, 'secondary', `slide-explainer-${slotPrefix}-secondary`, {}, [
632
699
  h('div', { class: bodyClass }, bodyNodes.map(node => cloneVNode(node))),
@@ -645,6 +712,7 @@ function buildTitleSupportsChildren(
645
712
  return buildTitleRichSideChildren(children, context, 'explainer:title-supports', { muted: true })
646
713
 
647
714
  const title = readHeadingText(children, 'explainer:title-supports')
715
+ const titleContent = readHeadingSlotContent(title.node, title.text)
648
716
  const list = children.find(isListNode)
649
717
  const muted = options.muted ?? true
650
718
  const supportSize = options.supportSize ?? '4'
@@ -677,7 +745,7 @@ function buildTitleSupportsChildren(
677
745
 
678
746
  return [
679
747
  hSlot(context, 'primary', 'slide-explainer-title-supports-bottom-primary', {}, [
680
- hText(context, 'h1', '6', title.text),
748
+ hText(context, 'h1', '6', titleContent),
681
749
  ]),
682
750
  ...items.map((item, index) =>
683
751
  hSlot(context, 'support', `slide-explainer-title-supports-bottom-support-${index + 1}`, {}, [
@@ -701,12 +769,21 @@ function buildTitleBodyChildren(children: VNode[], context: LayoutShorthandConte
701
769
 
702
770
  function buildPointsChildren(children: VNode[], context: LayoutShorthandContext) {
703
771
  const title = readHeadingText(children, 'collection:points')
772
+ const titleContent = readHeadingSlotContent(title.node, title.text)
704
773
  const list = children.find(isListNode)
705
774
 
706
775
  if (!list)
707
776
  failMarkdownContract('collection:points', 'ожидает markdown list с тремя пунктами.')
708
777
 
709
- const items = readListItems(list, 'collection:points')
778
+ const listItems = readListItemNodes(list, 'collection:points')
779
+ .map(node => ({
780
+ text: readListItemText(node),
781
+ content: readListItemSlotContent(node),
782
+ }))
783
+ const items = listItems
784
+ .filter(item => item.text)
785
+ .map(item => item.content)
786
+
710
787
  if (items.length !== 3)
711
788
  failMarkdownContract('collection:points', 'ожидает ровно три пункта для arrangement: trio.')
712
789
 
@@ -720,7 +797,7 @@ function buildPointsChildren(children: VNode[], context: LayoutShorthandContext)
720
797
  hSlot(context, 'primary', 'slide-collection-points-primary', {
721
798
  ...(context.frontmatterDecor ? { decor: context.frontmatterDecor } : {}),
722
799
  }, [
723
- hText(context, 'h1', '7', title.text, { className: 'Slide-PointsTitle' }),
800
+ hText(context, 'h1', '7', titleContent, { className: 'Slide-PointsTitle' }),
724
801
  ]),
725
802
  ...items.map((item, index) =>
726
803
  hSlot(context, 'support', `slide-collection-points-support-${index + 1}`, {}, [
@@ -777,6 +854,7 @@ function buildComparisonSideChildren(
777
854
 
778
855
  function buildComparisonChildren(children: VNode[], context: LayoutShorthandContext) {
779
856
  const title = readHeadingText(children, 'collection:comparison')
857
+ const titleContent = readHeadingSlotContent(title.node, title.text)
780
858
  const comparison = context.readFrontmatterObject('comparison')
781
859
 
782
860
  if (!comparison)
@@ -795,7 +873,7 @@ function buildComparisonChildren(children: VNode[], context: LayoutShorthandCont
795
873
 
796
874
  return [
797
875
  hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, {}, [
798
- hText(context, 'h1', '6', title.text),
876
+ hText(context, 'h1', '6', titleContent),
799
877
  ]),
800
878
  buildComparisonSideChildren(context, from, 0),
801
879
  buildComparisonSideChildren(context, to, 1),
@@ -825,12 +903,13 @@ type StepItem = {
825
903
  function normalizeStepItems(context: LayoutShorthandContext): StepItem[] {
826
904
  const items = context.readFrontmatterArray('items')
827
905
  const isStaggered = context.variant === 'steps-staggered'
906
+ const minItems = isStaggered ? 2 : 3
828
907
 
829
- if (items.length < 3 || items.length > 6)
830
- failMarkdownContract('collection:steps', 'ожидает от 3 до 6 элементов во frontmatter items.')
831
-
832
- if (isStaggered && items.length !== 5)
833
- failMarkdownContract('collection:steps', 'arrangement: staggered ожидает ровно 5 элементов.')
908
+ if (items.length < minItems || items.length > 6) {
909
+ const range = isStaggered ? 'от 2 до 6' : 'от 3 до 6'
910
+ const prefix = isStaggered ? 'arrangement: staggered ' : ''
911
+ failMarkdownContract('collection:steps', `${prefix}ожидает ${range} элементов во frontmatter items.`)
912
+ }
834
913
 
835
914
  const normalized = items.map((item, index) => {
836
915
  if (typeof item !== 'object' || item === null || Array.isArray(item))
@@ -858,6 +937,7 @@ function normalizeStepItems(context: LayoutShorthandContext): StepItem[] {
858
937
 
859
938
  function buildStepsChildren(children: VNode[], context: LayoutShorthandContext) {
860
939
  const title = readHeadingText(children, 'collection:steps')
940
+ const titleContent = readHeadingSlotContent(title.node, title.text)
861
941
  const items = normalizeStepItems(context)
862
942
  const arrangement = context.variant === 'steps-staggered' ? 'staggered' : 'linear'
863
943
 
@@ -868,25 +948,13 @@ function buildStepsChildren(children: VNode[], context: LayoutShorthandContext)
868
948
 
869
949
  return [
870
950
  hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, {}, [
871
- hText(context, 'h1', '6', title.text),
951
+ hText(context, 'h1', '6', titleContent),
872
952
  ]),
873
953
  hSlot(context, 'support', `slide-collection-${context.variant}-support`, {}, [
874
- h('div', {
875
- 'class': ['Slide-StepsGrid', `Slide-StepsGrid_${arrangement}`],
876
- 'data-count': String(items.length),
877
- 'style': { '--slide-steps-count': items.length },
878
- }, items.map((item, index) =>
879
- h('article', {
880
- 'class': ['Slide-Step', item.active ? 'Slide-Step_active' : ''],
881
- 'data-index': String(index + 1),
882
- }, [
883
- hText(context, 'div', '2', item.label, { muted: !item.active }),
884
- hText(context, 'div', '4-5', item.title, { priority: 1 }),
885
- ...(item.body
886
- ? [hText(context, 'div', '2-3', item.body, { priority: 2, muted: true })]
887
- : []),
888
- ]),
889
- )),
954
+ h(context.components.StepsGrid, {
955
+ items,
956
+ arrangement,
957
+ }),
890
958
  ]),
891
959
  ]
892
960
  }
@@ -918,8 +986,9 @@ function normalizeTimelineItems(context: LayoutShorthandContext) {
918
986
 
919
987
  function buildTimelineChildren(children: VNode[], context: LayoutShorthandContext) {
920
988
  const title = readHeadingText(children, 'collection:timeline')
989
+ const titleContent = readHeadingSlotContent(title.node, title.text)
921
990
  const paragraph = readParagraphText(children)
922
- const body = context.readFrontmatterText('body') || paragraph?.text || ''
991
+ const body = context.readFrontmatterText('body') || paragraph?.content || ''
923
992
 
924
993
  ensureOnlySupportedChildren('collection:timeline', children, node =>
925
994
  node === title.node
@@ -929,7 +998,7 @@ function buildTimelineChildren(children: VNode[], context: LayoutShorthandContex
929
998
 
930
999
  return [
931
1000
  hSlot(context, 'primary', 'slide-collection-timeline-primary', {}, [
932
- hText(context, 'h1', '6', title.text),
1001
+ hText(context, 'h1', '6', titleContent),
933
1002
  ]),
934
1003
  ...(body
935
1004
  ? [
@@ -1002,7 +1071,7 @@ function normalizeMetricItemsFromList(list: VNode) {
1002
1071
  failMarkdownContract('collection:metrics', 'ожидает минимум три пункта в markdown-списке.')
1003
1072
 
1004
1073
  return entries.map((entry, index) => {
1005
- if (!entry.label)
1074
+ if (!hasRichSlotContent(entry.label))
1006
1075
  failMarkdownContract('collection:metrics', `ожидает вложенный пункт с подписью у metric[${index}].`)
1007
1076
 
1008
1077
  return {
@@ -1034,6 +1103,7 @@ function normalizeMediaItems(context: LayoutShorthandContext) {
1034
1103
  function buildMetricsChildren(children: VNode[], context: LayoutShorthandContext) {
1035
1104
  if (context.variant === 'metrics-dashboard') {
1036
1105
  const title = readHeadingText(children, 'collection:metrics')
1106
+ const titleContent = readHeadingSlotContent(title.node, title.text)
1037
1107
  const metrics = normalizeDashboardMetrics(context)
1038
1108
  const fitGroup = 'collection-metrics-dashboard'
1039
1109
 
@@ -1044,7 +1114,7 @@ function buildMetricsChildren(children: VNode[], context: LayoutShorthandContext
1044
1114
 
1045
1115
  return [
1046
1116
  hSlot(context, 'primary', 'slide-collection-metrics-dashboard-primary', {}, [
1047
- hText(context, 'h1', '6', title.text),
1117
+ hText(context, 'h1', '6', titleContent),
1048
1118
  ]),
1049
1119
  ...metrics.map((metric, index) =>
1050
1120
  hSlot(context, 'support', `slide-collection-metrics-dashboard-support-${index + 1}`, {
@@ -1167,7 +1237,7 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
1167
1237
  }
1168
1238
 
1169
1239
  facts.forEach((fact, index) => {
1170
- if (!fact.label) {
1240
+ if (!hasRichSlotContent(fact.label)) {
1171
1241
  failMarkdownContract(
1172
1242
  'collection:facts',
1173
1243
  `ожидает вложенный пункт с подписью у facts[${index}].`,
@@ -1186,8 +1256,6 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
1186
1256
  const factFitGroup = shouldCoordinateFactSizes ? `collection-${context.variant}-facts` : ''
1187
1257
  const supportFactFitGroup = isFeatured ? `collection-${context.variant}-support-facts` : factFitGroup
1188
1258
  const headingText = readInlineText(heading)
1189
- const descriptionText = description ? readInlineText(description) : ''
1190
- const footnoteText = footnote ? readInlineText(footnote) : ''
1191
1259
  const rowFactSize: ThemeTextSizeInput = isQuartet ? '5-8' : isDuo ? '5-10' : '5-9'
1192
1260
  const stackedFactSize: ThemeTextSizeInput = '5-9'
1193
1261
  const stackedLabelSize: ThemeTextSizeInput = '3-5'
@@ -1196,24 +1264,30 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
1196
1264
  const supportFactSize: ThemeTextSizeInput = isFeatured ? '5-7' : isStacked ? stackedFactSize : rowFactSize
1197
1265
  const featuredLabelSize: ThemeTextSizeInput = isFeatured ? '4-6' : isStacked ? stackedLabelSize : rowLabelSize
1198
1266
  const supportLabelSize: ThemeTextSizeInput = isFeatured ? '2-4' : isStacked ? stackedLabelSize : rowLabelSize
1267
+ const headingContent = readHeadingSlotContent(heading, headingText)
1268
+ const descriptionContent = description ? readParagraphSlotContent(description, readInlineText(description)) : ''
1269
+ const footnoteContent = footnote ? readParagraphSlotContent(footnote, readInlineText(footnote)) : ''
1270
+ const hasFootnote = Array.isArray(footnoteContent)
1271
+ ? footnoteContent.length > 0
1272
+ : Boolean(footnoteContent)
1199
1273
 
1200
1274
  const primaryChildren: VNode[] = [
1201
- hText(context, 'h2', isFeatured ? '5-7' : '6-8', headingText, { priority: 1 }),
1275
+ hText(context, 'h2', isFeatured ? '5-7' : '6-8', headingContent, { priority: 1 }),
1202
1276
  ]
1203
1277
 
1204
- if (descriptionText)
1205
- primaryChildren.push(hText(context, 'p', isStacked ? '4-5' : '3-4', descriptionText, { priority: 2, muted: true }))
1278
+ if (descriptionContent)
1279
+ primaryChildren.push(hText(context, 'p', isStacked ? '4-5' : '3-4', descriptionContent, { priority: 2, muted: true }))
1206
1280
 
1207
- if (footnoteText && factFitGroup)
1208
- primaryChildren.push(hText(context, 'div', '2-3', footnoteText, { priority: 3, muted: true }))
1281
+ if (hasFootnote && factFitGroup)
1282
+ primaryChildren.push(hText(context, 'div', '2-3', footnoteContent, { priority: 3, muted: true }))
1209
1283
 
1210
1284
  const featuredChildren: VNode[] = [
1211
1285
  hText(context, 'div', featuredFactSize, featured.value, { priority: 1 }),
1212
1286
  hText(context, 'div', featuredLabelSize, featured.label, { priority: 2, muted: true }),
1213
1287
  ]
1214
1288
 
1215
- if (footnoteText && !factFitGroup)
1216
- featuredChildren.push(hText(context, 'div', '2-3', footnoteText, { priority: 3, muted: true }))
1289
+ if (hasFootnote && !factFitGroup)
1290
+ featuredChildren.push(hText(context, 'div', '2-3', footnoteContent, { priority: 3, muted: true }))
1217
1291
 
1218
1292
  if (isNumberedQuartet) {
1219
1293
  return [
@@ -0,0 +1,106 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { hasLayoutVariant, type ThemeLayout } from './layout-recipes'
4
+
5
+ export type DeckLayoutVariantFile = {
6
+ key: string
7
+ layout: ThemeLayout
8
+ variant: string
9
+ path: string
10
+ }
11
+
12
+ export const DECK_LAYOUT_VARIANTS_VIRTUAL_ID = 'virtual:practicum-deck-layout-variants'
13
+ export const RESOLVED_DECK_LAYOUT_VARIANTS_VIRTUAL_ID = '\0virtual:practicum-deck-layout-variants'
14
+
15
+ const THEME_LAYOUTS = new Set<ThemeLayout>(['cover', 'message', 'explainer', 'collection'])
16
+ const VARIANT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
17
+
18
+ export function deckLayoutVariantsDirectory(root: string) {
19
+ return join(root, 'components', 'layout-variants')
20
+ }
21
+
22
+ function readDirectory(path: string) {
23
+ try {
24
+ return readdirSync(path, { withFileTypes: true })
25
+ }
26
+ catch (error) {
27
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
28
+ return []
29
+ throw error
30
+ }
31
+ }
32
+
33
+ export function discoverDeckLayoutVariantFiles(root: string): DeckLayoutVariantFile[] {
34
+ const directory = deckLayoutVariantsDirectory(root)
35
+ const layoutEntries = readDirectory(directory)
36
+
37
+ for (const entry of layoutEntries) {
38
+ if (entry.isFile() && entry.name.endsWith('.vue')) {
39
+ throw new Error(
40
+ `[Practicum] Локальный вариант "${entry.name}" должен лежать в components/layout-variants/<layout>/${entry.name}.`,
41
+ )
42
+ }
43
+
44
+ if (entry.isDirectory() && !THEME_LAYOUTS.has(entry.name as ThemeLayout)) {
45
+ throw new Error(
46
+ `[Practicum] "${entry.name}" не является тематическим layout. Для нового layout создайте layouts/${entry.name}.vue.`,
47
+ )
48
+ }
49
+ }
50
+
51
+ return layoutEntries
52
+ .filter(entry => entry.isDirectory() && THEME_LAYOUTS.has(entry.name as ThemeLayout))
53
+ .flatMap((layoutEntry) => {
54
+ const layout = layoutEntry.name as ThemeLayout
55
+ const layoutDirectory = join(directory, layout)
56
+
57
+ return readDirectory(layoutDirectory)
58
+ .filter(entry => entry.isFile() && entry.name.endsWith('.vue'))
59
+ .map((entry) => {
60
+ const variant = entry.name.slice(0, -'.vue'.length)
61
+
62
+ if (!VARIANT_ID_PATTERN.test(variant)) {
63
+ throw new Error(
64
+ `[Practicum] Локальный вариант "${layout}/${entry.name}" должен иметь kebab-case имя, например "lesson-summary.vue".`,
65
+ )
66
+ }
67
+
68
+ if (hasLayoutVariant(layout, variant)) {
69
+ throw new Error(
70
+ `[Practicum] Локальный вариант "${layout}:${variant}" конфликтует со встроенным вариантом темы.`,
71
+ )
72
+ }
73
+
74
+ return {
75
+ key: `${layout}:${variant}`,
76
+ layout,
77
+ variant,
78
+ path: join(layoutDirectory, entry.name),
79
+ }
80
+ })
81
+ })
82
+ .sort((left, right) => left.key.localeCompare(right.key))
83
+ }
84
+
85
+ function toViteFileId(path: string) {
86
+ const normalized = path.replaceAll('\\', '/')
87
+ return `/@fs${normalized.startsWith('/') ? '' : '/'}${normalized}`
88
+ }
89
+
90
+ export function createDeckLayoutVariantModuleSource(files: readonly DeckLayoutVariantFile[]) {
91
+ const imports = files.map((file, index) =>
92
+ `import DeckLayoutVariant${index} from ${JSON.stringify(toViteFileId(file.path))}`,
93
+ )
94
+ const entries = files.map((file, index) =>
95
+ ` ${JSON.stringify(file.key)}: DeckLayoutVariant${index},`,
96
+ )
97
+
98
+ return [
99
+ ...imports,
100
+ '',
101
+ 'export const DECK_LAYOUT_VARIANTS = Object.freeze({',
102
+ ...entries,
103
+ '})',
104
+ '',
105
+ ].join('\n')
106
+ }
@@ -0,0 +1,73 @@
1
+ import { h, type Component, type VNode } from 'vue'
2
+ import type { ThemeLayout } from './layout-recipes'
3
+
4
+ export type DeckLayoutVariantCatalog = Readonly<Record<string, Component>>
5
+
6
+ export type DeckLayoutVariantProps<Frontmatter extends object = Record<string, unknown>> = {
7
+ layout: ThemeLayout
8
+ variant: string
9
+ frontmatter: Readonly<Frontmatter>
10
+ }
11
+
12
+ export type DeckLayoutVariantSlots = {
13
+ default(): VNode[]
14
+ }
15
+
16
+ export type SlideDeckLayoutVariantContractError = Error & {
17
+ name: 'SlideMarkdownContractError'
18
+ hint?: string
19
+ }
20
+
21
+ function failDeckLayoutVariantContract(message: string, hint?: string): never {
22
+ const error = new Error(`[Slide] Локальный вариант ${message}`) as SlideDeckLayoutVariantContractError
23
+ error.name = 'SlideMarkdownContractError'
24
+ if (hint)
25
+ error.hint = hint
26
+ throw error
27
+ }
28
+
29
+ export function deckLayoutVariantKey(layout: ThemeLayout, variant: string) {
30
+ return `${layout}:${variant}`
31
+ }
32
+
33
+ export function resolveDeckLayoutVariant(input: {
34
+ layout: ThemeLayout
35
+ variant: string
36
+ arrangement: string
37
+ variants: DeckLayoutVariantCatalog
38
+ }) {
39
+ const component = input.variants[deckLayoutVariantKey(input.layout, input.variant)]
40
+
41
+ if (component && input.arrangement) {
42
+ failDeckLayoutVariantContract(
43
+ `"${input.layout}:${input.variant}" не поддерживает arrangement: ${input.arrangement}.`,
44
+ 'Удалите `arrangement` либо используйте встроенный вариант темы.',
45
+ )
46
+ }
47
+
48
+ if (component)
49
+ return component
50
+
51
+ failDeckLayoutVariantContract(
52
+ `"${input.layout}:${input.variant}" не найден.`,
53
+ `Создайте components/layout-variants/${input.layout}/${input.variant}.vue в проекте презентации.`,
54
+ )
55
+ }
56
+
57
+ export function buildDeckLayoutVariantVNode(input: {
58
+ layout: ThemeLayout
59
+ variant: string
60
+ component: Component
61
+ frontmatter: Record<string, unknown>
62
+ children: readonly VNode[]
63
+ }) {
64
+ const frontmatter = Object.freeze({ ...input.frontmatter })
65
+
66
+ return h(input.component, {
67
+ layout: input.layout,
68
+ variant: input.variant,
69
+ frontmatter,
70
+ } satisfies DeckLayoutVariantProps, {
71
+ default: () => [...input.children],
72
+ })
73
+ }