slidev-theme-practicum 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/README.md +72 -10
- package/components/Slide.vue +81 -26
- package/components/Slot.vue +33 -48
- package/components/Text.vue +68 -2
- package/components/Timeline.vue +2 -23
- package/composables/deck-slot-markup.cjs +249 -0
- package/composables/decor-catalog.mjs +169 -47
- package/composables/layout-authoring.ts +6 -2
- package/composables/layout-markdown.mjs +33 -0
- package/composables/layout-recipes.ts +51 -5
- package/composables/layout-shorthands.ts +251 -81
- package/composables/markdown-to-vnodes.cjs +1 -0
- package/composables/markdown-to-vnodes.mjs +237 -0
- package/composables/slot-placement.ts +1 -1
- package/composables/slot-spacing.ts +66 -0
- package/composables/text-fit-runtime.ts +458 -76
- package/composables/text-fit-scope.ts +13 -0
- package/composables/text-slot-markdown.mjs +193 -0
- package/composables/text-typography.mjs +2 -0
- package/composables/theme-assets.mjs +60 -0
- package/composables/timeline-grid.ts +27 -0
- package/composables/typescript-require.cjs +29 -0
- package/composables/validate-deck-layouts.cjs +277 -0
- package/decor-library.md +2 -0
- package/example.md +132 -10
- package/package.json +25 -11
- package/public/photos/photo-1.webp +0 -0
- package/public/photos/photo-10.webp +0 -0
- package/public/photos/photo-11.webp +0 -0
- package/public/photos/photo-12.webp +0 -0
- package/public/photos/photo-13.webp +0 -0
- package/public/photos/photo-14.webp +0 -0
- package/public/photos/photo-15.webp +0 -0
- package/public/photos/photo-16.webp +0 -0
- package/public/photos/photo-17.webp +0 -0
- package/public/photos/photo-18.webp +0 -0
- package/public/photos/photo-19.webp +0 -0
- package/public/photos/photo-2.webp +0 -0
- package/public/photos/photo-20.webp +0 -0
- package/public/photos/photo-21.webp +0 -0
- package/public/photos/photo-22.webp +0 -0
- package/public/photos/photo-23.webp +0 -0
- package/public/photos/photo-24.webp +0 -0
- package/public/photos/photo-25.webp +0 -0
- package/public/photos/photo-26.webp +0 -0
- package/public/photos/photo-27.webp +0 -0
- package/public/photos/photo-28.webp +0 -0
- package/public/photos/photo-29.webp +0 -0
- package/public/photos/photo-3.webp +0 -0
- package/public/photos/photo-30.webp +0 -0
- package/public/photos/photo-4.webp +0 -0
- package/public/photos/photo-5.webp +0 -0
- package/public/photos/photo-6.webp +0 -0
- package/public/photos/photo-7.webp +0 -0
- package/public/photos/photo-8.webp +0 -0
- package/public/photos/photo-9.webp +0 -0
- package/scripts/browser-smoke-runtime.mjs +428 -0
- package/scripts/browser-smoke.mjs +424 -0
- package/scripts/build-artifact-assets.mjs +55 -0
- package/scripts/check-build-artifact.mjs +243 -0
- package/scripts/check-package.mjs +132 -0
- package/scripts/check-test-architecture.mjs +176 -0
- package/scripts/npm-spawn.mjs +31 -0
- package/scripts/test-architecture-policy.mjs +245 -0
- package/scripts/theme-asset-inventory.mjs +83 -0
- package/scripts/validate-deck.cjs +18 -0
- package/setup/decor-save-middleware.ts +244 -0
- package/setup/vite-plugins.ts +14 -84
- package/skills/slidev-practicum/SKILL.md +25 -0
- package/skills/slidev-practicum/agents/openai.yaml +4 -0
- package/styles/index.css +10 -1
- package/composables/layout-registry.ts +0 -47
- package/env.d.ts +0 -6
- package/public/photos/photo-1.png +0 -0
- package/public/photos/photo-10.png +0 -0
- package/public/photos/photo-2.png +0 -0
- package/public/photos/photo-3.png +0 -0
- package/public/photos/photo-4.png +0 -0
- package/public/photos/photo-5.png +0 -0
- package/public/photos/photo-6.png +0 -0
- package/public/photos/photo-7.png +0 -0
- package/public/photos/photo-8.png +0 -0
- package/public/photos/photo-9.png +0 -0
- package/slidev-theme-practicum.png +0 -0
- package/slidev-theme-practicum.svg +0 -102
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Comment, Fragment, Text as NativeText, isVNode } from 'vue'
|
|
2
|
+
import { dedentMarkdownSource } from './layout-markdown.mjs'
|
|
3
|
+
import { markdownToVnodes } from './markdown-to-vnodes.mjs'
|
|
4
|
+
|
|
5
|
+
const PASS_THROUGH_BLOCK_TAGS = new Set([
|
|
6
|
+
'ul',
|
|
7
|
+
'ol',
|
|
8
|
+
'blockquote',
|
|
9
|
+
'h1',
|
|
10
|
+
'h2',
|
|
11
|
+
'h3',
|
|
12
|
+
'h4',
|
|
13
|
+
'h5',
|
|
14
|
+
'h6',
|
|
15
|
+
'table',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
const MARKDOWN_INLINE_PATTERN = /\*\*|__|`|!\[|\[[^\]]+\]\(/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {import('vue').VNode | null | undefined} node
|
|
22
|
+
* @param {string} [separator]
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function readVnodePlainText(node, separator = '') {
|
|
26
|
+
if (!node)
|
|
27
|
+
return ''
|
|
28
|
+
|
|
29
|
+
if (node.type === NativeText)
|
|
30
|
+
return String(node.children ?? '')
|
|
31
|
+
|
|
32
|
+
if (typeof node.children === 'string')
|
|
33
|
+
return node.children
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(node.children)) {
|
|
36
|
+
return node.children
|
|
37
|
+
.map(child => isVNode(child) ? readVnodePlainText(child, separator) : String(child ?? ''))
|
|
38
|
+
.join(separator)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {import('vue').VNode | null | undefined} node */
|
|
45
|
+
function readPreLikePlainText(node) {
|
|
46
|
+
return readVnodePlainText(node, '\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {import('vue').VNode | null | undefined} node */
|
|
50
|
+
function isBlankTextNode(node) {
|
|
51
|
+
return node?.type === NativeText
|
|
52
|
+
&& typeof node.children === 'string'
|
|
53
|
+
&& !node.children.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string} source */
|
|
57
|
+
function countOrderedListMarkers(source) {
|
|
58
|
+
return source.match(/\d+\.\s+/g)?.length ?? 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} source
|
|
63
|
+
*/
|
|
64
|
+
function expandInlineOrderedListLines(source) {
|
|
65
|
+
if (countOrderedListMarkers(source) < 2)
|
|
66
|
+
return source
|
|
67
|
+
|
|
68
|
+
if (source.split(/\r?\n/).filter(line => /^\s*\d+\.\s/.test(line)).length >= 2)
|
|
69
|
+
return source
|
|
70
|
+
|
|
71
|
+
return source.replace(/\s+(?=\d+\.\s+)/g, '\n')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} source
|
|
76
|
+
* @returns {string[]}
|
|
77
|
+
*/
|
|
78
|
+
function splitContentLines(source) {
|
|
79
|
+
return source.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} source
|
|
84
|
+
*/
|
|
85
|
+
function looksLikeMarkdown(source) {
|
|
86
|
+
const trimmed = source.trim()
|
|
87
|
+
|
|
88
|
+
if (!trimmed)
|
|
89
|
+
return false
|
|
90
|
+
|
|
91
|
+
if (MARKDOWN_INLINE_PATTERN.test(trimmed))
|
|
92
|
+
return true
|
|
93
|
+
|
|
94
|
+
const lines = splitContentLines(source)
|
|
95
|
+
const firstLine = lines[0] ?? ''
|
|
96
|
+
|
|
97
|
+
if (/^#{1,6}\s/.test(firstLine) || /^>\s/.test(firstLine))
|
|
98
|
+
return true
|
|
99
|
+
|
|
100
|
+
const bulletLines = lines.filter(line => /^[-*+]\s/.test(line))
|
|
101
|
+
const orderedLines = lines.filter(line => /^\d+\.\s/.test(line))
|
|
102
|
+
|
|
103
|
+
if (orderedLines.length >= 2)
|
|
104
|
+
return true
|
|
105
|
+
|
|
106
|
+
if (countOrderedListMarkers(trimmed) >= 2)
|
|
107
|
+
return true
|
|
108
|
+
|
|
109
|
+
if (bulletLines.length >= 2)
|
|
110
|
+
return true
|
|
111
|
+
|
|
112
|
+
if (bulletLines.length === 1 && lines.length === 1)
|
|
113
|
+
return true
|
|
114
|
+
|
|
115
|
+
if (/\n\s*\n/.test(trimmed) && trimmed.split(/\n\s*\n/).map(block => block.trim()).filter(Boolean).length >= 2)
|
|
116
|
+
return true
|
|
117
|
+
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {import('vue').VNode[]} nodes
|
|
123
|
+
* @returns {string | null}
|
|
124
|
+
*/
|
|
125
|
+
function collectMarkdownSource(nodes) {
|
|
126
|
+
const parts = []
|
|
127
|
+
|
|
128
|
+
for (const node of nodes) {
|
|
129
|
+
if (node.type === Comment || isBlankTextNode(node))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if (node.type === NativeText) {
|
|
133
|
+
parts.push(String(node.children ?? ''))
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (node.type === 'pre' || node.type === 'code') {
|
|
138
|
+
const text = readPreLikePlainText(node)
|
|
139
|
+
|
|
140
|
+
if (!text.trim())
|
|
141
|
+
return null
|
|
142
|
+
|
|
143
|
+
parts.push(text)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (node.type === Fragment && Array.isArray(node.children)) {
|
|
148
|
+
const inner = collectMarkdownSource(node.children.filter(isVNode))
|
|
149
|
+
|
|
150
|
+
if (inner == null)
|
|
151
|
+
return null
|
|
152
|
+
|
|
153
|
+
parts.push(inner)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof node.type === 'string' && PASS_THROUGH_BLOCK_TAGS.has(node.type))
|
|
158
|
+
return null
|
|
159
|
+
|
|
160
|
+
if (node.type === 'p' || node.type === 'div') {
|
|
161
|
+
const text = readVnodePlainText(node).trim()
|
|
162
|
+
|
|
163
|
+
if (text)
|
|
164
|
+
parts.push(text)
|
|
165
|
+
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const source = dedentMarkdownSource(expandInlineOrderedListLines(parts.join('\n').replace(/\r\n/g, '\n'))).trim()
|
|
173
|
+
|
|
174
|
+
return source || null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {import('vue').VNode[] | undefined} nodes
|
|
179
|
+
* @returns {import('vue').VNode[] | null}
|
|
180
|
+
*/
|
|
181
|
+
export function resolveTextSlotMarkdown(nodes) {
|
|
182
|
+
if (!nodes?.length)
|
|
183
|
+
return null
|
|
184
|
+
|
|
185
|
+
const source = collectMarkdownSource(nodes)
|
|
186
|
+
|
|
187
|
+
if (source == null || !looksLikeMarkdown(source))
|
|
188
|
+
return null
|
|
189
|
+
|
|
190
|
+
const parsed = markdownToVnodes(source)
|
|
191
|
+
|
|
192
|
+
return parsed.length ? parsed : null
|
|
193
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const NON_BREAKING_SPACE = '\u00a0'
|
|
2
2
|
const RUSSIAN_SPACED_HYPHEN = /(\p{sc=Cyrillic}) - (?=\p{sc=Cyrillic})/gu
|
|
3
|
+
const RUSSIAN_SPACED_EM_DASH = /(\p{sc=Cyrillic}) (?=—)/gu
|
|
3
4
|
const TEXT_NODE_FILTER = 4
|
|
4
5
|
const FILTER_ACCEPT = 1
|
|
5
6
|
const FILTER_REJECT = 2
|
|
@@ -94,6 +95,7 @@ export function typographText(value, options = {}) {
|
|
|
94
95
|
return value
|
|
95
96
|
.replace(serviceWordPattern, `$1${NON_BREAKING_SPACE}`)
|
|
96
97
|
.replace(RUSSIAN_SPACED_HYPHEN, `$1${NON_BREAKING_SPACE}— `)
|
|
98
|
+
.replace(RUSSIAN_SPACED_EM_DASH, `$1${NON_BREAKING_SPACE}`)
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/**
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const THEME_ASSET_PREFIX = '/theme'
|
|
2
|
+
const THEME_ASSET_ORIGIN = 'https://theme-assets.invalid'
|
|
3
|
+
|
|
4
|
+
function unsafeThemeAssetPath() {
|
|
5
|
+
throw new Error('theme asset path содержит небезопасные сегменты')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function themeAssetPath(path = '') {
|
|
9
|
+
const source = String(path)
|
|
10
|
+
const pathPart = source.split(/[?#]/, 1)[0]
|
|
11
|
+
|
|
12
|
+
if (pathPart.includes('\\'))
|
|
13
|
+
unsafeThemeAssetPath()
|
|
14
|
+
|
|
15
|
+
const segments = pathPart
|
|
16
|
+
.replace(/^\/+/, '')
|
|
17
|
+
.split('/')
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
|
|
20
|
+
for (const segment of segments) {
|
|
21
|
+
let decoded = ''
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
decoded = decodeURIComponent(segment)
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
unsafeThemeAssetPath()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\'))
|
|
31
|
+
unsafeThemeAssetPath()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalized = `/${source.replace(/^\/+/, '')}`
|
|
35
|
+
const normalizedPathPart = normalized.split(/[?#]/, 1)[0]
|
|
36
|
+
const candidate = normalizedPathPart === THEME_ASSET_PREFIX
|
|
37
|
+
|| normalizedPathPart.startsWith(`${THEME_ASSET_PREFIX}/`)
|
|
38
|
+
? normalized
|
|
39
|
+
: `${THEME_ASSET_PREFIX}${normalized}`
|
|
40
|
+
const url = new URL(candidate, THEME_ASSET_ORIGIN)
|
|
41
|
+
|
|
42
|
+
if (url.pathname !== THEME_ASSET_PREFIX && !url.pathname.startsWith(`${THEME_ASSET_PREFIX}/`))
|
|
43
|
+
unsafeThemeAssetPath()
|
|
44
|
+
|
|
45
|
+
return candidate
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {number} number
|
|
50
|
+
*/
|
|
51
|
+
export function themePhotoPath(number) {
|
|
52
|
+
return themeAssetPath(`/photos/photo-${number}.webp`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} file
|
|
57
|
+
*/
|
|
58
|
+
export function themeDecorPath(file) {
|
|
59
|
+
return themeAssetPath(`/decor/${String(file).replace(/^\/+/, '')}`)
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TimelineGridItem {
|
|
2
|
+
active?: boolean
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function resolveTimelineItemGridStyle(
|
|
6
|
+
items: readonly TimelineGridItem[],
|
|
7
|
+
index: number,
|
|
8
|
+
) {
|
|
9
|
+
const count = items.length || 1
|
|
10
|
+
const activeItemIndices = items.flatMap((item, itemIndex) => item.active ? [itemIndex] : [])
|
|
11
|
+
|
|
12
|
+
if (count === 3 && activeItemIndices.length === 1) {
|
|
13
|
+
const activeIndex = activeItemIndices[0]
|
|
14
|
+
const spans = items.map((_, itemIndex) => itemIndex === activeIndex ? 6 : 3)
|
|
15
|
+
const start = spans.slice(0, index).reduce((sum, span) => sum + span, 1)
|
|
16
|
+
|
|
17
|
+
return { gridColumn: `${start} / ${start + spans[index]}` }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (count > 12)
|
|
21
|
+
throw new Error('[Timeline] items поддерживает не больше 12 событий.')
|
|
22
|
+
|
|
23
|
+
const start = Math.floor((index * 12) / count) + 1
|
|
24
|
+
const end = Math.floor(((index + 1) * 12) / count) + 1
|
|
25
|
+
|
|
26
|
+
return { gridColumn: `${start} / ${end}` }
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { readFileSync } = require('node:fs')
|
|
2
|
+
const ts = require('typescript')
|
|
3
|
+
|
|
4
|
+
/** @typedef {NodeJS.Module & { _compile(source: string, filename: string): void }} CompilableModule */
|
|
5
|
+
|
|
6
|
+
let registered = false
|
|
7
|
+
|
|
8
|
+
function registerTypeScript() {
|
|
9
|
+
if (registered)
|
|
10
|
+
return
|
|
11
|
+
|
|
12
|
+
require.extensions['.ts'] = (module, filename) => {
|
|
13
|
+
const compilableModule = /** @type {CompilableModule} */ (module)
|
|
14
|
+
const source = readFileSync(filename, 'utf8')
|
|
15
|
+
const { outputText } = ts.transpileModule(source, {
|
|
16
|
+
compilerOptions: {
|
|
17
|
+
module: ts.ModuleKind.CommonJS,
|
|
18
|
+
target: ts.ScriptTarget.ES2022,
|
|
19
|
+
},
|
|
20
|
+
fileName: filename,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
compilableModule._compile(outputText, filename)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
registered = true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { registerTypeScript }
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const { readFileSync } = require('node:fs')
|
|
2
|
+
const { isAbsolute, join, resolve } = require('node:path')
|
|
3
|
+
const { parseSync } = require('@slidev/parser')
|
|
4
|
+
const { createCommentVNode, createTextVNode, createVNode } = require('vue')
|
|
5
|
+
const { registerTypeScript } = require('./typescript-require.cjs')
|
|
6
|
+
const { extractDeckLiveStructure } = require('./deck-slot-markup.cjs')
|
|
7
|
+
const { markdownToVnodes } = require('./markdown-to-vnodes.cjs')
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('@slidev/types').SourceSlideInfo} SourceSlideInfo */
|
|
10
|
+
/** @typedef {import('./layout-recipes').ThemeLayout} ThemeLayout */
|
|
11
|
+
/** @typedef {import('./layout-recipes').ThemeLayoutRole} ThemeLayoutRole */
|
|
12
|
+
/** @typedef {{ role: string, hasCoordinates: boolean, label: string }} ExplicitSlotIntent */
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {(
|
|
15
|
+
* | { kind: 'comment' }
|
|
16
|
+
* | { kind: 'text', content: string }
|
|
17
|
+
* | { kind: 'element', tag: string, props: Record<string, unknown>, children: DeckLiveNode[] }
|
|
18
|
+
* )} DeckLiveNode
|
|
19
|
+
*/
|
|
20
|
+
/** @typedef {{ index: number, page: number, layout: string, variant: string, title: string, label: string }} SlideReference */
|
|
21
|
+
/** @typedef {SlideReference & { message: string, hint?: string }} DeckValidationIssue */
|
|
22
|
+
|
|
23
|
+
const THEME_ROOT = join(__dirname, '..')
|
|
24
|
+
/** @type {Set<ThemeLayout>} */
|
|
25
|
+
const THEME_LAYOUTS = new Set(['cover', 'message', 'explainer', 'collection'])
|
|
26
|
+
|
|
27
|
+
/** @param {...string} segments */
|
|
28
|
+
function themePath(...segments) {
|
|
29
|
+
return join(THEME_ROOT, ...segments)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} value
|
|
34
|
+
* @returns {value is ThemeLayout}
|
|
35
|
+
*/
|
|
36
|
+
function isThemeLayout(value) {
|
|
37
|
+
return THEME_LAYOUTS.has(/** @type {ThemeLayout} */ (value))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
registerTypeScript()
|
|
41
|
+
|
|
42
|
+
const { compileLayoutAuthoring } = require(themePath('composables/layout-authoring.ts'))
|
|
43
|
+
const { hasLayoutShorthand } = require(themePath('composables/layout-shorthands.ts'))
|
|
44
|
+
const {
|
|
45
|
+
resolveLayoutSlotSpec,
|
|
46
|
+
resolveLayoutVariant,
|
|
47
|
+
} = require(themePath('composables/layout-recipes.ts'))
|
|
48
|
+
const { createSlotRules } = require(themePath('composables/slot-rules.ts'))
|
|
49
|
+
|
|
50
|
+
/** @returns {Record<string, import('vue').Component>} */
|
|
51
|
+
function stubComponents() {
|
|
52
|
+
/** @param {string} name */
|
|
53
|
+
const named = name => ({ name })
|
|
54
|
+
return {
|
|
55
|
+
Image: named('Image'),
|
|
56
|
+
Person: named('Person'),
|
|
57
|
+
Slot: named('Slot'),
|
|
58
|
+
Text: named('Text'),
|
|
59
|
+
Timeline: named('Timeline'),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripAuthorNotes(content = '') {
|
|
64
|
+
return String(content).replace(/<!--[\s\S]*?-->/g, '').trim()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {DeckLiveNode} node
|
|
69
|
+
* @param {ReturnType<typeof stubComponents>} components
|
|
70
|
+
* @returns {import('vue').VNode}
|
|
71
|
+
*/
|
|
72
|
+
function liveNodeToVNode(node, components) {
|
|
73
|
+
if (node.kind === 'comment')
|
|
74
|
+
return createCommentVNode()
|
|
75
|
+
|
|
76
|
+
if (node.kind === 'text')
|
|
77
|
+
return createTextVNode(node.content)
|
|
78
|
+
|
|
79
|
+
const component = components[node.tag] ?? node.tag
|
|
80
|
+
return createVNode(
|
|
81
|
+
component,
|
|
82
|
+
node.props,
|
|
83
|
+
node.children.map(child => liveNodeToVNode(child, components)),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {ExplicitSlotIntent[]} intents
|
|
89
|
+
* @param {{ Slot: import('vue').Component, layout: ThemeLayout, variant: string }} input
|
|
90
|
+
*/
|
|
91
|
+
function validateExplicitSlotIntents(intents, input) {
|
|
92
|
+
const slotRules = createSlotRules({ Slot: input.Slot })
|
|
93
|
+
const roleIndices = new Map()
|
|
94
|
+
|
|
95
|
+
for (const intent of intents) {
|
|
96
|
+
const node = createVNode(input.Slot, {
|
|
97
|
+
role: intent.role,
|
|
98
|
+
label: intent.label,
|
|
99
|
+
area: intent.hasCoordinates ? 'explicit' : '',
|
|
100
|
+
})
|
|
101
|
+
slotRules.assertLayoutChild(node, {
|
|
102
|
+
label: `${input.layout}:${input.variant}`,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const index = roleIndices.get(intent.role) ?? 0
|
|
106
|
+
resolveLayoutSlotSpec(
|
|
107
|
+
input.layout,
|
|
108
|
+
input.variant,
|
|
109
|
+
/** @type {ThemeLayoutRole} */ (intent.role),
|
|
110
|
+
index,
|
|
111
|
+
)
|
|
112
|
+
roleIndices.set(intent.role, index + 1)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {SourceSlideInfo} slide
|
|
118
|
+
* @param {number} index
|
|
119
|
+
* @returns {SlideReference}
|
|
120
|
+
*/
|
|
121
|
+
function slideRef(slide, index) {
|
|
122
|
+
const layout = String(slide.frontmatter?.layout ?? '').trim()
|
|
123
|
+
const variant = String(slide.frontmatter?.variant ?? '').trim()
|
|
124
|
+
const title = String(slide.title || slide.content?.match(/^#\s+(.+)/m)?.[1] || '').trim()
|
|
125
|
+
return {
|
|
126
|
+
index,
|
|
127
|
+
page: index + 1,
|
|
128
|
+
layout,
|
|
129
|
+
variant,
|
|
130
|
+
title: title.slice(0, 80),
|
|
131
|
+
label: `${layout}:${variant || '-'}`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} deckPath
|
|
137
|
+
* @returns {Promise<DeckValidationIssue[]>}
|
|
138
|
+
*/
|
|
139
|
+
async function validateDeckLayouts(deckPath) {
|
|
140
|
+
const absolutePath = isAbsolute(deckPath) ? deckPath : resolve(deckPath)
|
|
141
|
+
const source = readFileSync(absolutePath, 'utf8')
|
|
142
|
+
const deck = parseSync(source, absolutePath)
|
|
143
|
+
const components = stubComponents()
|
|
144
|
+
/** @type {DeckValidationIssue[]} */
|
|
145
|
+
const issues = []
|
|
146
|
+
|
|
147
|
+
for (const [index, slide] of deck.slides.entries()) {
|
|
148
|
+
const layout = String(slide.frontmatter?.layout ?? '').trim()
|
|
149
|
+
if (!isThemeLayout(layout))
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
const rawVariant = String(slide.frontmatter?.variant ?? '').trim()
|
|
153
|
+
const arrangement = String(slide.frontmatter?.arrangement ?? '').trim()
|
|
154
|
+
const ref = slideRef(slide, index)
|
|
155
|
+
/** @type {string} */
|
|
156
|
+
let variant
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
variant = resolveLayoutVariant(layout, rawVariant, arrangement)
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
|
|
163
|
+
issues.push({
|
|
164
|
+
...ref,
|
|
165
|
+
message: error instanceof Error ? error.message : String(error),
|
|
166
|
+
hint: typeof hint === 'string' ? hint : undefined,
|
|
167
|
+
})
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const shorthandKey = `${layout}:${variant}`
|
|
172
|
+
|
|
173
|
+
let liveStructure
|
|
174
|
+
try {
|
|
175
|
+
liveStructure = extractDeckLiveStructure(slide.content ?? '')
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
|
|
179
|
+
issues.push({
|
|
180
|
+
...ref,
|
|
181
|
+
message: error instanceof Error ? error.message : String(error),
|
|
182
|
+
hint: typeof hint === 'string' ? hint : undefined,
|
|
183
|
+
})
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (liveStructure.intents.length) {
|
|
188
|
+
try {
|
|
189
|
+
compileLayoutAuthoring({
|
|
190
|
+
props: {
|
|
191
|
+
layout,
|
|
192
|
+
variant: rawVariant,
|
|
193
|
+
arrangement,
|
|
194
|
+
mode: slide.frontmatter?.mode,
|
|
195
|
+
tone: slide.frontmatter?.tone ?? '',
|
|
196
|
+
},
|
|
197
|
+
frontmatter: slide.frontmatter ?? {},
|
|
198
|
+
children: liveStructure.children.map(node => liveNodeToVNode(node, components)),
|
|
199
|
+
components,
|
|
200
|
+
defaultTone: 'blue',
|
|
201
|
+
})
|
|
202
|
+
validateExplicitSlotIntents(liveStructure.intents, {
|
|
203
|
+
Slot: components.Slot,
|
|
204
|
+
layout,
|
|
205
|
+
variant,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
|
|
210
|
+
issues.push({
|
|
211
|
+
...ref,
|
|
212
|
+
message: error instanceof Error ? error.message : String(error),
|
|
213
|
+
hint: typeof hint === 'string' ? hint : undefined,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!hasLayoutShorthand(shorthandKey) && layout !== 'cover')
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
const content = stripAuthorNotes(slide.content ?? '')
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
compileLayoutAuthoring({
|
|
226
|
+
props: {
|
|
227
|
+
layout,
|
|
228
|
+
variant: rawVariant,
|
|
229
|
+
arrangement,
|
|
230
|
+
mode: slide.frontmatter?.mode,
|
|
231
|
+
tone: slide.frontmatter?.tone ?? '',
|
|
232
|
+
},
|
|
233
|
+
frontmatter: slide.frontmatter ?? {},
|
|
234
|
+
children: markdownToVnodes(content),
|
|
235
|
+
components,
|
|
236
|
+
defaultTone: 'blue',
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
const hint = error && typeof error === 'object' && 'hint' in error ? error.hint : undefined
|
|
241
|
+
issues.push({
|
|
242
|
+
...ref,
|
|
243
|
+
message: error instanceof Error ? error.message : String(error),
|
|
244
|
+
hint: typeof hint === 'string' ? hint : undefined,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return issues
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {DeckValidationIssue[]} issues
|
|
254
|
+
* @param {string} [deckPath]
|
|
255
|
+
*/
|
|
256
|
+
function formatDeckLayoutIssues(issues, deckPath = 'deck') {
|
|
257
|
+
if (!issues.length)
|
|
258
|
+
return ''
|
|
259
|
+
|
|
260
|
+
const body = issues.map((issue) => {
|
|
261
|
+
const lines = [
|
|
262
|
+
`Слайд ${issue.page} (${issue.label})${issue.title ? `: «${issue.title}»` : ''}`,
|
|
263
|
+
` ${issue.message}`,
|
|
264
|
+
]
|
|
265
|
+
if (issue.hint)
|
|
266
|
+
lines.push(` Подсказка: ${issue.hint}`)
|
|
267
|
+
lines.push(' См. example.md в slidev-theme-practicum (заметки «Контракт»).')
|
|
268
|
+
return lines.join('\n')
|
|
269
|
+
}).join(`\n\n${'—'.repeat(40)}\n\n`)
|
|
270
|
+
|
|
271
|
+
return `${body}\n\nКолода: ${deckPath}\n`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
formatDeckLayoutIssues,
|
|
276
|
+
validateDeckLayouts,
|
|
277
|
+
}
|