slidev-theme-practicum 0.2.0 → 0.3.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 (35) hide show
  1. package/README.md +147 -18
  2. package/components/Slide.vue +11 -57
  3. package/components/Slot.vue +2 -1
  4. package/components/StepsGrid.vue +112 -0
  5. package/composables/deck-decors.ts +74 -0
  6. package/composables/decor-sources.ts +43 -0
  7. package/composables/layout-authoring.ts +34 -9
  8. package/composables/layout-recipes.ts +16 -2
  9. package/composables/layout-shorthands.ts +6 -17
  10. package/composables/local-layout-variant-files.ts +106 -0
  11. package/composables/local-layout-variants.ts +73 -0
  12. package/composables/slide-layout.ts +3 -0
  13. package/composables/use-theme-config.ts +17 -10
  14. package/composables/validate-deck-layouts.cjs +77 -6
  15. package/env.d.ts +18 -0
  16. package/package.json +6 -3
  17. package/scripts/browser-smoke.mjs +65 -0
  18. package/scripts/check-local-layout-variant-build.mjs +189 -0
  19. package/scripts/check-package.mjs +18 -2
  20. package/scripts/requirements-illustrations.txt +1 -0
  21. package/scripts/trace-line-art.py +422 -0
  22. package/scripts/validate-deck.cjs +1 -1
  23. package/setup/vite-plugins.ts +109 -2
  24. package/skills/slidev-practicum/SKILL.md +18 -4
  25. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  26. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  27. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  28. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  29. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  30. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  31. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  32. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  33. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  34. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  35. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
@@ -8,6 +8,7 @@ import {
8
8
  type LayoutShorthandContext,
9
9
  } from './layout-shorthands'
10
10
  import {
11
+ hasLayoutVariant,
11
12
  resolveLayoutSpec,
12
13
  resolveLayoutVariant,
13
14
  resolveLayoutRecipe,
@@ -21,6 +22,11 @@ import {
21
22
  type ThemeLayoutSlotSpec,
22
23
  } from './layout-recipes'
23
24
  import { isBlankTextVNode, isCommentVNode } from './layout-vnode'
25
+ import {
26
+ buildDeckLayoutVariantVNode,
27
+ resolveDeckLayoutVariant,
28
+ type DeckLayoutVariantCatalog,
29
+ } from './local-layout-variants'
24
30
  import { createSlotRules, type SlotRules } from './slot-rules'
25
31
  import {
26
32
  isContrastSlideMode,
@@ -48,6 +54,7 @@ export type LayoutAuthoringInput = {
48
54
  defaultTone: ThemeTone
49
55
  debugGrid?: boolean
50
56
  slotPlanState?: LayoutSlotPlanState
57
+ layoutVariants?: DeckLayoutVariantCatalog
51
58
  }
52
59
 
53
60
  export type LayoutSlotPlanState = Map<string, { role: ThemeLayoutRole, index: number }>
@@ -303,17 +310,27 @@ function resolveHeader(input: {
303
310
  export function compileLayoutAuthoring(input: LayoutAuthoringInput): CompiledLayoutAuthoring {
304
311
  const props = input.props
305
312
  const frontmatter = input.frontmatter
306
- const explicitLayout = props.layout || readFrontmatterString(frontmatter, 'layout')
313
+ const frontmatterLayout = readFrontmatterString(frontmatter, 'layout')
314
+ const explicitLayout = props.layout || frontmatterLayout
315
+ const rawVariant = props.variant || readFrontmatterString(frontmatter, 'variant')
316
+ const rawArrangement = props.arrangement || readFrontmatterString(frontmatter, 'arrangement')
307
317
  const layout = isThemeLayout(explicitLayout) ? explicitLayout : ''
308
318
  const active = Boolean(layout)
309
319
  const slideMode = resolveSlideMode(props.mode ?? readFrontmatterString(frontmatter, 'mode'), 'Slide.mode', 'light')
310
- const variant = resolveLayoutVariant(
311
- layout,
312
- props.variant || readFrontmatterString(frontmatter, 'variant'),
313
- props.arrangement || readFrontmatterString(frontmatter, 'arrangement'),
314
- )
315
- const layoutSpec = active ? resolveLayoutSpec(layout as ThemeLayout, variant) : null
316
- const recipe = active ? resolveLayoutRecipe(layout as ThemeLayout, variant) : null
320
+ const builtInVariant = active && hasLayoutVariant(layout as ThemeLayout, rawVariant)
321
+ const deckVariant = active && rawVariant && !builtInVariant
322
+ ? resolveDeckLayoutVariant({
323
+ layout: layout as ThemeLayout,
324
+ variant: rawVariant,
325
+ arrangement: rawArrangement,
326
+ variants: input.layoutVariants ?? {},
327
+ })
328
+ : undefined
329
+ const variant = deckVariant
330
+ ? rawVariant
331
+ : resolveLayoutVariant(layout, rawVariant, rawArrangement)
332
+ const layoutSpec = active && !deckVariant ? resolveLayoutSpec(layout as ThemeLayout, variant) : null
333
+ const recipe = active && !deckVariant ? resolveLayoutRecipe(layout as ThemeLayout, variant) : null
317
334
  const contrast = isContrastSlideMode(slideMode)
318
335
  const frontmatterDecor = readFrontmatterObject(frontmatter, 'decor')
319
336
  const agendaDecor = resolveAgendaDecor({
@@ -337,7 +354,15 @@ export function compileLayoutAuthoring(input: LayoutAuthoringInput): CompiledLay
337
354
  const rawChildren = input.children?.filter(isVNode) ?? []
338
355
  const shouldCompileChildren = input.children !== undefined
339
356
  const slotRules = createSlotRules({ Slot: input.components.Slot })
340
- const children = active && shouldCompileChildren
357
+ const children = deckVariant && shouldCompileChildren
358
+ ? [buildDeckLayoutVariantVNode({
359
+ layout: layout as ThemeLayout,
360
+ variant,
361
+ component: deckVariant,
362
+ frontmatter,
363
+ children: flattenVNodes(rawChildren, true),
364
+ })]
365
+ : active && shouldCompileChildren
341
366
  ? buildSourceChildren({
342
367
  rawChildren,
343
368
  layout: layout as ThemeLayout,
@@ -417,16 +417,30 @@ export const LAYOUT_RECIPES: ThemeLayoutRecipe[] = [
417
417
  ]),
418
418
  ]
419
419
 
420
- export function resolveLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
420
+ function findLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
421
421
  const resolvedVariant = variant.trim()
422
422
  const resolvedArrangement = arrangement.trim()
423
- const recipe = LAYOUT_RECIPES.find(candidate =>
423
+ return LAYOUT_RECIPES.find(candidate =>
424
424
  candidate.layout === layout
425
425
  && candidate.aliases.some(alias =>
426
426
  alias.variant === resolvedVariant
427
427
  && (alias.arrangement ?? '') === resolvedArrangement,
428
428
  ),
429
429
  )
430
+ }
431
+
432
+ export function hasLayoutVariant(layout: ThemeLayout, variant: string) {
433
+ const resolvedVariant = variant.trim()
434
+ return LAYOUT_RECIPES.some(candidate =>
435
+ candidate.layout === layout
436
+ && candidate.aliases.some(alias => alias.variant === resolvedVariant),
437
+ )
438
+ }
439
+
440
+ export function resolveLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
441
+ const resolvedVariant = variant.trim()
442
+ const resolvedArrangement = arrangement.trim()
443
+ const recipe = findLayoutRecipe(layout, resolvedVariant, resolvedArrangement)
430
444
 
431
445
  if (!recipe) {
432
446
  const suffix = resolvedArrangement ? `:${resolvedArrangement}` : ''
@@ -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
  }
@@ -547,7 +548,7 @@ function buildCenteredChildren(children: VNode[], context: LayoutShorthandContex
547
548
 
548
549
  return [
549
550
  hSlot(context, 'primary', 'slide-message-centered-primary', { centered: true }, [
550
- hText(context, 'h1', '7', title.text, { align: 'middle' }),
551
+ hText(context, 'h1', '7-12', title.text, { align: 'middle' }),
551
552
  ]),
552
553
  ]
553
554
  }
@@ -871,22 +872,10 @@ function buildStepsChildren(children: VNode[], context: LayoutShorthandContext)
871
872
  hText(context, 'h1', '6', title.text),
872
873
  ]),
873
874
  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
- )),
875
+ h(context.components.StepsGrid, {
876
+ items,
877
+ arrangement,
878
+ }),
890
879
  ]),
891
880
  ]
892
881
  }
@@ -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
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type LayoutAuthoringInput,
6
6
  } from './layout-authoring'
7
7
  import type { LayoutShorthandComponents } from './layout-shorthands'
8
+ import type { DeckLayoutVariantCatalog } from './local-layout-variants'
8
9
  import {
9
10
  createSlotPlacementSession,
10
11
  type SlotPlacementSession,
@@ -19,6 +20,7 @@ export type SlideLayout = SlotPlacementSession & {
19
20
 
20
21
  export function createSlideLayout(input: {
21
22
  components: LayoutShorthandComponents
23
+ layoutVariants?: DeckLayoutVariantCatalog
22
24
  }): SlideLayout {
23
25
  const slotPlanState = createLayoutSlotPlanState()
24
26
  let current: CompiledSlideLayout | null = null
@@ -31,6 +33,7 @@ export function createSlideLayout(input: {
31
33
  const compiled = compileLayoutAuthoring({
32
34
  ...authoringInput,
33
35
  components: input.components,
36
+ layoutVariants: input.layoutVariants,
34
37
  slotPlanState,
35
38
  })
36
39
 
@@ -1,9 +1,15 @@
1
1
  import { computed } from 'vue'
2
2
  import { useSlideContext } from '@slidev/client'
3
+ import { collectDecorSources, isRecord } from './decor-sources'
3
4
  import { resolveTone, type ThemeTone } from './theme-foundation'
4
5
 
5
- function isRecord(value: unknown): value is Record<string, unknown> {
6
- return typeof value === 'object' && value !== null && !Array.isArray(value)
6
+ function readFileDecors() {
7
+ try {
8
+ return Array.isArray(__PRATICUM_DECK_DECORS__) ? __PRATICUM_DECK_DECORS__ : []
9
+ }
10
+ catch {
11
+ return []
12
+ }
7
13
  }
8
14
 
9
15
  function readBoolean(value: unknown, fallback: boolean) {
@@ -45,20 +51,20 @@ export function useThemeConfig() {
45
51
  if (typeof explicit === 'string' && explicit.trim())
46
52
  return explicit.trim()
47
53
 
48
- const fallback = configs.value.title
49
- if (typeof fallback === 'string' && fallback.trim())
50
- return fallback.trim()
51
-
52
54
  return ''
53
55
  })
54
56
 
55
57
  const showPageNumber = computed(() => readBoolean(themeConfigs.value.showPageNumber, true))
56
58
  const debugGrid = computed(() => readBoolean(themeConfigs.value.debugGrid, false))
57
59
  const defaultTone = computed(() => readDefaultTone(themeConfigs.value.defaultTone, 'blue'))
58
- const decors = computed(() => {
59
- const explicit = themeConfigs.value.decors
60
- return Array.isArray(explicit) ? explicit : []
61
- })
60
+ const decors = computed(() => [
61
+ ...readFileDecors(),
62
+ ...collectDecorSources(themeConfigs.value.decors).inline,
63
+ ])
64
+ const replaceDecors = computed(() => readBoolean(
65
+ themeConfigs.value.replaceDecors,
66
+ readFileDecors().length > 0 || collectDecorSources(themeConfigs.value.decors).files.length > 0,
67
+ ))
62
68
 
63
69
  return {
64
70
  themeConfigs,
@@ -67,5 +73,6 @@ export function useThemeConfig() {
67
73
  debugGrid,
68
74
  defaultTone,
69
75
  decors,
76
+ replaceDecors,
70
77
  }
71
78
  }
@@ -1,5 +1,5 @@
1
1
  const { readFileSync } = require('node:fs')
2
- const { isAbsolute, join, resolve } = require('node:path')
2
+ const { dirname, isAbsolute, join, resolve } = require('node:path')
3
3
  const { parseSync } = require('@slidev/parser')
4
4
  const { createCommentVNode, createTextVNode, createVNode } = require('vue')
5
5
  const { registerTypeScript } = require('./typescript-require.cjs')
@@ -41,7 +41,10 @@ registerTypeScript()
41
41
 
42
42
  const { compileLayoutAuthoring } = require(themePath('composables/layout-authoring.ts'))
43
43
  const { hasLayoutShorthand } = require(themePath('composables/layout-shorthands.ts'))
44
+ const { discoverDeckLayoutVariantFiles } = require(themePath('composables/local-layout-variant-files.ts'))
45
+ const { resolveDeckLayoutVariant } = require(themePath('composables/local-layout-variants.ts'))
44
46
  const {
47
+ hasLayoutVariant,
45
48
  resolveLayoutSlotSpec,
46
49
  resolveLayoutVariant,
47
50
  } = require(themePath('composables/layout-recipes.ts'))
@@ -57,6 +60,7 @@ function stubComponents() {
57
60
  Slot: named('Slot'),
58
61
  Text: named('Text'),
59
62
  Timeline: named('Timeline'),
63
+ StepsGrid: named('StepsGrid'),
60
64
  }
61
65
  }
62
66
 
@@ -121,7 +125,7 @@ function validateExplicitSlotIntents(intents, input) {
121
125
  function slideRef(slide, index) {
122
126
  const layout = String(slide.frontmatter?.layout ?? '').trim()
123
127
  const variant = String(slide.frontmatter?.variant ?? '').trim()
124
- const title = String(slide.title || slide.content?.match(/^#\s+(.+)/m)?.[1] || '').trim()
128
+ const title = String(slide.content?.match(/^#\s+(.+)/m)?.[1] || '').trim()
125
129
  return {
126
130
  index,
127
131
  page: index + 1,
@@ -141,17 +145,64 @@ async function validateDeckLayouts(deckPath) {
141
145
  const source = readFileSync(absolutePath, 'utf8')
142
146
  const deck = parseSync(source, absolutePath)
143
147
  const components = stubComponents()
148
+ /** @type {{ key: string, path: string }[]} */
149
+ const layoutVariantFiles = discoverDeckLayoutVariantFiles(dirname(absolutePath))
150
+ const layoutVariants = Object.fromEntries(
151
+ layoutVariantFiles
152
+ .map(file => [file.key, { name: `DeckLayoutVariant:${file.key}` }]),
153
+ )
144
154
  /** @type {DeckValidationIssue[]} */
145
155
  const issues = []
146
156
 
147
157
  for (const [index, slide] of deck.slides.entries()) {
148
158
  const layout = String(slide.frontmatter?.layout ?? '').trim()
159
+ const ref = slideRef(slide, index)
160
+
161
+ if (index > 0 && Object.prototype.hasOwnProperty.call(slide.frontmatter ?? {}, 'title')) {
162
+ issues.push({
163
+ ...ref,
164
+ message: 'Верхнеуровневое поле `title` разрешено только в первом headmatter как служебное название колоды и не является видимым содержимым слайда.',
165
+ hint: 'Удалите поле и перенесите основной текст в тело слайда: используйте Markdown-заголовок `# …` или видимый `<Text as="h1">…</Text>` в явной композиции.',
166
+ })
167
+ }
168
+
149
169
  if (!isThemeLayout(layout))
150
170
  continue
151
171
 
152
172
  const rawVariant = String(slide.frontmatter?.variant ?? '').trim()
153
173
  const arrangement = String(slide.frontmatter?.arrangement ?? '').trim()
154
- const ref = slideRef(slide, index)
174
+ const localVariant = layoutVariants[`${layout}:${rawVariant}`]
175
+
176
+ if (localVariant) {
177
+ const content = stripAuthorNotes(slide.content ?? '')
178
+
179
+ try {
180
+ compileLayoutAuthoring({
181
+ props: {
182
+ layout,
183
+ variant: rawVariant,
184
+ arrangement,
185
+ mode: slide.frontmatter?.mode,
186
+ tone: slide.frontmatter?.tone ?? '',
187
+ },
188
+ frontmatter: slide.frontmatter ?? {},
189
+ children: markdownToVnodes(content),
190
+ components,
191
+ layoutVariants,
192
+ defaultTone: 'blue',
193
+ })
194
+ }
195
+ catch (error) {
196
+ const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
197
+ issues.push({
198
+ ...ref,
199
+ message: error instanceof Error ? error.message : String(error),
200
+ hint: typeof hint === 'string' ? hint : undefined,
201
+ })
202
+ }
203
+ continue
204
+ }
205
+
155
206
  /** @type {string} */
156
207
  let variant
157
208
 
@@ -159,10 +210,26 @@ async function validateDeckLayouts(deckPath) {
159
210
  variant = resolveLayoutVariant(layout, rawVariant, arrangement)
160
211
  }
161
212
  catch (error) {
162
- const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
213
+ let reportedError = error
214
+ if (rawVariant && !hasLayoutVariant(layout, rawVariant)) {
215
+ try {
216
+ resolveDeckLayoutVariant({
217
+ layout,
218
+ variant: rawVariant,
219
+ arrangement,
220
+ variants: layoutVariants,
221
+ })
222
+ }
223
+ catch (deckVariantError) {
224
+ reportedError = deckVariantError
225
+ }
226
+ }
227
+ const hint = reportedError && typeof reportedError === 'object' && 'hint' in reportedError
228
+ ? reportedError.hint
229
+ : undefined
163
230
  issues.push({
164
231
  ...ref,
165
- message: error instanceof Error ? error.message : String(error),
232
+ message: reportedError instanceof Error ? reportedError.message : String(reportedError),
166
233
  hint: typeof hint === 'string' ? hint : undefined,
167
234
  })
168
235
  continue
@@ -197,6 +264,7 @@ async function validateDeckLayouts(deckPath) {
197
264
  frontmatter: slide.frontmatter ?? {},
198
265
  children: liveStructure.children.map(node => liveNodeToVNode(node, components)),
199
266
  components,
267
+ layoutVariants,
200
268
  defaultTone: 'blue',
201
269
  })
202
270
  validateExplicitSlotIntents(liveStructure.intents, {
@@ -233,6 +301,7 @@ async function validateDeckLayouts(deckPath) {
233
301
  frontmatter: slide.frontmatter ?? {},
234
302
  children: markdownToVnodes(content),
235
303
  components,
304
+ layoutVariants,
236
305
  defaultTone: 'blue',
237
306
  })
238
307
  }
@@ -264,7 +333,9 @@ function formatDeckLayoutIssues(issues, deckPath = 'deck') {
264
333
  ]
265
334
  if (issue.hint)
266
335
  lines.push(` Подсказка: ${issue.hint}`)
267
- lines.push(' См. example.md в slidev-theme-practicum (заметки «Контракт»).')
336
+ lines.push(issue.hint?.includes('components/layout-variants/')
337
+ ? ' См. README.md в slidev-theme-practicum, раздел «Локальные варианты презентации».'
338
+ : ' См. example.md в slidev-theme-practicum (заметки «Контракт»).')
268
339
  return lines.join('\n')
269
340
  }).join(`\n\n${'—'.repeat(40)}\n\n`)
270
341
 
package/env.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ declare module '*.vue' {
2
+ import type { DefineComponent } from 'vue'
3
+
4
+ const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
5
+ export default component
6
+ }
7
+
8
+ declare const __PRATICUM_DECK_DECORS__: readonly Record<string, unknown>[]
9
+
10
+ declare module 'virtual:practicum-deck-layout-variants' {
11
+ import type { Component } from 'vue'
12
+
13
+ export const DECK_LAYOUT_VARIANTS: Readonly<Record<string, Component>>
14
+ }
15
+
16
+ declare module 'virtual:practicum-deck-decors' {
17
+ export const DECK_FILE_DECORS: readonly Record<string, unknown>[]
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-theme-practicum",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Strict Slidev presentation theme with a 12x12 grid, editorial layouts, and low-level slot composition API",
5
5
  "keywords": [
6
6
  "slidev-theme",
@@ -25,6 +25,7 @@
25
25
  "styles",
26
26
  "types",
27
27
  "skills",
28
+ "env.d.ts",
28
29
  "LICENSE",
29
30
  "README.md",
30
31
  "example.md",
@@ -64,12 +65,13 @@
64
65
  "lint": "eslint .",
65
66
  "lint:fix": "eslint . --fix",
66
67
  "validate-deck": "node scripts/validate-deck.cjs",
67
- "test": "npm run lint && npm run typecheck && npm run test:unit && npm run test:architecture && npm run test:build && npm run test:build-artifact && npm run test:browser && npm run test:package",
68
+ "test": "npm run lint && npm run typecheck && npm run test:unit && npm run test:architecture && npm run test:build && npm run test:layout-variant-build && npm run test:build-artifact && npm run test:browser && npm run test:package",
68
69
  "typecheck": "vue-tsc --noEmit",
69
70
  "test:architecture": "node scripts/check-test-architecture.mjs",
70
71
  "test:unit": "node --test tests/*.test.cjs",
71
72
  "test:validate-deck": "node scripts/validate-deck.cjs example.md",
72
73
  "test:build": "slidev build example.md",
74
+ "test:layout-variant-build": "node scripts/check-local-layout-variant-build.mjs",
73
75
  "test:build-artifact": "node scripts/check-build-artifact.mjs",
74
76
  "test:browser": "node scripts/browser-smoke.mjs",
75
77
  "test:package": "node scripts/check-package.mjs"
@@ -81,7 +83,8 @@
81
83
  "@slidev/types": "^52.14.2",
82
84
  "markdown-it": "^14.1.0",
83
85
  "typescript": "^6.0.3",
84
- "vue": "^3.5.13"
86
+ "vue": "^3.5.13",
87
+ "yaml": "^2.9.0"
85
88
  },
86
89
  "devDependencies": {
87
90
  "@eslint/js": "^10.0.1",