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
@@ -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
 
@@ -190,8 +190,8 @@ function readBounds(input: ElementFitInput) {
190
190
  return bounds
191
191
  }
192
192
 
193
- function readFitObserverElements(element: HTMLElement) {
194
- const elements: HTMLElement[] = [element]
193
+ function readFitObserverElements(element: HTMLElement, observeOwnSize: boolean) {
194
+ const elements: HTMLElement[] = observeOwnSize ? [element] : []
195
195
  let parent = element.parentElement
196
196
 
197
197
  while (parent && elements.length < 8) {
@@ -496,6 +496,7 @@ function createTextFitCleanupOwner(isDisposed: () => boolean) {
496
496
  type TextFitLifecycleOwnerInput = {
497
497
  adapter: TextFitAdapter
498
498
  fit: () => void | Promise<void>
499
+ observeOwnSize?: boolean
499
500
  onDispose: () => void
500
501
  target: Ref<HTMLElement | null>
501
502
  watchTarget?: boolean
@@ -504,6 +505,7 @@ type TextFitLifecycleOwnerInput = {
504
505
  function createTextFitLifecycleOwner({
505
506
  adapter,
506
507
  fit,
508
+ observeOwnSize = true,
507
509
  onDispose,
508
510
  target,
509
511
  watchTarget = false,
@@ -532,7 +534,7 @@ function createTextFitLifecycleOwner({
532
534
  if (!element)
533
535
  return null
534
536
 
535
- return adapter.observe(readFitObserverElements(element), scheduleFit)
537
+ return adapter.observe(readFitObserverElements(element, observeOwnSize), scheduleFit)
536
538
  })
537
539
  }
538
540
 
@@ -734,6 +736,7 @@ export function createTextFitRuntime(adapter: TextFitAdapter = createBrowserText
734
736
  const { scheduleFit } = createTextFitLifecycleOwner({
735
737
  adapter,
736
738
  fit: fitNow,
739
+ observeOwnSize: false,
737
740
  onDispose: () => {
738
741
  runId += 1
739
742
  },
@@ -876,6 +879,7 @@ export function createTextFitRuntime(adapter: TextFitAdapter = createBrowserText
876
879
  const { scheduleFit } = createTextFitLifecycleOwner({
877
880
  adapter,
878
881
  fit: fitNow,
882
+ observeOwnSize: false,
879
883
  onDispose: () => {
880
884
  runId += 1
881
885
  unregisterSharedItem()
@@ -13,6 +13,7 @@ export type ResolvedThemeMode = {
13
13
  text: string
14
14
  muted: string
15
15
  contrast: boolean
16
+ foreground: 'light' | 'dark'
16
17
  }
17
18
 
18
19
  export type ResolvedSlideTheme = ResolvedThemeMode
@@ -112,6 +113,7 @@ export function resolveThemeMode(options: ResolveThemeModeOptions = {}): Resolve
112
113
  text: 'var(--theme-color-dark-0)',
113
114
  muted: 'var(--theme-color-dark-2)',
114
115
  contrast: false,
116
+ foreground: 'dark',
115
117
  }
116
118
  }
117
119
 
@@ -123,6 +125,7 @@ export function resolveThemeMode(options: ResolveThemeModeOptions = {}): Resolve
123
125
  text: 'var(--theme-color-light-0)',
124
126
  muted: 'var(--theme-text-muted-on-contrast)',
125
127
  contrast: true,
128
+ foreground: 'light',
126
129
  }
127
130
  }
128
131
 
@@ -130,9 +133,10 @@ export function resolveThemeMode(options: ResolveThemeModeOptions = {}): Resolve
130
133
  mode,
131
134
  tone,
132
135
  background: `var(--theme-color-${tone}-0)`,
133
- text: 'var(--theme-color-light-0)',
134
- muted: 'var(--theme-text-muted-on-contrast)',
136
+ text: 'var(--theme-text-on-color)',
137
+ muted: 'var(--theme-text-muted-on-color)',
135
138
  contrast: true,
139
+ foreground: 'light',
136
140
  }
137
141
  }
138
142
 
@@ -176,7 +180,7 @@ export function themeVars(
176
180
  '--theme-text-muted': slideTheme.muted,
177
181
  '--theme-inline-code-text': slideTheme.text,
178
182
  '--theme-text-on-dark': 'var(--theme-color-light-0)',
179
- '--theme-link': slideTheme.contrast ? 'var(--theme-color-light-0)' : 'var(--theme-current-color)',
183
+ '--theme-link': slideTheme.foreground === 'light' ? 'var(--theme-color-light-0)' : slideTheme.mode === 'color' ? 'var(--theme-text-on-color)' : 'var(--theme-current-color)',
180
184
  '--theme-current-color': `var(--theme-color-${slideTheme.tone}-0)`,
181
185
  }
182
186
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @typedef {{ text: string, size?: string, color?: string, slot: string, keyNumber: boolean, muted?: boolean }} TypographyText
3
+ * @typedef {{ code: string, message: string, hint?: string }} TypographyIssue
4
+ */
5
+
6
+ /** @param {TypographyText[]} texts */
7
+ function describeSizes(texts) {
8
+ return [...new Set(texts.map(text => `${text.size}: «${text.text}»`))].join(', ')
9
+ }
10
+
11
+ /**
12
+ * Общая политика для объявленных токенов и вычисленных значений браузера.
13
+ * Отсутствующее значение означает «неизвестно», а не значение по умолчанию.
14
+ * @param {TypographyText[]} texts
15
+ * @returns {TypographyIssue[]}
16
+ */
17
+ function inspectTypography(texts) {
18
+ /** @type {TypographyIssue[]} */
19
+ const issues = []
20
+ const regular = texts.filter(text => !text.keyNumber && text.size !== undefined)
21
+ const numbers = texts.filter(text => text.keyNumber && text.size !== undefined)
22
+ if (new Set(regular.map(text => text.size)).size > 2) {
23
+ issues.push({
24
+ code: 'text-sizes',
25
+ message: `Больше двух размеров обычного текста: ${describeSizes(regular)}.`,
26
+ hint: 'Объедините подписи с основным поясняющим текстом по размеру или измените композицию. Заголовок тоже входит в подсчёт.',
27
+ })
28
+ }
29
+ if (new Set(numbers.map(text => text.size)).size > 1) {
30
+ issues.push({
31
+ code: 'number-sizes',
32
+ message: `Ключевые числа имеют разные размеры: ${describeSizes(numbers)}.`,
33
+ hint: 'Все Text as="data" на слайде должны иметь один размер, в том числе после автоматического подбора.',
34
+ })
35
+ }
36
+
37
+ for (const slot of new Set(texts.map(text => text.slot))) {
38
+ const contents = texts.filter(text => text.slot === slot)
39
+ const colored = contents.filter(text => text.color !== undefined)
40
+ if (new Set(colored.map(text => text.color)).size > 1) {
41
+ issues.push({
42
+ code: 'slot-colors',
43
+ message: `${slot}: разные цвета текста: ${colored.map(text => `${text.color}: «${text.text}»`).join(', ')}.`,
44
+ hint: 'Выберите один основной цвет для всего текста внутри Slot.',
45
+ })
46
+ }
47
+ const muted = contents.filter(text => text.muted)
48
+ if (muted.length) {
49
+ issues.push({
50
+ code: 'muted-text',
51
+ message: `${slot}: приглушённый текст: ${muted.map(text => `«${text.text}»`).join(', ')}.`,
52
+ hint: 'Удалите muted и токены text-muted, используйте основной цвет поверхности.',
53
+ })
54
+ }
55
+ }
56
+ return issues
57
+ }
58
+
59
+ module.exports = { inspectTypography }
@@ -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,10 +1,11 @@
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')
6
6
  const { extractDeckLiveStructure } = require('./deck-slot-markup.cjs')
7
7
  const { markdownToVnodes } = require('./markdown-to-vnodes.cjs')
8
+ const { validateDeclaredTypography } = require('./validate-deck-typography.cjs')
8
9
 
9
10
  /** @typedef {import('@slidev/types').SourceSlideInfo} SourceSlideInfo */
10
11
  /** @typedef {import('./layout-recipes').ThemeLayout} ThemeLayout */
@@ -41,7 +42,10 @@ registerTypeScript()
41
42
 
42
43
  const { compileLayoutAuthoring } = require(themePath('composables/layout-authoring.ts'))
43
44
  const { hasLayoutShorthand } = require(themePath('composables/layout-shorthands.ts'))
45
+ const { discoverDeckLayoutVariantFiles } = require(themePath('composables/local-layout-variant-files.ts'))
46
+ const { resolveDeckLayoutVariant } = require(themePath('composables/local-layout-variants.ts'))
44
47
  const {
48
+ hasLayoutVariant,
45
49
  resolveLayoutSlotSpec,
46
50
  resolveLayoutVariant,
47
51
  } = require(themePath('composables/layout-recipes.ts'))
@@ -57,6 +61,7 @@ function stubComponents() {
57
61
  Slot: named('Slot'),
58
62
  Text: named('Text'),
59
63
  Timeline: named('Timeline'),
64
+ StepsGrid: named('StepsGrid'),
60
65
  }
61
66
  }
62
67
 
@@ -121,7 +126,7 @@ function validateExplicitSlotIntents(intents, input) {
121
126
  function slideRef(slide, index) {
122
127
  const layout = String(slide.frontmatter?.layout ?? '').trim()
123
128
  const variant = String(slide.frontmatter?.variant ?? '').trim()
124
- const title = String(slide.title || slide.content?.match(/^#\s+(.+)/m)?.[1] || '').trim()
129
+ const title = String(slide.content?.match(/^#\s+(.+)/m)?.[1] || '').trim()
125
130
  return {
126
131
  index,
127
132
  page: index + 1,
@@ -134,24 +139,87 @@ function slideRef(slide, index) {
134
139
 
135
140
  /**
136
141
  * @param {string} deckPath
142
+ * @param {{ typographyGuard?: boolean }} [options]
137
143
  * @returns {Promise<DeckValidationIssue[]>}
138
144
  */
139
- async function validateDeckLayouts(deckPath) {
145
+ async function validateDeckLayouts(deckPath, options = {}) {
140
146
  const absolutePath = isAbsolute(deckPath) ? deckPath : resolve(deckPath)
141
147
  const source = readFileSync(absolutePath, 'utf8')
142
148
  const deck = parseSync(source, absolutePath)
143
149
  const components = stubComponents()
150
+ /** @type {{ key: string, path: string }[]} */
151
+ const layoutVariantFiles = discoverDeckLayoutVariantFiles(dirname(absolutePath))
152
+ const layoutVariants = Object.fromEntries(
153
+ layoutVariantFiles
154
+ .map(file => [file.key, { name: `DeckLayoutVariant:${file.key}` }]),
155
+ )
144
156
  /** @type {DeckValidationIssue[]} */
145
157
  const issues = []
146
158
 
147
159
  for (const [index, slide] of deck.slides.entries()) {
148
160
  const layout = String(slide.frontmatter?.layout ?? '').trim()
161
+ const ref = slideRef(slide, index)
162
+
163
+ if (options.typographyGuard) {
164
+ if (slide.frontmatter?.src) {
165
+ issues.push({ ...ref, message: 'Статическая проверка типографики пока не раскрывает включения src. Соберите слайды в один Markdown-файл перед проверкой.' })
166
+ continue
167
+ }
168
+ try {
169
+ const structure = extractDeckLiveStructure(slide.content ?? '')
170
+ issues.push(...validateDeclaredTypography(structure.children).map(issue => ({ ...ref, ...issue })))
171
+ }
172
+ catch (error) {
173
+ issues.push({ ...ref, message: error instanceof Error ? error.message : String(error) })
174
+ continue
175
+ }
176
+ }
177
+
178
+ if (index > 0 && Object.prototype.hasOwnProperty.call(slide.frontmatter ?? {}, 'title')) {
179
+ issues.push({
180
+ ...ref,
181
+ message: 'Верхнеуровневое поле `title` разрешено только в первом headmatter как служебное название колоды и не является видимым содержимым слайда.',
182
+ hint: 'Удалите поле и перенесите основной текст в тело слайда: используйте Markdown-заголовок `# …` или видимый `<Text as="h1">…</Text>` в явной композиции.',
183
+ })
184
+ }
185
+
149
186
  if (!isThemeLayout(layout))
150
187
  continue
151
188
 
152
189
  const rawVariant = String(slide.frontmatter?.variant ?? '').trim()
153
190
  const arrangement = String(slide.frontmatter?.arrangement ?? '').trim()
154
- const ref = slideRef(slide, index)
191
+ const localVariant = layoutVariants[`${layout}:${rawVariant}`]
192
+
193
+ if (localVariant) {
194
+ const content = stripAuthorNotes(slide.content ?? '')
195
+
196
+ try {
197
+ compileLayoutAuthoring({
198
+ props: {
199
+ layout,
200
+ variant: rawVariant,
201
+ arrangement,
202
+ mode: slide.frontmatter?.mode,
203
+ tone: slide.frontmatter?.tone ?? '',
204
+ },
205
+ frontmatter: slide.frontmatter ?? {},
206
+ children: markdownToVnodes(content),
207
+ components,
208
+ layoutVariants,
209
+ defaultTone: 'blue',
210
+ })
211
+ }
212
+ catch (error) {
213
+ const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
214
+ issues.push({
215
+ ...ref,
216
+ message: error instanceof Error ? error.message : String(error),
217
+ hint: typeof hint === 'string' ? hint : undefined,
218
+ })
219
+ }
220
+ continue
221
+ }
222
+
155
223
  /** @type {string} */
156
224
  let variant
157
225
 
@@ -159,10 +227,26 @@ async function validateDeckLayouts(deckPath) {
159
227
  variant = resolveLayoutVariant(layout, rawVariant, arrangement)
160
228
  }
161
229
  catch (error) {
162
- const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
230
+ let reportedError = error
231
+ if (rawVariant && !hasLayoutVariant(layout, rawVariant)) {
232
+ try {
233
+ resolveDeckLayoutVariant({
234
+ layout,
235
+ variant: rawVariant,
236
+ arrangement,
237
+ variants: layoutVariants,
238
+ })
239
+ }
240
+ catch (deckVariantError) {
241
+ reportedError = deckVariantError
242
+ }
243
+ }
244
+ const hint = reportedError && typeof reportedError === 'object' && 'hint' in reportedError
245
+ ? reportedError.hint
246
+ : undefined
163
247
  issues.push({
164
248
  ...ref,
165
- message: error instanceof Error ? error.message : String(error),
249
+ message: reportedError instanceof Error ? reportedError.message : String(reportedError),
166
250
  hint: typeof hint === 'string' ? hint : undefined,
167
251
  })
168
252
  continue
@@ -197,6 +281,7 @@ async function validateDeckLayouts(deckPath) {
197
281
  frontmatter: slide.frontmatter ?? {},
198
282
  children: liveStructure.children.map(node => liveNodeToVNode(node, components)),
199
283
  components,
284
+ layoutVariants,
200
285
  defaultTone: 'blue',
201
286
  })
202
287
  validateExplicitSlotIntents(liveStructure.intents, {
@@ -233,6 +318,7 @@ async function validateDeckLayouts(deckPath) {
233
318
  frontmatter: slide.frontmatter ?? {},
234
319
  children: markdownToVnodes(content),
235
320
  components,
321
+ layoutVariants,
236
322
  defaultTone: 'blue',
237
323
  })
238
324
  }
@@ -264,7 +350,9 @@ function formatDeckLayoutIssues(issues, deckPath = 'deck') {
264
350
  ]
265
351
  if (issue.hint)
266
352
  lines.push(` Подсказка: ${issue.hint}`)
267
- lines.push(' См. example.md в slidev-theme-practicum (заметки «Контракт»).')
353
+ lines.push(issue.hint?.includes('components/layout-variants/')
354
+ ? ' См. README.md в slidev-theme-practicum, раздел «Локальные варианты презентации».'
355
+ : ' См. example.md в slidev-theme-practicum (заметки «Контракт»).')
268
356
  return lines.join('\n')
269
357
  }).join(`\n\n${'—'.repeat(40)}\n\n`)
270
358
 
@@ -0,0 +1,101 @@
1
+ const { inspectTypography } = require('./typography-guard.cjs')
2
+
3
+ /** @typedef {import('./deck-slot-markup.cjs').DeckLiveNode} DeckLiveNode */
4
+ /** @typedef {import('./typography-guard.cjs').TypographyText} TypographyText */
5
+ /** @typedef {import('./typography-guard.cjs').TypographyIssue} TypographyIssue */
6
+
7
+ const EXCLUDED = new Set(['Header', 'Logo', 'Image', 'Decor', 'svg', 'img', 'script', 'style'])
8
+ const SIZE_PATTERN = /^(\d+)(?:\s*-\s*(\d+))?$/u
9
+
10
+ /** @param {DeckLiveNode} node @returns {string} */
11
+ function textContent(node) {
12
+ if (node.kind === 'text')
13
+ return node.content
14
+ return node.kind === 'element' ? node.children.map(textContent).join(' ') : ''
15
+ }
16
+
17
+ /** @param {unknown} value */
18
+ function isEnabled(value) {
19
+ return value === '' || Boolean(value)
20
+ }
21
+
22
+ /** @param {Record<string, unknown>} props */
23
+ function sizePolicy(props) {
24
+ if (('max-size' in props && isEnabled(props['max-size'])) || ('maxSize' in props && isEnabled(props.maxSize)))
25
+ return '0-12'
26
+ const input = String(props.size ?? '2').trim()
27
+ const match = input.match(SIZE_PATTERN)
28
+ if (!match || Number(match[1]) > 12 || Number(match[2] ?? match[1]) > 12)
29
+ return undefined
30
+ const min = Math.min(Number(match[1]), Number(match[2] ?? match[1]))
31
+ const max = Math.max(Number(match[1]), Number(match[2] ?? match[1]))
32
+ return min === max ? String(min) : `${min}-${max}`
33
+ }
34
+
35
+ /**
36
+ * Проверяет только явно объявленные Text. Сокращённая запись и стили
37
+ * компонентов проверяются после отображения слайда в браузере.
38
+ * @param {DeckLiveNode[]} children
39
+ * @returns {TypographyIssue[]}
40
+ */
41
+ function validateDeclaredTypography(children) {
42
+ /** @type {TypographyText[]} */
43
+ const texts = []
44
+ /** @type {TypographyIssue[]} */
45
+ const issues = []
46
+ let slotIndex = 0
47
+
48
+ /** @param {DeckLiveNode} node @param {string} slot @param {string[]} inherited */
49
+ function visit(node, slot, inherited) {
50
+ if (node.kind !== 'element' || EXCLUDED.has(node.tag))
51
+ return
52
+ if (node.tag === 'Slot') {
53
+ slotIndex += 1
54
+ slot = `Slot ${slotIndex}${node.props.role ? ` (${node.props.role})` : ''}`
55
+ }
56
+ const relevantProps = node.tag === 'Text'
57
+ ? ['size', 'max-size', 'maxSize', 'color', 'muted', 'as', 'class', 'style']
58
+ : ['class', 'style']
59
+ const dynamic = [...inherited, ...(node.dynamicProps ?? []).filter(prop =>
60
+ !prop.startsWith('v-bind:') || relevantProps.includes(prop.slice(7)),
61
+ )]
62
+ if (node.tag === 'Text') {
63
+ const text = textContent(node).replace(/\s+/gu, ' ').trim().slice(0, 100)
64
+ if (!text)
65
+ return
66
+ if (dynamic.length) {
67
+ issues.push({
68
+ code: 'dynamic-typography',
69
+ message: `«${text}»: статически не определены ${[...new Set(dynamic)].join(', ')}.`,
70
+ hint: 'Задайте литеральные атрибуты или проверьте фактический результат командой slidev-practicum-check-typography.',
71
+ })
72
+ return
73
+ }
74
+ const size = sizePolicy(node.props)
75
+ if (size === undefined) {
76
+ issues.push({
77
+ code: 'invalid-size',
78
+ message: `«${text}»: неверный размер ${String(node.props.size)}.`,
79
+ hint: 'Укажите токен 0–12 или диапазон этих токенов, например size="7-8".',
80
+ })
81
+ }
82
+ const color = String(node.props.color ?? 'current')
83
+ const muted = ('muted' in node.props && isEnabled(node.props.muted)) || color.startsWith('text-muted')
84
+ texts.push({
85
+ text,
86
+ size,
87
+ color: muted ? 'text-muted' : color,
88
+ slot,
89
+ keyNumber: String(node.props.as ?? '').toLowerCase() === 'data',
90
+ muted,
91
+ })
92
+ }
93
+ for (const child of node.children)
94
+ visit(child, slot, dynamic)
95
+ }
96
+ for (const child of children)
97
+ visit(child, 'Слайд вне Slot', [])
98
+ return [...issues, ...inspectTypography(texts)]
99
+ }
100
+
101
+ module.exports = { validateDeclaredTypography }
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/example.md CHANGED
@@ -557,7 +557,7 @@ items:
557
557
  body: Применить в другой задаче
558
558
  ---
559
559
 
560
- <!-- Контракт: steps staggered — ровно 5 шагов во frontmatter для ритма 3+2. -->
560
+ <!-- Контракт: steps staggered — от 2 до 6 шагов во frontmatter, два центрированных ряда; label сверху, title и body снизу. -->
561
561
 
562
562
  # Как команда разбирает ошибку
563
563
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-theme-practicum",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -38,10 +39,12 @@
38
39
  "favicon": "/theme/favicon.svg",
39
40
  "fonts": {
40
41
  "provider": "none",
41
- "sans": "YS Text, Inter",
42
+ "sans": "YS Text, Inter, system-ui",
42
43
  "mono": "monospace",
43
44
  "local": [
44
45
  "YS Text",
46
+ "Inter",
47
+ "system-ui",
45
48
  "monospace"
46
49
  ]
47
50
  }
@@ -52,7 +55,8 @@
52
55
  "slidev": ">=0.50.0"
53
56
  },
54
57
  "bin": {
55
- "slidev-practicum-validate": "scripts/validate-deck.cjs"
58
+ "slidev-practicum-validate": "scripts/validate-deck.cjs",
59
+ "slidev-practicum-check-typography": "scripts/check-typography.mjs"
56
60
  },
57
61
  "scripts": {
58
62
  "dev": "slidev example.md",
@@ -64,15 +68,22 @@
64
68
  "lint": "eslint .",
65
69
  "lint:fix": "eslint . --fix",
66
70
  "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",
71
+ "check-typography": "node scripts/check-typography.mjs",
72
+ "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:accessibility && npm run test:typography && npm run test:pixels && npm run test:package && npm run test:consumer",
68
73
  "typecheck": "vue-tsc --noEmit",
69
74
  "test:architecture": "node scripts/check-test-architecture.mjs",
70
75
  "test:unit": "node --test tests/*.test.cjs",
71
76
  "test:validate-deck": "node scripts/validate-deck.cjs example.md",
72
77
  "test:build": "slidev build example.md",
78
+ "test:layout-variant-build": "node scripts/check-local-layout-variant-build.mjs",
73
79
  "test:build-artifact": "node scripts/check-build-artifact.mjs",
74
80
  "test:browser": "node scripts/browser-smoke.mjs",
75
- "test:package": "node scripts/check-package.mjs"
81
+ "test:accessibility": "node scripts/check-accessibility.mjs",
82
+ "test:typography": "node scripts/test-typography.mjs",
83
+ "test:pixels": "node scripts/check-pixels.mjs",
84
+ "test:pixels:update": "node scripts/check-pixels.mjs --update",
85
+ "test:package": "node scripts/check-package.mjs",
86
+ "test:consumer": "node scripts/check-consumer.mjs"
76
87
  },
77
88
  "author": "",
78
89
  "type": "commonjs",
@@ -81,7 +92,8 @@
81
92
  "@slidev/types": "^52.14.2",
82
93
  "markdown-it": "^14.1.0",
83
94
  "typescript": "^6.0.3",
84
- "vue": "^3.5.13"
95
+ "vue": "^3.5.13",
96
+ "yaml": "^2.9.0"
85
97
  },
86
98
  "devDependencies": {
87
99
  "@eslint/js": "^10.0.1",