slidev-theme-practicum 0.3.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 +78 -4
- package/components/Slide.vue +1 -1
- package/components/Slot.vue +2 -2
- package/components/StepsGrid.vue +19 -14
- package/components/Text.vue +5 -0
- package/composables/deck-slot-markup.cjs +19 -5
- package/composables/layout-shorthands.ts +152 -67
- package/composables/text-fit-runtime.ts +7 -3
- package/composables/theme-foundation.ts +7 -3
- package/composables/typography-guard.cjs +59 -0
- package/composables/validate-deck-layouts.cjs +18 -1
- package/composables/validate-deck-typography.cjs +101 -0
- package/example.md +1 -1
- package/package.json +14 -5
- package/scripts/browser-smoke.mjs +20 -23
- package/scripts/check-accessibility.mjs +364 -0
- package/scripts/check-consumer.mjs +159 -0
- package/scripts/check-pixels.mjs +251 -0
- package/scripts/check-typography.mjs +108 -0
- package/scripts/test-typography.mjs +89 -0
- package/scripts/typography-browser.mjs +134 -0
- package/scripts/validate-deck.cjs +22 -2
- package/skills/slidev-practicum/SKILL.md +1 -0
- package/styles/index.css +20 -27
- package/styles/vars.css +6 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join, resolve } from 'node:path'
|
|
15
|
+
import process from 'node:process'
|
|
16
|
+
import { chromium } from 'playwright-chromium'
|
|
17
|
+
import { createStaticDistServer, isSameOriginUrl } from './browser-smoke-runtime.mjs'
|
|
18
|
+
|
|
19
|
+
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
20
|
+
const CLI_PATH = resolve(PROJECT_ROOT, 'node_modules/@slidev/cli/bin/slidev.mjs')
|
|
21
|
+
const workRoot = mkdtempSync(join(tmpdir(), 'slidev-theme-practicum-consumer-'))
|
|
22
|
+
const packDir = join(workRoot, 'pack')
|
|
23
|
+
const consumerRoot = join(workRoot, 'consumer')
|
|
24
|
+
const consumerDist = join(consumerRoot, 'dist')
|
|
25
|
+
|
|
26
|
+
function run(command, args, cwd, options = {}) {
|
|
27
|
+
const result = spawnSync(command, args, {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
env: options.env ?? process.env,
|
|
31
|
+
shell: process.platform === 'win32',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
assert.equal(
|
|
35
|
+
result.status,
|
|
36
|
+
0,
|
|
37
|
+
`${command} ${args.join(' ')} завершился с кодом ${result.status}\n${result.stderr || result.stdout}`,
|
|
38
|
+
)
|
|
39
|
+
return result.stdout
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function inspectConsumerBuild() {
|
|
43
|
+
const externalRequests = []
|
|
44
|
+
let browser
|
|
45
|
+
let server
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
server = await createStaticDistServer(consumerDist)
|
|
49
|
+
browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
|
|
50
|
+
const context = await browser.newContext({
|
|
51
|
+
colorScheme: 'light',
|
|
52
|
+
reducedMotion: 'reduce',
|
|
53
|
+
serviceWorkers: 'block',
|
|
54
|
+
viewport: { width: 1280, height: 720 },
|
|
55
|
+
})
|
|
56
|
+
const page = await context.newPage()
|
|
57
|
+
page.on('request', (request) => {
|
|
58
|
+
if (!isSameOriginUrl(server.origin, request.url()))
|
|
59
|
+
externalRequests.push(request.url())
|
|
60
|
+
})
|
|
61
|
+
await page.goto(`${server.origin}/#/1`, { waitUntil: 'domcontentloaded' })
|
|
62
|
+
const slide = page.locator('.slidev-page[data-slidev-no="1"]')
|
|
63
|
+
await slide.waitFor({ state: 'visible' })
|
|
64
|
+
await page.evaluate(async () => {
|
|
65
|
+
await document.fonts?.ready
|
|
66
|
+
await new Promise(resolveFrame => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
assert.equal(
|
|
70
|
+
(await slide.locator('h1').textContent())?.replace(/\s+/gu, ' ').trim(),
|
|
71
|
+
'Тема установлена из архива',
|
|
72
|
+
'внешняя презентация должна отрисовать содержимое через установленную тему',
|
|
73
|
+
)
|
|
74
|
+
assert.equal(
|
|
75
|
+
await slide.locator('.Slide_layout_cover.Slide_variant_grid').count(),
|
|
76
|
+
1,
|
|
77
|
+
'внешняя презентация должна получить layout и variant из архива темы',
|
|
78
|
+
)
|
|
79
|
+
for (const assetPath of ['/theme/favicon.svg', '/theme/photos/photo-1.webp']) {
|
|
80
|
+
const response = await page.request.get(`${server.origin}${assetPath}`)
|
|
81
|
+
assert.equal(response.status(), 200, `${assetPath} должен попасть во внешнюю сборку`)
|
|
82
|
+
}
|
|
83
|
+
assert.deepEqual([...new Set(externalRequests)], [], 'внешняя сборка не должна обращаться в сеть')
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await browser?.close()
|
|
87
|
+
await server?.close()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(packDir, { recursive: true })
|
|
93
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
94
|
+
const packOutput = run('npm', ['pack', '--json', '--pack-destination', packDir], PROJECT_ROOT)
|
|
95
|
+
const [pack] = JSON.parse(packOutput)
|
|
96
|
+
const archivePath = join(packDir, pack.filename)
|
|
97
|
+
assert.equal(existsSync(archivePath), true, 'npm pack должен создать архив темы')
|
|
98
|
+
|
|
99
|
+
writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({
|
|
100
|
+
name: 'slidev-theme-practicum-consumer-check',
|
|
101
|
+
private: true,
|
|
102
|
+
type: 'module',
|
|
103
|
+
}, null, 2)}\n`)
|
|
104
|
+
writeFileSync(join(consumerRoot, 'slides.md'), `---
|
|
105
|
+
theme: practicum
|
|
106
|
+
title: Проверка установленного архива
|
|
107
|
+
routerMode: hash
|
|
108
|
+
preload: false
|
|
109
|
+
layout: cover
|
|
110
|
+
variant: grid
|
|
111
|
+
mode: dark
|
|
112
|
+
tone: blue
|
|
113
|
+
themeConfig:
|
|
114
|
+
deckTitle: Проверка установленного архива
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
<Slot role="primary" surface="dark" margin="4" gap="4">
|
|
118
|
+
<Text as="h1" size="7">Тема установлена из архива</Text>
|
|
119
|
+
<Text size="3" align="end" muted>Внешняя презентация собирает layout, компоненты и ресурсы только из упакованной темы.</Text>
|
|
120
|
+
</Slot>
|
|
121
|
+
|
|
122
|
+
<Slot role="media" surface="color" tone="green" :background="{ src: '/theme/photos/photo-1.webp' }" />
|
|
123
|
+
|
|
124
|
+
<Slot role="media" surface="dark" :decor="{ meaning: 'cover', tone: 'green' }" />
|
|
125
|
+
|
|
126
|
+
<Slot role="media" :decor="{ meaning: 'workspace', tone: 'blue' }" />
|
|
127
|
+
`)
|
|
128
|
+
|
|
129
|
+
run('npm', [
|
|
130
|
+
'install',
|
|
131
|
+
'--prefer-offline',
|
|
132
|
+
'--ignore-scripts',
|
|
133
|
+
'--no-audit',
|
|
134
|
+
'--no-fund',
|
|
135
|
+
'--no-package-lock',
|
|
136
|
+
'--no-save',
|
|
137
|
+
archivePath,
|
|
138
|
+
], consumerRoot)
|
|
139
|
+
|
|
140
|
+
const installedRoot = join(consumerRoot, 'node_modules/slidev-theme-practicum')
|
|
141
|
+
assert.equal(lstatSync(installedRoot).isSymbolicLink(), false, 'тема должна быть распакована, а не связана с рабочей копией')
|
|
142
|
+
assert.ok(
|
|
143
|
+
realpathSync(installedRoot).startsWith(realpathSync(consumerRoot)),
|
|
144
|
+
'установленная тема должна находиться внутри внешнего проекта',
|
|
145
|
+
)
|
|
146
|
+
const installedManifest = JSON.parse(readFileSync(join(installedRoot, 'package.json'), 'utf8'))
|
|
147
|
+
const sourceManifest = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8'))
|
|
148
|
+
assert.equal(installedManifest.version, sourceManifest.version)
|
|
149
|
+
|
|
150
|
+
run(process.execPath, [CLI_PATH, 'build', 'slides.md', '--out', consumerDist], consumerRoot)
|
|
151
|
+
await inspectConsumerBuild()
|
|
152
|
+
console.log(
|
|
153
|
+
`Потребитель архива: ${pack.filename}, версия=${installedManifest.version}, `
|
|
154
|
+
+ 'установка=локальный архив, сборка=ok, браузер=ok, внешние запросы=0, ресурсы=2',
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
rmSync(workRoot, { recursive: true, force: true })
|
|
159
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join, resolve } from 'node:path'
|
|
14
|
+
import { parseSync } from '@slidev/parser'
|
|
15
|
+
import { chromium } from 'playwright-chromium'
|
|
16
|
+
import { createStaticDistServer, isSameOriginUrl } from './browser-smoke-runtime.mjs'
|
|
17
|
+
|
|
18
|
+
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
19
|
+
const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
|
|
20
|
+
const DECK_PATH = resolve(PROJECT_ROOT, 'example.md')
|
|
21
|
+
const BASELINE_DIR = resolve(PROJECT_ROOT, 'tests/visual-baselines/example')
|
|
22
|
+
const MANIFEST_PATH = join(BASELINE_DIR, 'manifest.json')
|
|
23
|
+
const UPDATE_BASELINES = process.argv.includes('--update')
|
|
24
|
+
const VIEWPORT = { width: 960, height: 540 }
|
|
25
|
+
const DEVICE_SCALE_FACTOR = 1
|
|
26
|
+
|
|
27
|
+
function slideFilename(slideNumber) {
|
|
28
|
+
return `slide-${String(slideNumber).padStart(2, '0')}.png`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sha256(buffer) {
|
|
32
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function settleSlide(page, slide) {
|
|
36
|
+
await page.evaluate(async () => {
|
|
37
|
+
await Promise.race([
|
|
38
|
+
document.fonts?.ready ?? Promise.resolve(),
|
|
39
|
+
new Promise(resolveTimeout => setTimeout(resolveTimeout, 3_000)),
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await slide.evaluate(async (root) => {
|
|
44
|
+
await Promise.all(
|
|
45
|
+
[...root.querySelectorAll('img')].map(image => image.decode().catch(() => undefined)),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
await new Promise((resolveStable, rejectStable) => {
|
|
49
|
+
const timeoutAt = performance.now() + 3_000
|
|
50
|
+
let previousSignature = ''
|
|
51
|
+
let stableFrames = 0
|
|
52
|
+
|
|
53
|
+
const readSignature = () => [...root.querySelectorAll('.Text')]
|
|
54
|
+
.map(element => [
|
|
55
|
+
element.getAttribute('class'),
|
|
56
|
+
element.clientWidth,
|
|
57
|
+
element.clientHeight,
|
|
58
|
+
element.scrollWidth,
|
|
59
|
+
element.scrollHeight,
|
|
60
|
+
].join(':'))
|
|
61
|
+
.join('|')
|
|
62
|
+
|
|
63
|
+
const observeFrame = () => {
|
|
64
|
+
const signature = readSignature()
|
|
65
|
+
stableFrames = signature === previousSignature ? stableFrames + 1 : 0
|
|
66
|
+
previousSignature = signature
|
|
67
|
+
|
|
68
|
+
if (stableFrames >= 8) {
|
|
69
|
+
resolveStable()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (performance.now() >= timeoutAt) {
|
|
74
|
+
rejectStable(new Error('Раскладка слайда не стабилизировалась перед снимком.'))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
requestAnimationFrame(observeFrame)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
requestAnimationFrame(observeFrame)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function openSlide(page, origin, slideNumber) {
|
|
87
|
+
const hash = `#/${slideNumber}`
|
|
88
|
+
|
|
89
|
+
if (page.url() === 'about:blank')
|
|
90
|
+
await page.goto(`${origin}/${hash}`, { waitUntil: 'domcontentloaded' })
|
|
91
|
+
else {
|
|
92
|
+
await page.evaluate((nextHash) => {
|
|
93
|
+
window.location.hash = nextHash
|
|
94
|
+
}, hash)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await page.waitForFunction(expectedHash => window.location.hash === expectedHash, hash)
|
|
98
|
+
const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
|
|
99
|
+
await slide.waitFor({ state: 'visible' })
|
|
100
|
+
try {
|
|
101
|
+
await settleSlide(page, slide)
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
throw new Error(`Слайд ${slideNumber}: ${error.message}`, { cause: error })
|
|
105
|
+
}
|
|
106
|
+
return slide
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removeOldBaselines() {
|
|
110
|
+
if (!existsSync(BASELINE_DIR))
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
for (const file of readdirSync(BASELINE_DIR)) {
|
|
114
|
+
if (/^slide-\d+\.png$/u.test(file))
|
|
115
|
+
unlinkSync(join(BASELINE_DIR, file))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function baselineFiles() {
|
|
120
|
+
if (!existsSync(BASELINE_DIR))
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
return readdirSync(BASELINE_DIR)
|
|
124
|
+
.filter(file => /^slide-\d+\.png$/u.test(file))
|
|
125
|
+
.sort()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function run() {
|
|
129
|
+
const source = readFileSync(DECK_PATH, 'utf8')
|
|
130
|
+
const slideCount = parseSync(source, DECK_PATH).slides.length
|
|
131
|
+
const actualDir = join(tmpdir(), `slidev-theme-practicum-pixels-${process.pid}`)
|
|
132
|
+
const externalRequests = []
|
|
133
|
+
const screenshots = []
|
|
134
|
+
let browser
|
|
135
|
+
let server
|
|
136
|
+
|
|
137
|
+
mkdirSync(actualDir, { recursive: true })
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
server = await createStaticDistServer(DIST_DIR)
|
|
141
|
+
browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
|
|
142
|
+
const context = await browser.newContext({
|
|
143
|
+
colorScheme: 'light',
|
|
144
|
+
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
145
|
+
locale: 'ru-RU',
|
|
146
|
+
reducedMotion: 'reduce',
|
|
147
|
+
serviceWorkers: 'block',
|
|
148
|
+
timezoneId: 'Europe/Moscow',
|
|
149
|
+
viewport: VIEWPORT,
|
|
150
|
+
})
|
|
151
|
+
const page = await context.newPage()
|
|
152
|
+
page.setDefaultTimeout(15_000)
|
|
153
|
+
page.on('request', (request) => {
|
|
154
|
+
if (!isSameOriginUrl(server.origin, request.url()))
|
|
155
|
+
externalRequests.push(request.url())
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
let fontEnvironment
|
|
159
|
+
for (let slideNumber = 1; slideNumber <= slideCount; slideNumber += 1) {
|
|
160
|
+
const slide = await openSlide(page, server.origin, slideNumber)
|
|
161
|
+
fontEnvironment ??= await slide.evaluate(root => ({
|
|
162
|
+
cssStack: getComputedStyle(root.querySelector('.slidev-layout')).fontFamily,
|
|
163
|
+
interAvailable: document.fonts.check('16px "Inter"'),
|
|
164
|
+
ysTextAvailable: document.fonts.check('16px "YS Text"'),
|
|
165
|
+
}))
|
|
166
|
+
const image = await slide.screenshot({
|
|
167
|
+
animations: 'disabled',
|
|
168
|
+
caret: 'hide',
|
|
169
|
+
scale: 'css',
|
|
170
|
+
})
|
|
171
|
+
screenshots.push({
|
|
172
|
+
file: slideFilename(slideNumber),
|
|
173
|
+
image,
|
|
174
|
+
sha256: sha256(image),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(
|
|
179
|
+
[...new Set(externalRequests)],
|
|
180
|
+
[],
|
|
181
|
+
`Пиксельный контур не должен обращаться во внешнюю сеть: ${externalRequests.join(', ')}`,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const environment = {
|
|
185
|
+
arch: process.arch,
|
|
186
|
+
chromium: browser.version(),
|
|
187
|
+
colorProfile: 'srgb',
|
|
188
|
+
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
189
|
+
fonts: fontEnvironment,
|
|
190
|
+
platform: process.platform,
|
|
191
|
+
slideCount,
|
|
192
|
+
viewport: VIEWPORT,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (UPDATE_BASELINES) {
|
|
196
|
+
mkdirSync(BASELINE_DIR, { recursive: true })
|
|
197
|
+
removeOldBaselines()
|
|
198
|
+
for (const screenshot of screenshots)
|
|
199
|
+
writeFileSync(join(BASELINE_DIR, screenshot.file), screenshot.image)
|
|
200
|
+
writeFileSync(MANIFEST_PATH, `${JSON.stringify({
|
|
201
|
+
environment,
|
|
202
|
+
slides: screenshots.map(({ file, sha256: digest }) => ({ file, sha256: digest })),
|
|
203
|
+
}, null, 2)}\n`)
|
|
204
|
+
console.log(`Пиксельные эталоны обновлены: ${slideCount} слайдов, Chromium ${environment.chromium}`)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
assert.equal(existsSync(MANIFEST_PATH), true, 'Отсутствует manifest пиксельных эталонов. Запустите npm run test:pixels:update.')
|
|
209
|
+
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'))
|
|
210
|
+
assert.deepEqual(
|
|
211
|
+
environment,
|
|
212
|
+
manifest.environment,
|
|
213
|
+
'Окружение пиксельной проверки отличается от принятого эталона. Обновите эталоны осознанно.',
|
|
214
|
+
)
|
|
215
|
+
assert.deepEqual(
|
|
216
|
+
baselineFiles(),
|
|
217
|
+
screenshots.map(({ file }) => file),
|
|
218
|
+
'Набор PNG-эталонов не соответствует всем слайдам example.md.',
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const failures = []
|
|
222
|
+
for (const screenshot of screenshots) {
|
|
223
|
+
const baselinePath = join(BASELINE_DIR, screenshot.file)
|
|
224
|
+
const baseline = readFileSync(baselinePath)
|
|
225
|
+
const expectedDigest = manifest.slides.find(slide => slide.file === screenshot.file)?.sha256
|
|
226
|
+
assert.equal(sha256(baseline), expectedDigest, `${screenshot.file}: PNG не соответствует manifest`)
|
|
227
|
+
|
|
228
|
+
if (baseline.equals(screenshot.image))
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
const actualPath = join(actualDir, screenshot.file)
|
|
232
|
+
writeFileSync(actualPath, screenshot.image)
|
|
233
|
+
failures.push(`${screenshot.file}: эталон ${baselinePath}, результат ${actualPath}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
assert.deepEqual(failures, [], `Пиксельная регрессия:\n${failures.join('\n')}`)
|
|
237
|
+
rmSync(actualDir, { recursive: true, force: true })
|
|
238
|
+
console.log(`Пиксельная регрессия: ${slideCount}/${slideCount} слайдов совпали, Chromium ${environment.chromium}`)
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (!UPDATE_BASELINES && existsSync(actualDir) && readdirSync(actualDir).length === 0)
|
|
242
|
+
rmSync(actualDir, { recursive: true, force: true })
|
|
243
|
+
throw error
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await browser?.close()
|
|
247
|
+
await server?.close()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await run()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { parseArgs } from 'node:util'
|
|
7
|
+
import { parseSync } from '@slidev/parser'
|
|
8
|
+
import { createStaticDistServer } from './browser-smoke-runtime.mjs'
|
|
9
|
+
import { inspectRenderedTypography } from './typography-browser.mjs'
|
|
10
|
+
|
|
11
|
+
function loadChromium(deckPath) {
|
|
12
|
+
// В установленной теме Playwright может принадлежать проекту самой колоды.
|
|
13
|
+
const require = createRequire(import.meta.url)
|
|
14
|
+
try {
|
|
15
|
+
return require('playwright-chromium').chromium
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
try {
|
|
19
|
+
return createRequire(deckPath)('playwright-chromium').chromium
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error('Для браузерной проверки установите в проект колоды playwright-chromium: npm install -D playwright-chromium')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function checkTypography({ deckPath, distDir }) {
|
|
28
|
+
const absolutePath = resolve(deckPath)
|
|
29
|
+
const slides = parseSync(readFileSync(absolutePath, 'utf8'), absolutePath).slides
|
|
30
|
+
const hashRouter = slides[0]?.frontmatter.routerMode === 'hash'
|
|
31
|
+
if (slides.some(slide => slide.frontmatter.src))
|
|
32
|
+
throw new Error('Браузерная проверка пока требует колоду без включений src. Соберите слайды в один Markdown-файл и повторите проверку его сборки.')
|
|
33
|
+
const chromium = loadChromium(absolutePath)
|
|
34
|
+
const issues = []
|
|
35
|
+
let browser
|
|
36
|
+
let server
|
|
37
|
+
let textCount = 0
|
|
38
|
+
try {
|
|
39
|
+
server = await createStaticDistServer(resolve(distDir))
|
|
40
|
+
browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
|
|
41
|
+
const context = await browser.newContext({
|
|
42
|
+
colorScheme: 'light',
|
|
43
|
+
reducedMotion: 'reduce',
|
|
44
|
+
serviceWorkers: 'block',
|
|
45
|
+
viewport: { width: 1280, height: 720 },
|
|
46
|
+
})
|
|
47
|
+
await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
|
|
48
|
+
const page = await context.newPage()
|
|
49
|
+
page.setDefaultTimeout(15_000)
|
|
50
|
+
let currentSlide = 1
|
|
51
|
+
page.on('pageerror', error => issues.push({ page: currentSlide, code: 'browser-error', message: error.message }))
|
|
52
|
+
for (let index = 0; index < slides.length; index += 1) {
|
|
53
|
+
const slideNumber = index + 1
|
|
54
|
+
currentSlide = slideNumber
|
|
55
|
+
try {
|
|
56
|
+
if (!hashRouter || index === 0)
|
|
57
|
+
await page.goto(`${server.origin}/${hashRouter ? '#/' : ''}${slideNumber}`, { waitUntil: 'domcontentloaded' })
|
|
58
|
+
else
|
|
59
|
+
await page.evaluate(number => window.location.hash = `#/${number}`, slideNumber)
|
|
60
|
+
const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
|
|
61
|
+
await slide.waitFor({ state: 'visible' })
|
|
62
|
+
if (!await slide.locator('.Slide').count())
|
|
63
|
+
throw new Error('В сборке не найден слайд темы Practicum. Проверьте соответствие файла колоды и каталога сборки.')
|
|
64
|
+
const result = await inspectRenderedTypography(slide)
|
|
65
|
+
textCount += result.textCount
|
|
66
|
+
issues.push(...result.issues.map(issue => ({ page: slideNumber, ...issue })))
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
issues.push({ page: slideNumber, code: 'render-error', message: error.message })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await browser?.close()
|
|
75
|
+
await server?.close()
|
|
76
|
+
}
|
|
77
|
+
return { issues, slideCount: slides.length, textCount }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
const { values, positionals } = parseArgs({
|
|
82
|
+
allowPositionals: true,
|
|
83
|
+
options: {
|
|
84
|
+
dist: { type: 'string' },
|
|
85
|
+
help: { type: 'boolean', short: 'h' },
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
if (values.help) {
|
|
89
|
+
console.log('Использование: slidev-practicum-check-typography [slides.md] [--dist dist]\n'
|
|
90
|
+
+ 'Проверяет типографику показанных слайдов готовой сборки. Сначала соберите эту же колоду через slidev build.')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (positionals.length > 1)
|
|
94
|
+
throw new Error('Укажите один файл колоды.')
|
|
95
|
+
const deckPath = resolve(positionals[0] ?? 'example.md')
|
|
96
|
+
const result = await checkTypography({ deckPath, distDir: values.dist ?? resolve(dirname(deckPath), 'dist') })
|
|
97
|
+
for (const issue of result.issues)
|
|
98
|
+
console.error(`Слайд ${issue.page}: ${issue.message}${issue.hint ? `\n ${issue.hint}` : ''}`)
|
|
99
|
+
console.log(`Типографика: слайды=${result.slideCount}, текстовые фрагменты=${result.textCount}, нарушения=${result.issues.length}`)
|
|
100
|
+
process.exitCode = result.issues.length ? 1 : 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
104
|
+
main().catch((error) => {
|
|
105
|
+
console.error(error.message)
|
|
106
|
+
process.exitCode = 1
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { chromium } from 'playwright-chromium'
|
|
6
|
+
import { checkTypography } from './check-typography.mjs'
|
|
7
|
+
import { collectRenderedTypography, inspectRenderedTypography } from './typography-browser.mjs'
|
|
8
|
+
|
|
9
|
+
const root = resolve(import.meta.dirname, '..')
|
|
10
|
+
const deckPath = join(root, 'tests/fixtures/typography-deck.md')
|
|
11
|
+
mkdirSync(join(root, '.slidev'), { recursive: true })
|
|
12
|
+
const workRoot = mkdtempSync(join(root, '.slidev/typography-test-'))
|
|
13
|
+
|
|
14
|
+
async function checkDom() {
|
|
15
|
+
const browser = await chromium.launch()
|
|
16
|
+
try {
|
|
17
|
+
const page = await browser.newPage()
|
|
18
|
+
await page.setContent(`
|
|
19
|
+
<style>
|
|
20
|
+
.Slot-Content { width: 600px; height: 400px; }
|
|
21
|
+
.Text { font-size: 24px; color: rgb(0, 0, 0); }
|
|
22
|
+
</style>
|
|
23
|
+
<div class="slidev-layout">
|
|
24
|
+
<div class="Header" style="font-size: 9px">Служебная шапка 2026</div>
|
|
25
|
+
<div class="Slot"><div class="Slot-Content">
|
|
26
|
+
<div class="Text">Первый <span style="color: #000">тот же цвет</span><span style="color: color(srgb 0 0 0)">цвет sRGB</span></div>
|
|
27
|
+
<div class="Text" style="font-size: 40px">Заголовок</div>
|
|
28
|
+
<data class="Text" style="font-size: 60px">42</data>
|
|
29
|
+
<div class="Logo" style="font-size: 1px">Логотип</div>
|
|
30
|
+
<svg><text style="font-size: 2px">Иллюстрация</text></svg>
|
|
31
|
+
<div style="opacity: 0"><span style="font-size: 3px">Скрытый текст</span></div>
|
|
32
|
+
</div></div>
|
|
33
|
+
</div>`)
|
|
34
|
+
const slide = page.locator('.slidev-layout')
|
|
35
|
+
assert.deepEqual((await inspectRenderedTypography(slide)).issues, [])
|
|
36
|
+
const visible = await slide.evaluate(collectRenderedTypography)
|
|
37
|
+
assert.deepEqual(visible.texts.map(text => text.text), ['Первый', 'тот же цвет', 'цвет sRGB', 'Заголовок', '42'])
|
|
38
|
+
assert.equal(new Set(visible.texts.map(text => text.color)).size, 1)
|
|
39
|
+
|
|
40
|
+
await page.locator('.Text span').last().evaluate((element) => {
|
|
41
|
+
element.style.color = 'color(srgb 1 0 0)'
|
|
42
|
+
})
|
|
43
|
+
assert.ok((await inspectRenderedTypography(slide)).issues.some(issue => issue.code === 'slot-colors'))
|
|
44
|
+
|
|
45
|
+
await page.locator('.Slot-Content').evaluate((content) => {
|
|
46
|
+
const timelineLabel = document.createElement('span')
|
|
47
|
+
timelineLabel.textContent = '2026'
|
|
48
|
+
timelineLabel.style.fontSize = '16px'
|
|
49
|
+
content.append(timelineLabel)
|
|
50
|
+
})
|
|
51
|
+
assert.ok((await inspectRenderedTypography(slide)).issues.some(issue => issue.code === 'text-sizes'))
|
|
52
|
+
|
|
53
|
+
await page.locator('.Text').first().evaluate((element) => {
|
|
54
|
+
element.setAttribute('data-text-color', 'text-muted')
|
|
55
|
+
element.style.transform = 'translateX(650px)'
|
|
56
|
+
})
|
|
57
|
+
const violations = (await inspectRenderedTypography(slide)).issues.map(issue => issue.code)
|
|
58
|
+
assert.ok(violations.includes('muted-text'))
|
|
59
|
+
assert.ok(violations.includes('text-overflow'))
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await browser.close()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const build = spawnSync(process.execPath, [
|
|
68
|
+
join(root, 'node_modules/@slidev/cli/bin/slidev.mjs'),
|
|
69
|
+
'build', deckPath, '--out', workRoot,
|
|
70
|
+
], { cwd: root, encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
71
|
+
assert.equal(build.status, 0, build.stderr || build.stdout)
|
|
72
|
+
const { issues, slideCount } = await checkTypography({ deckPath, distDir: workRoot })
|
|
73
|
+
assert.equal(slideCount, 9)
|
|
74
|
+
const codes = page => issues.filter(issue => issue.page === page).map(issue => issue.code)
|
|
75
|
+
for (const page of [1, 3, 8])
|
|
76
|
+
assert.deepEqual(codes(page), [], JSON.stringify(issues.filter(issue => issue.page === page)))
|
|
77
|
+
assert.ok(codes(2).includes('text-sizes'))
|
|
78
|
+
assert.ok(codes(4).includes('muted-text'))
|
|
79
|
+
assert.ok(codes(5).includes('text-overflow'))
|
|
80
|
+
assert.ok(codes(6).includes('number-sizes'))
|
|
81
|
+
assert.ok(codes(7).includes('slot-colors'))
|
|
82
|
+
assert.ok(codes(9).includes('text-overflow'), JSON.stringify(issues.filter(issue => issue.page === 9)))
|
|
83
|
+
assert.equal(issues.some(issue => ['render-error', 'browser-error'].includes(issue.code)), false, JSON.stringify(issues))
|
|
84
|
+
await checkDom()
|
|
85
|
+
console.log('Типографика: проверены 9 слайдов с явной разметкой и Markdown, цвета, исключения и геометрия DOM.')
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
rmSync(workRoot, { recursive: true, force: true })
|
|
89
|
+
}
|