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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -10
  3. package/components/Slide.vue +81 -26
  4. package/components/Slot.vue +33 -48
  5. package/components/Text.vue +68 -2
  6. package/components/Timeline.vue +2 -23
  7. package/composables/deck-slot-markup.cjs +249 -0
  8. package/composables/decor-catalog.mjs +169 -47
  9. package/composables/layout-authoring.ts +6 -2
  10. package/composables/layout-markdown.mjs +33 -0
  11. package/composables/layout-recipes.ts +51 -5
  12. package/composables/layout-shorthands.ts +251 -81
  13. package/composables/markdown-to-vnodes.cjs +1 -0
  14. package/composables/markdown-to-vnodes.mjs +237 -0
  15. package/composables/slot-placement.ts +1 -1
  16. package/composables/slot-spacing.ts +66 -0
  17. package/composables/text-fit-runtime.ts +458 -76
  18. package/composables/text-fit-scope.ts +13 -0
  19. package/composables/text-slot-markdown.mjs +193 -0
  20. package/composables/text-typography.mjs +2 -0
  21. package/composables/theme-assets.mjs +60 -0
  22. package/composables/timeline-grid.ts +27 -0
  23. package/composables/typescript-require.cjs +29 -0
  24. package/composables/validate-deck-layouts.cjs +277 -0
  25. package/decor-library.md +2 -0
  26. package/example.md +132 -10
  27. package/package.json +25 -11
  28. package/public/photos/photo-1.webp +0 -0
  29. package/public/photos/photo-10.webp +0 -0
  30. package/public/photos/photo-11.webp +0 -0
  31. package/public/photos/photo-12.webp +0 -0
  32. package/public/photos/photo-13.webp +0 -0
  33. package/public/photos/photo-14.webp +0 -0
  34. package/public/photos/photo-15.webp +0 -0
  35. package/public/photos/photo-16.webp +0 -0
  36. package/public/photos/photo-17.webp +0 -0
  37. package/public/photos/photo-18.webp +0 -0
  38. package/public/photos/photo-19.webp +0 -0
  39. package/public/photos/photo-2.webp +0 -0
  40. package/public/photos/photo-20.webp +0 -0
  41. package/public/photos/photo-21.webp +0 -0
  42. package/public/photos/photo-22.webp +0 -0
  43. package/public/photos/photo-23.webp +0 -0
  44. package/public/photos/photo-24.webp +0 -0
  45. package/public/photos/photo-25.webp +0 -0
  46. package/public/photos/photo-26.webp +0 -0
  47. package/public/photos/photo-27.webp +0 -0
  48. package/public/photos/photo-28.webp +0 -0
  49. package/public/photos/photo-29.webp +0 -0
  50. package/public/photos/photo-3.webp +0 -0
  51. package/public/photos/photo-30.webp +0 -0
  52. package/public/photos/photo-4.webp +0 -0
  53. package/public/photos/photo-5.webp +0 -0
  54. package/public/photos/photo-6.webp +0 -0
  55. package/public/photos/photo-7.webp +0 -0
  56. package/public/photos/photo-8.webp +0 -0
  57. package/public/photos/photo-9.webp +0 -0
  58. package/scripts/browser-smoke-runtime.mjs +428 -0
  59. package/scripts/browser-smoke.mjs +424 -0
  60. package/scripts/build-artifact-assets.mjs +55 -0
  61. package/scripts/check-build-artifact.mjs +243 -0
  62. package/scripts/check-package.mjs +132 -0
  63. package/scripts/check-test-architecture.mjs +176 -0
  64. package/scripts/npm-spawn.mjs +31 -0
  65. package/scripts/test-architecture-policy.mjs +245 -0
  66. package/scripts/theme-asset-inventory.mjs +83 -0
  67. package/scripts/validate-deck.cjs +18 -0
  68. package/setup/decor-save-middleware.ts +244 -0
  69. package/setup/vite-plugins.ts +14 -84
  70. package/skills/slidev-practicum/SKILL.md +25 -0
  71. package/skills/slidev-practicum/agents/openai.yaml +4 -0
  72. package/styles/index.css +10 -1
  73. package/composables/layout-registry.ts +0 -47
  74. package/env.d.ts +0 -6
  75. package/public/photos/photo-1.png +0 -0
  76. package/public/photos/photo-10.png +0 -0
  77. package/public/photos/photo-2.png +0 -0
  78. package/public/photos/photo-3.png +0 -0
  79. package/public/photos/photo-4.png +0 -0
  80. package/public/photos/photo-5.png +0 -0
  81. package/public/photos/photo-6.png +0 -0
  82. package/public/photos/photo-7.png +0 -0
  83. package/public/photos/photo-8.png +0 -0
  84. package/public/photos/photo-9.png +0 -0
  85. package/slidev-theme-practicum.png +0 -0
  86. package/slidev-theme-practicum.svg +0 -102
@@ -0,0 +1,424 @@
1
+ import assert from 'node:assert/strict'
2
+ import { resolve } from 'node:path'
3
+ import { chromium } from 'playwright-chromium'
4
+ import {
5
+ classifyBrowserConsoleMessage,
6
+ createInFlightRequestTracker,
7
+ createStaticDistServer,
8
+ isSameOriginUrl,
9
+ } from './browser-smoke-runtime.mjs'
10
+
11
+ const PROJECT_ROOT = resolve(import.meta.dirname, '..')
12
+ const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
13
+ const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 32, 40, 41]
14
+ const LIFECYCLE_EVENT = 'practicum:slide-lifecycle'
15
+ const LIFECYCLE_STATE_KEY = '__practicumBrowserLifecycle'
16
+ const THEME_MEDIA = [
17
+ {
18
+ label: 'декор',
19
+ path: '/theme/decor/decor-1.svg',
20
+ },
21
+ {
22
+ label: 'фотография',
23
+ path: '/theme/photos/photo-1.webp',
24
+ },
25
+ ]
26
+
27
+ function formatDiagnostic(diagnostic) {
28
+ return typeof diagnostic === 'string'
29
+ ? diagnostic
30
+ : JSON.stringify(diagnostic)
31
+ }
32
+
33
+ function assertNoDiagnostics(label, diagnostics) {
34
+ assert.equal(
35
+ diagnostics.length,
36
+ 0,
37
+ `${label}:\n${diagnostics.map(formatDiagnostic).join('\n')}`,
38
+ )
39
+ }
40
+
41
+ async function settleSlide(page) {
42
+ await page.evaluate(async () => {
43
+ await Promise.race([
44
+ document.fonts?.ready ?? Promise.resolve(),
45
+ new Promise(resolveTimeout => setTimeout(resolveTimeout, 3_000)),
46
+ ])
47
+ await new Promise((resolveFrame) => {
48
+ requestAnimationFrame(() => requestAnimationFrame(resolveFrame))
49
+ })
50
+ })
51
+ }
52
+
53
+ async function readLifecycleSnapshot(page) {
54
+ return page.evaluate((stateKey) => {
55
+ const state = window[stateKey]
56
+
57
+ return {
58
+ active: structuredClone(state?.active ?? {}),
59
+ cleanedTokens: structuredClone(state?.cleanedTokens ?? {}),
60
+ mounts: structuredClone(state?.mounts ?? {}),
61
+ unmounts: structuredClone(state?.unmounts ?? {}),
62
+ }
63
+ }, LIFECYCLE_STATE_KEY)
64
+ }
65
+
66
+ function countFor(snapshot, collection, slideNumber) {
67
+ return snapshot[collection][String(slideNumber)] ?? 0
68
+ }
69
+
70
+ function assertLifecycleTransition(before, after, slideNumber, previousSlideNumber) {
71
+ const slideKey = String(slideNumber)
72
+ assert.ok(
73
+ countFor(after, 'mounts', slideNumber) > countFor(before, 'mounts', slideNumber),
74
+ `слайд ${slideNumber}: компонент не смонтировался при переходе; `
75
+ + `состояние=${JSON.stringify(after)}`,
76
+ )
77
+ assert.ok(
78
+ (after.active[slideKey]?.length ?? 0) > 0,
79
+ `слайд ${slideNumber}: после перехода нет активного маркера жизненного цикла`,
80
+ )
81
+
82
+ if (previousSlideNumber === undefined)
83
+ return
84
+
85
+ const previousKey = String(previousSlideNumber)
86
+ const previousTokens = before.active[previousKey] ?? []
87
+ assert.ok(
88
+ previousTokens.length > 0,
89
+ `слайд ${previousSlideNumber}: до перехода нет активного маркера жизненного цикла`,
90
+ )
91
+ assert.ok(
92
+ countFor(after, 'unmounts', previousSlideNumber)
93
+ > countFor(before, 'unmounts', previousSlideNumber),
94
+ `слайд ${previousSlideNumber}: компонент не демонтировался при переходе; `
95
+ + `до=${JSON.stringify(before)}, после=${JSON.stringify(after)}`,
96
+ )
97
+ assert.equal(
98
+ after.active[previousKey]?.length ?? 0,
99
+ 0,
100
+ `слайд ${previousSlideNumber}: маркер жизненного цикла остался активным после перехода`,
101
+ )
102
+
103
+ for (const token of previousTokens) {
104
+ assert.equal(
105
+ after.cleanedTokens[token],
106
+ true,
107
+ `слайд ${previousSlideNumber}: маркер очистки ${token} не установлен`,
108
+ )
109
+ }
110
+ }
111
+
112
+ async function assertStrongEmphasis(slide, slideNumber) {
113
+ const strong = slide.locator('strong')
114
+ const strongCount = await strong.count()
115
+
116
+ assert.equal(
117
+ strongCount,
118
+ 1,
119
+ `слайд ${slideNumber}: ожидается один семантический strong, найдено ${strongCount}`,
120
+ )
121
+ assert.equal(
122
+ await strong.evaluate(element => getComputedStyle(element).fontWeight),
123
+ '600',
124
+ `слайд ${slideNumber}: strong должен использовать YS Text Bold с весом 600`,
125
+ )
126
+ }
127
+
128
+ async function assertClosingLayout(slide, slideNumber, expectedTextCount) {
129
+ const grid = slide.locator('.Slide-Grid')
130
+ const primary = grid.locator(':scope > .Slot').first()
131
+ const [gridBox, primaryBox, placement, textCount] = await Promise.all([
132
+ grid.boundingBox(),
133
+ primary.boundingBox(),
134
+ primary.evaluate((element) => {
135
+ const style = getComputedStyle(element)
136
+
137
+ return {
138
+ columnEnd: style.gridColumnEnd,
139
+ columnStart: style.gridColumnStart,
140
+ rowEnd: style.gridRowEnd,
141
+ rowStart: style.gridRowStart,
142
+ }
143
+ }),
144
+ primary.locator(':scope > .Slot-Content > .Text').count(),
145
+ ])
146
+
147
+ assert.deepEqual(placement, {
148
+ columnEnd: '12',
149
+ columnStart: '2',
150
+ rowEnd: '13',
151
+ rowStart: '1',
152
+ }, `слайд ${slideNumber}: primary использует неверную область сетки`)
153
+ assert.ok(gridBox, `слайд ${slideNumber}: не найдена геометрия Slide-Grid`)
154
+ assert.ok(primaryBox, `слайд ${slideNumber}: не найдена геометрия primary`)
155
+ assert.ok(
156
+ Math.abs(primaryBox.y - gridBox.y) <= 1,
157
+ `слайд ${slideNumber}: primary не начинается с первой строки`,
158
+ )
159
+ assert.ok(
160
+ Math.abs(primaryBox.y + primaryBox.height - gridBox.y - gridBox.height) <= 1,
161
+ `слайд ${slideNumber}: primary не заканчивается на последней строке`,
162
+ )
163
+ assert.equal(
164
+ textCount,
165
+ expectedTextCount,
166
+ `слайд ${slideNumber}: неожиданное количество текстовых элементов`,
167
+ )
168
+ }
169
+
170
+ async function inspectSlide(
171
+ page,
172
+ origin,
173
+ slideNumber,
174
+ previousSlideNumber,
175
+ waitForNetworkIdle,
176
+ ) {
177
+ const hashTarget = `${origin}/#/${slideNumber}`
178
+ const beforeLifecycle = previousSlideNumber === undefined
179
+ ? { active: {}, cleanedTokens: {}, mounts: {}, unmounts: {} }
180
+ : await readLifecycleSnapshot(page)
181
+
182
+ if (previousSlideNumber === undefined) {
183
+ await page.goto(hashTarget, { waitUntil: 'domcontentloaded' })
184
+ }
185
+ else {
186
+ await page.evaluate((hash) => {
187
+ window.location.hash = hash
188
+ }, `#/${slideNumber}`)
189
+ }
190
+
191
+ await page.waitForFunction(
192
+ expectedHash => window.location.hash === expectedHash,
193
+ `#/${slideNumber}`,
194
+ )
195
+
196
+ const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
197
+ await slide.waitFor({ state: 'visible' })
198
+ if (previousSlideNumber !== undefined) {
199
+ await page
200
+ .locator(`.slidev-page[data-slidev-no="${previousSlideNumber}"]`)
201
+ .waitFor({ state: 'hidden' })
202
+ }
203
+ await settleSlide(page)
204
+ await waitForNetworkIdle()
205
+
206
+ const [result, afterLifecycle] = await Promise.all([
207
+ slide.evaluate(element => ({
208
+ heightOverflow: element.scrollHeight - element.clientHeight,
209
+ text: element.textContent?.trim() ?? '',
210
+ widthOverflow: element.scrollWidth - element.clientWidth,
211
+ })),
212
+ readLifecycleSnapshot(page),
213
+ ])
214
+
215
+ assert.ok(result.text.length > 0, `слайд ${slideNumber}: отсутствует текст`)
216
+ assert.ok(
217
+ result.widthOverflow <= 1,
218
+ `слайд ${slideNumber}: горизонтальное переполнение ${result.widthOverflow}px`,
219
+ )
220
+ assert.ok(
221
+ result.heightOverflow <= 1,
222
+ `слайд ${slideNumber}: вертикальное переполнение ${result.heightOverflow}px`,
223
+ )
224
+ if (slideNumber === 32)
225
+ await assertStrongEmphasis(slide, slideNumber)
226
+ if (slideNumber === 40)
227
+ await assertClosingLayout(slide, slideNumber, 1)
228
+ if (slideNumber === 41)
229
+ await assertClosingLayout(slide, slideNumber, 2)
230
+ assertLifecycleTransition(
231
+ beforeLifecycle,
232
+ afterLifecycle,
233
+ slideNumber,
234
+ previousSlideNumber,
235
+ )
236
+ }
237
+
238
+ async function loadThemeImage(page, origin, asset) {
239
+ const url = `${origin}${asset.path}?browser-smoke=${encodeURIComponent(asset.label)}`
240
+ const responsePromise = page.waitForResponse(response => response.url() === url)
241
+ const imagePromise = page.evaluate(async (imageUrl) => {
242
+ const image = new Image()
243
+ image.src = imageUrl
244
+ await image.decode()
245
+
246
+ return {
247
+ height: image.naturalHeight,
248
+ width: image.naturalWidth,
249
+ }
250
+ }, url)
251
+
252
+ const [response, dimensions] = await Promise.all([responsePromise, imagePromise])
253
+ assert.equal(response.status(), 200, `${asset.label}: HTTP ${response.status()}`)
254
+ assert.ok(dimensions.width > 0, `${asset.label}: изображение имеет нулевую ширину`)
255
+ assert.ok(dimensions.height > 0, `${asset.label}: изображение имеет нулевую высоту`)
256
+ }
257
+
258
+ async function closeResources(browser, server) {
259
+ const cleanupErrors = []
260
+
261
+ if (browser) {
262
+ try {
263
+ await browser.close()
264
+ }
265
+ catch (error) {
266
+ cleanupErrors.push(error)
267
+ }
268
+ }
269
+
270
+ if (server) {
271
+ try {
272
+ await server.close()
273
+ }
274
+ catch (error) {
275
+ cleanupErrors.push(error)
276
+ }
277
+ }
278
+
279
+ return cleanupErrors
280
+ }
281
+
282
+ export async function runBrowserSmoke() {
283
+ let activeSlide = 'инициализация'
284
+ let browser
285
+ let inFlightTracker
286
+ let server
287
+ let runError
288
+ const consoleErrors = []
289
+ const failedRequests = []
290
+ const failedResponses = []
291
+ const pageErrors = []
292
+
293
+ try {
294
+ server = await createStaticDistServer(DIST_DIR)
295
+ browser = await chromium.launch()
296
+ const context = await browser.newContext({
297
+ serviceWorkers: 'block',
298
+ viewport: {
299
+ height: 720,
300
+ width: 1280,
301
+ },
302
+ })
303
+ await context.addInitScript(({ eventName, stateKey }) => {
304
+ const state = {
305
+ active: {},
306
+ cleanedTokens: {},
307
+ mounts: {},
308
+ unmounts: {},
309
+ }
310
+ window[stateKey] = state
311
+ window.addEventListener(eventName, (event) => {
312
+ const detail = event.detail ?? {}
313
+ const phase = detail.phase
314
+ const slide = String(detail.slide ?? '')
315
+ const token = String(detail.token ?? '')
316
+
317
+ if (!slide || !token || (phase !== 'mounted' && phase !== 'unmounted'))
318
+ return
319
+
320
+ state.active[slide] ??= []
321
+ if (phase === 'mounted') {
322
+ state.mounts[slide] = (state.mounts[slide] ?? 0) + 1
323
+ if (!state.active[slide].includes(token))
324
+ state.active[slide].push(token)
325
+ return
326
+ }
327
+
328
+ state.unmounts[slide] = (state.unmounts[slide] ?? 0) + 1
329
+ state.active[slide] = state.active[slide].filter(activeToken => activeToken !== token)
330
+ state.cleanedTokens[token] = true
331
+ })
332
+ }, {
333
+ eventName: LIFECYCLE_EVENT,
334
+ stateKey: LIFECYCLE_STATE_KEY,
335
+ })
336
+ await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
337
+ const page = await context.newPage()
338
+ page.setDefaultTimeout(15_000)
339
+ inFlightTracker = createInFlightRequestTracker({
340
+ origin: server.origin,
341
+ })
342
+
343
+ page.on('pageerror', (error) => {
344
+ pageErrors.push(`[${activeSlide}] ${error.stack || String(error)}`)
345
+ })
346
+ page.on('console', (message) => {
347
+ const classification = classifyBrowserConsoleMessage(message.type(), message.text())
348
+ if (classification === 'rejected') {
349
+ consoleErrors.push({
350
+ location: message.location(),
351
+ slide: activeSlide,
352
+ text: message.text(),
353
+ })
354
+ }
355
+ })
356
+ page.on('request', request => inFlightTracker.requestStarted(request))
357
+ page.on('requestfinished', request => inFlightTracker.requestSettled(request))
358
+ page.on('response', (response) => {
359
+ if (response.status() >= 400) {
360
+ failedResponses.push({
361
+ method: response.request().method(),
362
+ slide: activeSlide,
363
+ status: response.status(),
364
+ url: response.url(),
365
+ })
366
+ }
367
+ })
368
+ page.on('requestfailed', (request) => {
369
+ inFlightTracker.requestSettled(request)
370
+ if (isSameOriginUrl(server.origin, request.url())) {
371
+ failedRequests.push({
372
+ error: request.failure()?.errorText ?? 'неизвестная ошибка',
373
+ method: request.method(),
374
+ slide: activeSlide,
375
+ url: request.url(),
376
+ })
377
+ }
378
+ })
379
+
380
+ for (const [index, slideNumber] of REPRESENTATIVE_SLIDES.entries()) {
381
+ activeSlide = `слайд ${slideNumber}`
382
+ await inspectSlide(
383
+ page,
384
+ server.origin,
385
+ slideNumber,
386
+ REPRESENTATIVE_SLIDES[index - 1],
387
+ () => inFlightTracker.waitForIdle(),
388
+ )
389
+ }
390
+
391
+ for (const asset of THEME_MEDIA) {
392
+ activeSlide = `медиа: ${asset.label}`
393
+ await loadThemeImage(page, server.origin, asset)
394
+ await inFlightTracker.waitForIdle()
395
+ }
396
+
397
+ await inFlightTracker.waitForIdle()
398
+ assertNoDiagnostics('ошибки страницы', pageErrors)
399
+ assertNoDiagnostics('ошибки консоли', consoleErrors)
400
+ assertNoDiagnostics('ответы HTTP со статусом >= 400', failedResponses)
401
+ assertNoDiagnostics('неуспешные запросы того же источника', failedRequests)
402
+
403
+ console.log(
404
+ `Браузерная проверка: слайды=${REPRESENTATIVE_SLIDES.length}, `
405
+ + `ошибкиСтраницы=${pageErrors.length}, ошибкиКонсоли=${consoleErrors.length}, `
406
+ + `ошибочныеОтветы=${failedResponses.length}, активныеЗапросы=${inFlightTracker.size}, `
407
+ + `переполнение=0, медиа=${THEME_MEDIA.length}, переходыЖЦ=${REPRESENTATIVE_SLIDES.length - 1}`,
408
+ )
409
+ }
410
+ catch (error) {
411
+ runError = error
412
+ }
413
+
414
+ inFlightTracker?.dispose()
415
+ const cleanupErrors = await closeResources(browser, server)
416
+ if (runError && cleanupErrors.length > 0)
417
+ throw new AggregateError([runError, ...cleanupErrors], 'Браузерная проверка и очистка завершились с ошибками')
418
+ if (runError)
419
+ throw runError
420
+ if (cleanupErrors.length > 0)
421
+ throw new AggregateError(cleanupErrors, 'Очистка браузерной проверки завершилась с ошибками')
422
+ }
423
+
424
+ await runBrowserSmoke()
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ function contentHash(content) {
4
+ return createHash('sha256').update(content).digest('hex')
5
+ }
6
+
7
+ /**
8
+ * @template {{ relativePath: string, size: number }} T
9
+ * @param {T[]} files
10
+ * @param {number} maxBytes
11
+ * @param {(file: T) => Promise<string | NodeJS.ArrayBufferView>} readContent
12
+ */
13
+ export async function readArtifactContentsWithinLimit(files, maxBytes, readContent) {
14
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
15
+
16
+ if (totalBytes > maxBytes) {
17
+ return {
18
+ filesWithContent: [],
19
+ overLimit: true,
20
+ totalBytes,
21
+ }
22
+ }
23
+
24
+ const filesWithContent = await Promise.all(files.map(async file => ({
25
+ relativePath: file.relativePath,
26
+ content: await readContent(file),
27
+ })))
28
+
29
+ return {
30
+ filesWithContent,
31
+ overLimit: false,
32
+ totalBytes,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {Array<{ relativePath: string, content: string | NodeJS.ArrayBufferView }>} themeAssets
38
+ * @param {Array<{ relativePath: string, content: string | NodeJS.ArrayBufferView }>} distFiles
39
+ */
40
+ export function findThemeAssetOccurrences(themeAssets, distFiles) {
41
+ const pathsByHash = new Map()
42
+
43
+ for (const file of distFiles) {
44
+ const hash = contentHash(file.content)
45
+ const paths = pathsByHash.get(hash) ?? []
46
+
47
+ paths.push(file.relativePath)
48
+ pathsByHash.set(hash, paths)
49
+ }
50
+
51
+ return themeAssets.map(asset => ({
52
+ assetPath: asset.relativePath,
53
+ occurrences: [...(pathsByHash.get(contentHash(asset.content)) ?? [])].sort(),
54
+ }))
55
+ }