slidev-theme-practicum 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +238 -18
  2. package/components/Slide.vue +27 -4
  3. package/components/Slot.vue +2 -1
  4. package/components/StepsGrid.vue +112 -0
  5. package/composables/deck-decors.ts +74 -0
  6. package/composables/decor-sources.ts +43 -0
  7. package/composables/layout-authoring.ts +34 -9
  8. package/composables/layout-recipes.ts +83 -2
  9. package/composables/layout-shorthands.ts +233 -2
  10. package/composables/local-layout-variant-files.ts +106 -0
  11. package/composables/local-layout-variants.ts +73 -0
  12. package/composables/slide-layout.ts +3 -0
  13. package/composables/use-theme-config.ts +17 -10
  14. package/composables/validate-deck-layouts.cjs +77 -6
  15. package/env.d.ts +18 -0
  16. package/example.md +135 -5
  17. package/package.json +6 -3
  18. package/scripts/browser-smoke.mjs +168 -5
  19. package/scripts/check-local-layout-variant-build.mjs +189 -0
  20. package/scripts/check-package.mjs +18 -2
  21. package/scripts/requirements-illustrations.txt +1 -0
  22. package/scripts/trace-line-art.py +422 -0
  23. package/scripts/validate-deck.cjs +1 -1
  24. package/setup/vite-plugins.ts +109 -2
  25. package/skills/slidev-practicum/SKILL.md +18 -4
  26. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  27. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  28. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  29. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  30. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  31. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  32. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  33. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  34. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  35. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  36. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
@@ -10,8 +10,28 @@ import {
10
10
 
11
11
  const PROJECT_ROOT = resolve(import.meta.dirname, '..')
12
12
  const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
13
- const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 32, 40, 41]
13
+ const EXPECTED_DOCUMENT_TITLE = 'Яндекс Практикум справочная галерея - Slidev'
14
+ const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 38, 46, 47]
14
15
  const VISUAL_ONLY_SLIDES = [17, 18]
16
+ const P0_REGRESSION_SLIDES = [23, 24, 25, 26, 27, 28]
17
+ const P0_FRONTMATTER_TEXT = {
18
+ 23: {
19
+ selector: '.Slide_variant_comparison-before-after',
20
+ values: ['«Я просто запомнил формулу»', '«Я выбрал формулу по условию»'],
21
+ },
22
+ 24: {
23
+ selector: '.Slide_variant_comparison-stable-variable',
24
+ values: ['Научиться проверять гипотезу', 'Данные и уровень подсказок'],
25
+ },
26
+ 25: {
27
+ selector: '.Slide-StepsGrid_linear',
28
+ values: ['Назвать цель', 'Дать контекст', 'Показать пример', 'Оставить практику', 'Проверить критерий', 'Закрепить вывод'],
29
+ },
30
+ 26: {
31
+ selector: '.Slide-StepsGrid_staggered',
32
+ values: ['Заметить сбой', 'Повторить шаг', 'Найти правило', 'Изменить подход', 'Записать вывод'],
33
+ },
34
+ }
15
35
  const LIFECYCLE_EVENT = 'practicum:slide-lifecycle'
16
36
  const LIFECYCLE_STATE_KEY = '__practicumBrowserLifecycle'
17
37
  const THEME_MEDIA = [
@@ -39,6 +59,10 @@ function assertNoDiagnostics(label, diagnostics) {
39
59
  )
40
60
  }
41
61
 
62
+ function normalizeVisibleText(value) {
63
+ return value.replace(/\s+/g, ' ').trim()
64
+ }
65
+
42
66
  async function settleSlide(page) {
43
67
  await page.evaluate(async () => {
44
68
  await Promise.race([
@@ -168,6 +192,122 @@ async function assertClosingLayout(slide, slideNumber, expectedTextCount) {
168
192
  )
169
193
  }
170
194
 
195
+ async function assertStaggeredStepsLayout(slide, slideNumber) {
196
+ const grid = slide.locator('.Slide-StepsGrid_staggered')
197
+ const cards = grid.locator(':scope > .Slide-Step')
198
+ const cardCount = await cards.count()
199
+
200
+ assert.equal(cardCount, 5, `слайд ${slideNumber}: staggered должен содержать ровно 5 карточек`)
201
+
202
+ const [gridBox, cardBoxes, placements] = await Promise.all([
203
+ grid.boundingBox(),
204
+ Promise.all(Array.from({ length: cardCount }, (_, index) => cards.nth(index).boundingBox())),
205
+ cards.evaluateAll(elements => elements.map((element) => {
206
+ const style = getComputedStyle(element)
207
+
208
+ return {
209
+ columnEnd: style.gridColumnEnd,
210
+ columnStart: style.gridColumnStart,
211
+ rowEnd: style.gridRowEnd,
212
+ rowStart: style.gridRowStart,
213
+ }
214
+ })),
215
+ ])
216
+
217
+ assert.ok(gridBox, `слайд ${slideNumber}: не найдена геометрия staggered grid`)
218
+ assert.ok(cardBoxes.every(Boolean), `слайд ${slideNumber}: не найдена геометрия всех staggered-карточек`)
219
+ assert.deepEqual(placements, [
220
+ { columnEnd: '3', columnStart: '1', rowEnd: '2', rowStart: '1' },
221
+ { columnEnd: '5', columnStart: '3', rowEnd: '2', rowStart: '1' },
222
+ { columnEnd: '7', columnStart: '5', rowEnd: '2', rowStart: '1' },
223
+ { columnEnd: '4', columnStart: '2', rowEnd: '3', rowStart: '2' },
224
+ { columnEnd: '6', columnStart: '4', rowEnd: '3', rowStart: '2' },
225
+ ], `слайд ${slideNumber}: staggered должен использовать центрированную геометрию 3+2`)
226
+
227
+ const boxes = cardBoxes
228
+ const topY = boxes[0].y
229
+ const bottomY = boxes[3].y
230
+
231
+ assert.ok(
232
+ boxes.slice(0, 3).every(box => Math.abs(box.y - topY) <= 1),
233
+ `слайд ${slideNumber}: первые три staggered-карточки должны быть в верхнем ряду`,
234
+ )
235
+ assert.ok(
236
+ boxes.slice(3).every(box => Math.abs(box.y - bottomY) <= 1) && bottomY > topY,
237
+ `слайд ${slideNumber}: последние две staggered-карточки должны быть в нижнем ряду`,
238
+ )
239
+
240
+ const lowerCenter = (boxes[3].x + boxes[4].x + boxes[4].width) / 2
241
+ const gridCenter = gridBox.x + gridBox.width / 2
242
+
243
+ assert.ok(
244
+ Math.abs(lowerCenter - gridCenter) <= 1,
245
+ `слайд ${slideNumber}: нижняя пара staggered не центрирована`,
246
+ )
247
+ }
248
+
249
+ async function assertNumberedQuartet(slide, slideNumber) {
250
+ const markers = slide.locator('.Text_size_4')
251
+ assert.deepEqual(
252
+ (await markers.allTextContents()).map(text => text.trim()),
253
+ ['1', 'Цель', '2', 'Контекст', '3', 'Критерий', '4', 'Обратная связь'],
254
+ `слайд ${slideNumber}: номер и название каждого факта должны быть plain Text размера 4`,
255
+ )
256
+ assert.equal(
257
+ await slide.locator('.Slide-FactNumber').count(),
258
+ 0,
259
+ `слайд ${slideNumber}: номера не должны оформляться кружками`,
260
+ )
261
+
262
+ const factTitles = slide.locator('.Text_size_4.Text_align_end')
263
+ assert.deepEqual(
264
+ (await factTitles.allTextContents()).map(text => text.trim()),
265
+ ['Цель', 'Контекст', 'Критерий', 'Обратная связь'],
266
+ `слайд ${slideNumber}: названия фактов должны быть прижаты к нижнему краю карточки`,
267
+ )
268
+ assert.ok(
269
+ (await factTitles.evaluateAll(elements => elements.every((element) => {
270
+ const marginTop = Number.parseFloat(getComputedStyle(element).marginTop)
271
+ return Number.isFinite(marginTop) && marginTop > 0
272
+ }))),
273
+ `слайд ${slideNumber}: названия фактов должны быть отодвинуты вниз от номера`,
274
+ )
275
+ }
276
+
277
+ async function assertP0FrontmatterText(slide, slideNumber) {
278
+ const contract = P0_FRONTMATTER_TEXT[slideNumber]
279
+ if (!contract)
280
+ return
281
+
282
+ assert.equal(
283
+ await slide.locator(contract.selector).count(),
284
+ 1,
285
+ `слайд ${slideNumber}: не найден ожидаемый variant/arrangement ${contract.selector}`,
286
+ )
287
+
288
+ const visibleText = await slide.locator('.Text').evaluateAll(elements => elements
289
+ .filter((element) => {
290
+ const rect = element.getBoundingClientRect()
291
+ const style = getComputedStyle(element)
292
+
293
+ return rect.width > 0
294
+ && rect.height > 0
295
+ && style.display !== 'none'
296
+ && style.visibility !== 'hidden'
297
+ && style.opacity !== '0'
298
+ })
299
+ .map(element => element.textContent ?? ''))
300
+ const normalizedVisibleText = visibleText.map(normalizeVisibleText)
301
+
302
+ for (const expected of contract.values) {
303
+ assert.ok(
304
+ normalizedVisibleText.includes(normalizeVisibleText(expected)),
305
+ `слайд ${slideNumber}: вложенный текст front matter «${expected}» не виден; `
306
+ + `видимые Text=${JSON.stringify(normalizedVisibleText)}`,
307
+ )
308
+ }
309
+ }
310
+
171
311
  async function inspectSlide(
172
312
  page,
173
313
  origin,
@@ -222,12 +362,17 @@ async function inspectSlide(
222
362
  result.heightOverflow <= 1,
223
363
  `слайд ${slideNumber}: вертикальное переполнение ${result.heightOverflow}px`,
224
364
  )
225
- if (slideNumber === 32)
365
+ if (slideNumber === 38)
226
366
  await assertStrongEmphasis(slide, slideNumber)
227
- if (slideNumber === 40)
367
+ if (slideNumber === 46)
228
368
  await assertClosingLayout(slide, slideNumber, 1)
229
- if (slideNumber === 41)
369
+ if (slideNumber === 47)
230
370
  await assertClosingLayout(slide, slideNumber, 2)
371
+ if (slideNumber === 26)
372
+ await assertStaggeredStepsLayout(slide, slideNumber)
373
+ if (slideNumber === 28)
374
+ await assertNumberedQuartet(slide, slideNumber)
375
+ await assertP0FrontmatterText(slide, slideNumber)
231
376
  assertLifecycleTransition(
232
377
  beforeLifecycle,
233
378
  afterLifecycle,
@@ -387,6 +532,13 @@ export async function runBrowserSmoke() {
387
532
  REPRESENTATIVE_SLIDES[index - 1],
388
533
  () => inFlightTracker.waitForIdle(),
389
534
  )
535
+ if (index === 0) {
536
+ assert.equal(
537
+ await page.title(),
538
+ EXPECTED_DOCUMENT_TITLE,
539
+ 'первый headmatter title должен задавать служебный HTML title колоды',
540
+ )
541
+ }
390
542
  }
391
543
 
392
544
  for (const slideNumber of VISUAL_ONLY_SLIDES) {
@@ -400,6 +552,17 @@ export async function runBrowserSmoke() {
400
552
  )
401
553
  }
402
554
 
555
+ for (const slideNumber of P0_REGRESSION_SLIDES) {
556
+ activeSlide = `P0-регрессия: слайд ${slideNumber}`
557
+ await inspectSlide(
558
+ page,
559
+ server.origin,
560
+ slideNumber,
561
+ undefined,
562
+ () => inFlightTracker.waitForIdle(),
563
+ )
564
+ }
565
+
403
566
  for (const asset of THEME_MEDIA) {
404
567
  activeSlide = `медиа: ${asset.label}`
405
568
  await loadThemeImage(page, server.origin, asset)
@@ -413,7 +576,7 @@ export async function runBrowserSmoke() {
413
576
  assertNoDiagnostics('неуспешные запросы того же источника', failedRequests)
414
577
 
415
578
  console.log(
416
- `Браузерная проверка: слайды=${REPRESENTATIVE_SLIDES.length + VISUAL_ONLY_SLIDES.length}, `
579
+ `Браузерная проверка: слайды=${REPRESENTATIVE_SLIDES.length + VISUAL_ONLY_SLIDES.length + P0_REGRESSION_SLIDES.length}, `
417
580
  + `ошибкиСтраницы=${pageErrors.length}, ошибкиКонсоли=${consoleErrors.length}, `
418
581
  + `ошибочныеОтветы=${failedResponses.length}, активныеЗапросы=${inFlightTracker.size}, `
419
582
  + `переполнение=0, медиа=${THEME_MEDIA.length}, переходыЖЦ=${REPRESENTATIVE_SLIDES.length - 1}`,
@@ -0,0 +1,189 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import {
4
+ cpSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ } from 'node:fs'
11
+ import { join, resolve } from 'node:path'
12
+ import process from 'node:process'
13
+ import { chromium } from 'playwright-chromium'
14
+ import { createStaticDistServer } from './browser-smoke-runtime.mjs'
15
+
16
+ const PROJECT_ROOT = resolve(import.meta.dirname, '..')
17
+ const FIXTURE_ROOT = join(PROJECT_ROOT, 'tests/fixtures/local-layout-variant-deck')
18
+ const SLIDEV_CLI = join(PROJECT_ROOT, 'node_modules/@slidev/cli/bin/slidev.mjs')
19
+ const consumerParent = join(PROJECT_ROOT, '.slidev')
20
+ mkdirSync(consumerParent, { recursive: true })
21
+ const consumerRoot = mkdtempSync(join(consumerParent, 'layout-variant-consumer-'))
22
+ const deckRoot = join(consumerRoot, 'deck')
23
+ const outputRoot = join(deckRoot, 'dist')
24
+
25
+ const FRONTMATTER_SLIDES = [
26
+ {
27
+ number: 2,
28
+ selector: '.Slide-StepsGrid_linear',
29
+ values: ['Найти нужную версию', 'Восстановить контекст', 'Сгенерировать черновик', 'Проверить', 'Исправить', 'Перенести результат'],
30
+ },
31
+ {
32
+ number: 3,
33
+ selector: '.Slide-StepsGrid_staggered',
34
+ values: ['Заметить сбой', 'Повторить шаг', 'Найти правило', 'Изменить подход', 'Записать вывод'],
35
+ },
36
+ {
37
+ number: 4,
38
+ selector: '.Slide_variant_comparison-before-after',
39
+ values: ['Решение — за человеком', 'Стадия работает сама'],
40
+ },
41
+ {
42
+ number: 5,
43
+ selector: '.Slide_variant_comparison-stable-variable',
44
+ values: ['Научиться проверять гипотезу', 'Данные и уровень подсказок'],
45
+ },
46
+ ]
47
+
48
+ function normalizeVisibleText(value) {
49
+ return value.replace(/\s+/g, ' ').trim()
50
+ }
51
+
52
+ function readArtifact(directory) {
53
+ return readdirSync(directory, { withFileTypes: true })
54
+ .flatMap((entry) => {
55
+ const path = join(directory, entry.name)
56
+ return entry.isDirectory() ? readArtifact(path) : [readFileSync(path)]
57
+ })
58
+ }
59
+
60
+ async function inspectRenderedVariant() {
61
+ const diagnostics = []
62
+ const server = await createStaticDistServer(outputRoot)
63
+ const browser = await chromium.launch({ headless: true })
64
+
65
+ try {
66
+ const context = await browser.newContext({ serviceWorkers: 'block' })
67
+ await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
68
+ const page = await context.newPage()
69
+ page.on('console', (message) => {
70
+ if (message.type() === 'error')
71
+ diagnostics.push(message.text())
72
+ })
73
+ page.on('pageerror', error => diagnostics.push(error.message))
74
+
75
+ await page.goto(`${server.origin}/1`, { waitUntil: 'networkidle' })
76
+
77
+ const firstSlide = page.locator('.slidev-page[data-slidev-no="1"]')
78
+ const badge = firstSlide.locator('.LocalLayoutVariantBadge')
79
+ await firstSlide.waitFor({ state: 'visible' })
80
+ await badge.waitFor({ state: 'visible' })
81
+
82
+ assert.equal((await badge.textContent())?.trim(), 'Практика')
83
+ assert.equal((await firstSlide.locator('h1').textContent())?.trim(), 'Что запомнить')
84
+
85
+ for (const contract of FRONTMATTER_SLIDES) {
86
+ await page.goto(`${server.origin}/${contract.number}`, { waitUntil: 'networkidle' })
87
+ const slide = page.locator(`.slidev-page[data-slidev-no="${contract.number}"]`)
88
+ await slide.waitFor({ state: 'visible' })
89
+
90
+ assert.equal(
91
+ await slide.locator(contract.selector).count(),
92
+ 1,
93
+ `слайд ${contract.number}: не найден ${contract.selector}`,
94
+ )
95
+
96
+ const rendered = await slide.locator('.Text').evaluateAll(elements => elements
97
+ .filter((element) => {
98
+ const rect = element.getBoundingClientRect()
99
+ const style = getComputedStyle(element)
100
+
101
+ return rect.width > 0
102
+ && rect.height > 0
103
+ && style.display !== 'none'
104
+ && style.visibility !== 'hidden'
105
+ && style.opacity !== '0'
106
+ })
107
+ .map(element => element.textContent ?? ''))
108
+ const normalized = rendered.map(normalizeVisibleText)
109
+
110
+ for (const expected of contract.values) {
111
+ assert.ok(
112
+ normalized.includes(normalizeVisibleText(expected)),
113
+ `слайд ${contract.number}: вложенный текст front matter «${expected}» не виден; `
114
+ + `видимые Text=${JSON.stringify(normalized)}`,
115
+ )
116
+ }
117
+
118
+ const overflow = await slide.evaluate(element => ({
119
+ x: element.scrollWidth - element.clientWidth,
120
+ y: element.scrollHeight - element.clientHeight,
121
+ }))
122
+ assert.ok(overflow.x <= 1, `слайд ${contract.number}: горизонтальное переполнение ${overflow.x}px`)
123
+ assert.ok(overflow.y <= 1, `слайд ${contract.number}: вертикальное переполнение ${overflow.y}px`)
124
+ }
125
+
126
+ assert.deepEqual(diagnostics, [], diagnostics.join('\n'))
127
+ }
128
+ finally {
129
+ await browser.close()
130
+ await server.close()
131
+ }
132
+ }
133
+
134
+ try {
135
+ cpSync(FIXTURE_ROOT, deckRoot, { recursive: true })
136
+ const themeInstallRoot = join(deckRoot, 'node_modules', 'slidev-theme-practicum')
137
+ const manifest = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8'))
138
+
139
+ mkdirSync(themeInstallRoot, { recursive: true })
140
+ cpSync(join(PROJECT_ROOT, 'package.json'), join(themeInstallRoot, 'package.json'))
141
+ for (const entry of manifest.files)
142
+ cpSync(join(PROJECT_ROOT, entry), join(themeInstallRoot, entry), { recursive: true })
143
+
144
+ const deckPath = join(deckRoot, 'slides.md')
145
+ const validation = spawnSync(process.execPath, [
146
+ join(themeInstallRoot, 'scripts', 'validate-deck.cjs'),
147
+ deckPath,
148
+ ], {
149
+ cwd: deckRoot,
150
+ encoding: 'utf8',
151
+ })
152
+
153
+ assert.equal(
154
+ validation.status,
155
+ 0,
156
+ validation.stderr || validation.stdout || validation.error?.message,
157
+ )
158
+
159
+ const result = spawnSync(process.execPath, [
160
+ SLIDEV_CLI,
161
+ 'build',
162
+ deckPath,
163
+ '--out',
164
+ outputRoot,
165
+ ], {
166
+ cwd: deckRoot,
167
+ encoding: 'utf8',
168
+ })
169
+
170
+ if (result.error || result.status !== 0) {
171
+ process.stderr.write(result.stderr || result.stdout || result.error?.message || 'Сборка локального варианта завершилась без диагностики.\n')
172
+ process.exitCode = result.status || 1
173
+ }
174
+ else {
175
+ const artifact = Buffer.concat(readArtifact(outputRoot)).toString()
176
+
177
+ if (!artifact.includes('LocalLayoutVariantBadge')) {
178
+ console.error('Сборка не содержит локальный компонент, вызванный из варианта презентации.')
179
+ process.exitCode = 1
180
+ }
181
+ else {
182
+ await inspectRenderedVariant()
183
+ console.log('Локальный вариант и вложенные поля front matter отрисовались в итоговой сборке установленной темы.')
184
+ }
185
+ }
186
+ }
187
+ finally {
188
+ rmSync(consumerRoot, { recursive: true, force: true })
189
+ }
@@ -83,12 +83,28 @@ try {
83
83
  if (!files.has('LICENSE'))
84
84
  errors.push('package должен содержать LICENSE')
85
85
 
86
- if (!files.has('skills/slidev-practicum/SKILL.md'))
87
- errors.push('package должен содержать skills/slidev-practicum/SKILL.md')
86
+ for (const skillFile of [
87
+ 'skills/slidev-practicum/SKILL.md',
88
+ 'skills/slidev-practicum/references/deck-project-structure.md',
89
+ 'skills/slidev-practicum/references/contour-illustrations.md',
90
+ 'skills/slidev-practicum/references/photographic-illustrations.md',
91
+ ]) {
92
+ if (!files.has(skillFile))
93
+ errors.push(`package должен содержать ${skillFile}`)
94
+ }
88
95
 
89
96
  if (!files.has('example.md'))
90
97
  errors.push('package должен содержать example.md как каноническую колоду для авторов')
91
98
 
99
+ for (const runtimeFile of [
100
+ 'composables/local-layout-variant-files.ts',
101
+ 'composables/local-layout-variants.ts',
102
+ 'env.d.ts',
103
+ ]) {
104
+ if (!files.has(runtimeFile))
105
+ errors.push(`package должен содержать контракт локальных вариантов: ${runtimeFile}`)
106
+ }
107
+
92
108
  const defaultDeckTargets = new Set(
93
109
  Object.values(manifest.scripts ?? {})
94
110
  .flatMap(script => String(script).match(/\b[\w-]+\.md\b/g) ?? []),
@@ -0,0 +1 @@
1
+ Pillow==12.2.0