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,428 @@
1
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs'
2
+ import { createServer } from 'node:http'
3
+ import {
4
+ extname,
5
+ isAbsolute,
6
+ join,
7
+ relative,
8
+ resolve,
9
+ sep,
10
+ } from 'node:path'
11
+
12
+ const CONTENT_TYPES = new Map([
13
+ ['.css', 'text/css; charset=utf-8'],
14
+ ['.html', 'text/html; charset=utf-8'],
15
+ ['.js', 'text/javascript; charset=utf-8'],
16
+ ['.json', 'application/json; charset=utf-8'],
17
+ ['.mjs', 'text/javascript; charset=utf-8'],
18
+ ['.png', 'image/png'],
19
+ ['.svg', 'image/svg+xml'],
20
+ ['.txt', 'text/plain; charset=utf-8'],
21
+ ['.webp', 'image/webp'],
22
+ ['.woff', 'font/woff'],
23
+ ['.woff2', 'font/woff2'],
24
+ ])
25
+
26
+ const STATIC_PREFIXES = ['/assets/', '/theme/']
27
+ const MAX_DECODE_PASSES = 10
28
+ const ALLOWED_LOCAL_STORAGE_WARNING = '`--localstorage-file` was provided without a valid path'
29
+
30
+ export function classifyBrowserConsoleMessage(type, text) {
31
+ if (type === 'warning' && text === ALLOWED_LOCAL_STORAGE_WARNING)
32
+ return 'allowed'
33
+
34
+ if (type === 'error')
35
+ return 'rejected'
36
+
37
+ return 'ignored'
38
+ }
39
+
40
+ export function isSameOriginUrl(origin, candidateUrl) {
41
+ try {
42
+ return new URL(candidateUrl).origin === new URL(origin).origin
43
+ }
44
+ catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ export function createInFlightRequestTracker({
50
+ clearTimer = clearTimeout,
51
+ idleMs = 150,
52
+ origin,
53
+ setTimer = setTimeout,
54
+ timeoutMs = 15_000,
55
+ }) {
56
+ const expectedOrigin = new URL(origin).origin
57
+ const inFlight = new Set()
58
+ const waiters = new Set()
59
+ let disposed = false
60
+
61
+ function isSameOrigin(request) {
62
+ try {
63
+ const requestUrl = typeof request === 'string' ? request : request.url()
64
+ return isSameOriginUrl(expectedOrigin, requestUrl)
65
+ }
66
+ catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ function clearIdleTimer(waiter) {
72
+ if (waiter.idleTimer === undefined)
73
+ return
74
+
75
+ clearTimer(waiter.idleTimer)
76
+ waiter.idleTimer = undefined
77
+ }
78
+
79
+ function removeWaiter(waiter) {
80
+ if (!waiters.delete(waiter))
81
+ return false
82
+
83
+ clearIdleTimer(waiter)
84
+ if (waiter.timeoutTimer !== undefined) {
85
+ clearTimer(waiter.timeoutTimer)
86
+ waiter.timeoutTimer = undefined
87
+ }
88
+
89
+ return true
90
+ }
91
+
92
+ function resolveWaiter(waiter) {
93
+ if (!removeWaiter(waiter))
94
+ return
95
+
96
+ waiter.resolve()
97
+ }
98
+
99
+ function rejectWaiter(waiter, error) {
100
+ if (!removeWaiter(waiter))
101
+ return
102
+
103
+ waiter.reject(error)
104
+ }
105
+
106
+ function armIdleTimer(waiter) {
107
+ clearIdleTimer(waiter)
108
+ if (inFlight.size > 0)
109
+ return
110
+
111
+ waiter.idleTimer = setTimer(() => resolveWaiter(waiter), idleMs)
112
+ }
113
+
114
+ return {
115
+ dispose() {
116
+ if (disposed)
117
+ return
118
+
119
+ disposed = true
120
+ inFlight.clear()
121
+ for (const waiter of [...waiters])
122
+ rejectWaiter(waiter, new Error('in-flight tracker остановлен'))
123
+ },
124
+ requestSettled(request) {
125
+ if (!inFlight.delete(request) || inFlight.size > 0)
126
+ return
127
+
128
+ for (const waiter of waiters)
129
+ armIdleTimer(waiter)
130
+ },
131
+ requestStarted(request) {
132
+ if (disposed || !isSameOrigin(request))
133
+ return
134
+
135
+ inFlight.add(request)
136
+ for (const waiter of waiters)
137
+ clearIdleTimer(waiter)
138
+ },
139
+ get size() {
140
+ return inFlight.size
141
+ },
142
+ waitForIdle() {
143
+ if (disposed)
144
+ return Promise.reject(new Error('in-flight tracker остановлен'))
145
+
146
+ return new Promise((resolveIdle, rejectIdle) => {
147
+ const waiter = {
148
+ idleTimer: undefined,
149
+ reject: rejectIdle,
150
+ resolve: resolveIdle,
151
+ timeoutTimer: undefined,
152
+ }
153
+ waiters.add(waiter)
154
+ waiter.timeoutTimer = setTimer(() => {
155
+ rejectWaiter(
156
+ waiter,
157
+ new Error(`не дождался завершения same-origin requests за ${timeoutMs} ms`),
158
+ )
159
+ }, timeoutMs)
160
+ armIdleTimer(waiter)
161
+ })
162
+ },
163
+ }
164
+ }
165
+
166
+ function withoutQueryOrHash(value) {
167
+ const queryIndex = value.indexOf('?')
168
+ const hashIndex = value.indexOf('#')
169
+ const indexes = [queryIndex, hashIndex].filter(index => index >= 0)
170
+
171
+ return indexes.length === 0 ? value : value.slice(0, Math.min(...indexes))
172
+ }
173
+
174
+ function decodePathname(pathname) {
175
+ if (typeof pathname !== 'string')
176
+ return null
177
+
178
+ let decoded = withoutQueryOrHash(pathname)
179
+ if (!decoded.startsWith('/'))
180
+ return null
181
+
182
+ try {
183
+ for (let pass = 0; pass < MAX_DECODE_PASSES; pass++) {
184
+ const next = decodeURIComponent(decoded)
185
+ if (next === decoded)
186
+ break
187
+
188
+ decoded = next
189
+ }
190
+ }
191
+ catch {
192
+ return null
193
+ }
194
+
195
+ decoded = decoded.replaceAll('\\', '/')
196
+ if (decoded.includes('\0'))
197
+ return null
198
+
199
+ const segments = decoded.split('/')
200
+ if (segments.includes('..'))
201
+ return null
202
+
203
+ return decoded
204
+ }
205
+
206
+ function isContained(root, candidate) {
207
+ const relativePath = relative(root, candidate)
208
+
209
+ return relativePath === ''
210
+ || (!isAbsolute(relativePath)
211
+ && relativePath !== '..'
212
+ && !relativePath.startsWith(`..${sep}`))
213
+ }
214
+
215
+ function isSafeFile(root, candidate) {
216
+ if (!isContained(root, candidate))
217
+ return false
218
+
219
+ const relativePath = relative(root, candidate)
220
+ let current = root
221
+
222
+ try {
223
+ for (const segment of relativePath.split(sep).filter(Boolean)) {
224
+ current = join(current, segment)
225
+ const stat = lstatSync(current)
226
+ if (stat.isSymbolicLink())
227
+ return false
228
+ }
229
+
230
+ const stat = lstatSync(candidate)
231
+ if (!stat.isFile())
232
+ return false
233
+
234
+ return isContained(realpathSync(root), realpathSync(candidate))
235
+ }
236
+ catch {
237
+ return false
238
+ }
239
+ }
240
+
241
+ function isSpaRoute(pathname) {
242
+ if (STATIC_PREFIXES.some(prefix => pathname.startsWith(prefix)))
243
+ return false
244
+
245
+ const lastSegment = pathname.split('/').filter(Boolean).at(-1) ?? ''
246
+ return extname(lastSegment) === ''
247
+ }
248
+
249
+ /**
250
+ * Безопасно сопоставляет URL path с обычным файлом внутри готового dist.
251
+ *
252
+ * @param {string} distDir
253
+ * @param {string} pathname
254
+ * @returns {{ path: string, relativePath: string } | null}
255
+ */
256
+ export function resolveDistRequest(distDir, pathname) {
257
+ const decodedPathname = decodePathname(pathname)
258
+ if (decodedPathname === null)
259
+ return null
260
+
261
+ const root = resolve(distDir)
262
+ try {
263
+ const rootStat = lstatSync(root)
264
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
265
+ return null
266
+ }
267
+ catch {
268
+ return null
269
+ }
270
+
271
+ const requestRelativePath = decodedPathname.replace(/^\/+/, '')
272
+ const requestedPath = resolve(root, requestRelativePath)
273
+
274
+ if (isSafeFile(root, requestedPath)) {
275
+ return {
276
+ path: requestedPath,
277
+ relativePath: relative(root, requestedPath).split(sep).join('/'),
278
+ }
279
+ }
280
+
281
+ try {
282
+ if (lstatSync(requestedPath).isDirectory() && requestedPath !== root)
283
+ return null
284
+ }
285
+ catch {
286
+ // Отсутствующий route ниже может получить SPA fallback.
287
+ }
288
+
289
+ if (!isSpaRoute(decodedPathname))
290
+ return null
291
+
292
+ const indexPath = join(root, 'index.html')
293
+ if (!isSafeFile(root, indexPath))
294
+ return null
295
+
296
+ return {
297
+ path: indexPath,
298
+ relativePath: 'index.html',
299
+ }
300
+ }
301
+
302
+ function contentTypeFor(path) {
303
+ return CONTENT_TYPES.get(extname(path).toLowerCase()) ?? 'application/octet-stream'
304
+ }
305
+
306
+ function sendStatus(response, status, message, headers = {}, omitBody = false) {
307
+ const body = `${message}\n`
308
+ response.writeHead(status, {
309
+ 'cache-control': 'no-store',
310
+ 'content-length': Buffer.byteLength(body),
311
+ 'content-type': 'text/plain; charset=utf-8',
312
+ 'x-content-type-options': 'nosniff',
313
+ ...headers,
314
+ })
315
+ response.end(omitBody ? undefined : body)
316
+ }
317
+
318
+ export function readStaticFile(path, readFile = readFileSync) {
319
+ try {
320
+ return readFile(path)
321
+ }
322
+ catch (error) {
323
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR')
324
+ return null
325
+
326
+ throw error
327
+ }
328
+ }
329
+
330
+ async function handleRequest(distDir, request, response, readFile) {
331
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
332
+ sendStatus(response, 405, 'Method Not Allowed', { allow: 'GET, HEAD' })
333
+ return
334
+ }
335
+
336
+ const requestUrl = request.url ?? ''
337
+ if (!requestUrl.startsWith('/')) {
338
+ sendStatus(response, 400, 'Bad Request', {}, request.method === 'HEAD')
339
+ return
340
+ }
341
+
342
+ const resolved = resolveDistRequest(distDir, requestUrl)
343
+ if (resolved === null) {
344
+ sendStatus(response, 404, 'Not Found', {}, request.method === 'HEAD')
345
+ return
346
+ }
347
+
348
+ const body = readStaticFile(resolved.path, readFile)
349
+ if (body === null) {
350
+ sendStatus(response, 404, 'Not Found', {}, request.method === 'HEAD')
351
+ return
352
+ }
353
+
354
+ response.writeHead(200, {
355
+ 'cache-control': 'no-store',
356
+ 'content-length': body.byteLength,
357
+ 'content-type': contentTypeFor(resolved.path),
358
+ 'x-content-type-options': 'nosniff',
359
+ })
360
+
361
+ if (request.method === 'HEAD')
362
+ response.end()
363
+ else
364
+ response.end(body)
365
+ }
366
+
367
+ /**
368
+ * Поднимает локальный static server на loopback и случайном свободном порту.
369
+ *
370
+ * @param {string} distDir
371
+ * @param {{ readFile?: (path: string) => Buffer }} options
372
+ * @returns {Promise<{ origin: string, close: () => Promise<void> }>}
373
+ */
374
+ export async function createStaticDistServer(distDir, { readFile = readFileSync } = {}) {
375
+ const root = resolve(distDir)
376
+ const rootStat = lstatSync(root)
377
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
378
+ throw new Error(`dist должен быть обычной директорией: ${root}`)
379
+
380
+ const server = createServer((request, response) => {
381
+ handleRequest(root, request, response, readFile).catch(() => {
382
+ if (!response.headersSent)
383
+ sendStatus(response, 500, 'Internal Server Error', {}, request.method === 'HEAD')
384
+ else
385
+ response.destroy()
386
+ })
387
+ })
388
+
389
+ await new Promise((resolveListen, rejectListen) => {
390
+ const onError = (error) => {
391
+ server.off('listening', onListening)
392
+ rejectListen(error)
393
+ }
394
+ const onListening = () => {
395
+ server.off('error', onError)
396
+ resolveListen()
397
+ }
398
+
399
+ server.once('error', onError)
400
+ server.once('listening', onListening)
401
+ server.listen(0, '127.0.0.1')
402
+ })
403
+
404
+ const address = server.address()
405
+ if (address === null || typeof address === 'string') {
406
+ server.close()
407
+ throw new Error('static server не сообщил TCP port')
408
+ }
409
+
410
+ let closePromise
411
+
412
+ return {
413
+ origin: `http://127.0.0.1:${address.port}`,
414
+ close() {
415
+ closePromise ??= new Promise((resolveClose, rejectClose) => {
416
+ server.close((error) => {
417
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING')
418
+ rejectClose(error)
419
+ else
420
+ resolveClose()
421
+ })
422
+ server.closeAllConnections?.()
423
+ })
424
+
425
+ return closePromise
426
+ },
427
+ }
428
+ }