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.
- package/README.md +225 -22
- package/components/Slide.vue +12 -58
- package/components/Slot.vue +4 -3
- package/components/StepsGrid.vue +117 -0
- package/components/Text.vue +5 -0
- package/composables/deck-decors.ts +74 -0
- package/composables/deck-slot-markup.cjs +19 -5
- package/composables/decor-sources.ts +43 -0
- package/composables/layout-authoring.ts +34 -9
- package/composables/layout-recipes.ts +16 -2
- package/composables/layout-shorthands.ts +157 -83
- package/composables/local-layout-variant-files.ts +106 -0
- package/composables/local-layout-variants.ts +73 -0
- package/composables/slide-layout.ts +3 -0
- package/composables/text-fit-runtime.ts +7 -3
- package/composables/theme-foundation.ts +7 -3
- package/composables/typography-guard.cjs +59 -0
- package/composables/use-theme-config.ts +17 -10
- package/composables/validate-deck-layouts.cjs +95 -7
- package/composables/validate-deck-typography.cjs +101 -0
- package/env.d.ts +18 -0
- package/example.md +1 -1
- package/package.json +18 -6
- package/scripts/browser-smoke.mjs +85 -23
- package/scripts/check-accessibility.mjs +364 -0
- package/scripts/check-consumer.mjs +159 -0
- package/scripts/check-local-layout-variant-build.mjs +189 -0
- package/scripts/check-package.mjs +18 -2
- package/scripts/check-pixels.mjs +251 -0
- package/scripts/check-typography.mjs +108 -0
- package/scripts/requirements-illustrations.txt +1 -0
- package/scripts/test-typography.mjs +89 -0
- package/scripts/trace-line-art.py +422 -0
- package/scripts/typography-browser.mjs +134 -0
- package/scripts/validate-deck.cjs +23 -3
- package/setup/vite-plugins.ts +109 -2
- package/skills/slidev-practicum/SKILL.md +19 -4
- package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
- package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
- package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
- package/styles/index.css +20 -27
- 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-
|
|
134
|
-
muted: 'var(--theme-text-muted-on-
|
|
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.
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 —
|
|
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.
|
|
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
|
-
"
|
|
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:
|
|
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",
|