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,245 @@
1
+ import ts from 'typescript'
2
+
3
+ const allowedRawExtensions = new Set(['.css', '.md'])
4
+ const projectReaderModules = new Set([
5
+ './helpers/project-root.cjs',
6
+ '../tests/helpers/project-root.cjs',
7
+ ])
8
+ const fileSystemModules = new Set(['fs', 'fs/promises', 'node:fs', 'node:fs/promises'])
9
+ const fileSystemReaders = new Set(['readFile', 'readFileSync'])
10
+
11
+ function moduleKind(specifier) {
12
+ if (projectReaderModules.has(specifier) || specifier.endsWith('/helpers/project-root.cjs'))
13
+ return 'project'
14
+ if (fileSystemModules.has(specifier))
15
+ return 'fs'
16
+ return undefined
17
+ }
18
+
19
+ function requireSpecifier(node) {
20
+ return (
21
+ ts.isCallExpression(node)
22
+ && ts.isIdentifier(node.expression)
23
+ && node.expression.text === 'require'
24
+ && node.arguments.length === 1
25
+ && ts.isStringLiteral(node.arguments[0])
26
+ )
27
+ ? node.arguments[0].text
28
+ : undefined
29
+ }
30
+
31
+ function readerNameAllowed(kind, name) {
32
+ return kind === 'project'
33
+ ? name === 'readProjectFile'
34
+ : kind === 'fs' && fileSystemReaders.has(name)
35
+ }
36
+
37
+ function memberAccess(node) {
38
+ if (ts.isPropertyAccessExpression(node))
39
+ return { object: node.expression, name: node.name.text }
40
+ if (
41
+ ts.isElementAccessExpression(node)
42
+ && node.argumentExpression
43
+ && ts.isStringLiteralLike(node.argumentExpression)
44
+ ) {
45
+ return { object: node.expression, name: node.argumentExpression.text }
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ function collectBindings(sourceFile) {
51
+ const directReaders = new Set()
52
+ const namespaces = new Map()
53
+ const initializers = new Map()
54
+ const declarations = []
55
+
56
+ for (const statement of sourceFile.statements) {
57
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
58
+ const kind = moduleKind(statement.moduleSpecifier.text)
59
+ if (!kind)
60
+ continue
61
+
62
+ const clause = statement.importClause
63
+ if (clause?.name)
64
+ namespaces.set(clause.name.text, kind)
65
+ if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings))
66
+ namespaces.set(clause.namedBindings.name.text, kind)
67
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
68
+ for (const element of clause.namedBindings.elements) {
69
+ const importedName = element.propertyName?.text ?? element.name.text
70
+ if (readerNameAllowed(kind, importedName))
71
+ directReaders.add(element.name.text)
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ function visitDeclarations(node) {
78
+ if (ts.isVariableDeclaration(node)) {
79
+ declarations.push(node)
80
+ if (ts.isIdentifier(node.name) && node.initializer)
81
+ initializers.set(node.name.text, node.initializer)
82
+
83
+ const specifier = node.initializer
84
+ ? requireSpecifier(node.initializer)
85
+ : undefined
86
+ const kind = specifier ? moduleKind(specifier) : undefined
87
+
88
+ if (kind && ts.isIdentifier(node.name))
89
+ namespaces.set(node.name.text, kind)
90
+ if (kind && ts.isObjectBindingPattern(node.name)) {
91
+ for (const element of node.name.elements) {
92
+ if (!ts.isIdentifier(element.name))
93
+ continue
94
+
95
+ const importedName = element.propertyName && ts.isIdentifier(element.propertyName)
96
+ ? element.propertyName.text
97
+ : element.name.text
98
+ if (readerNameAllowed(kind, importedName))
99
+ directReaders.add(element.name.text)
100
+ }
101
+ }
102
+ }
103
+
104
+ ts.forEachChild(node, visitDeclarations)
105
+ }
106
+
107
+ visitDeclarations(sourceFile)
108
+
109
+ let changed = true
110
+ while (changed) {
111
+ changed = false
112
+
113
+ for (const declaration of declarations) {
114
+ if (!declaration.initializer)
115
+ continue
116
+
117
+ if (
118
+ ts.isObjectBindingPattern(declaration.name)
119
+ && ts.isIdentifier(declaration.initializer)
120
+ ) {
121
+ const kind = namespaces.get(declaration.initializer.text)
122
+ if (!kind)
123
+ continue
124
+
125
+ for (const element of declaration.name.elements) {
126
+ if (!ts.isIdentifier(element.name))
127
+ continue
128
+
129
+ const importedName = element.propertyName && ts.isIdentifier(element.propertyName)
130
+ ? element.propertyName.text
131
+ : element.name.text
132
+ if (readerNameAllowed(kind, importedName) && !directReaders.has(element.name.text)) {
133
+ directReaders.add(element.name.text)
134
+ changed = true
135
+ }
136
+ }
137
+ continue
138
+ }
139
+
140
+ if (!ts.isIdentifier(declaration.name))
141
+ continue
142
+
143
+ const localName = declaration.name.text
144
+ if (directReaders.has(localName))
145
+ continue
146
+
147
+ if (
148
+ ts.isIdentifier(declaration.initializer)
149
+ && directReaders.has(declaration.initializer.text)
150
+ ) {
151
+ directReaders.add(localName)
152
+ changed = true
153
+ continue
154
+ }
155
+
156
+ const access = memberAccess(declaration.initializer)
157
+ if (access) {
158
+ const namespaceKind = ts.isIdentifier(access.object)
159
+ ? namespaces.get(access.object.text)
160
+ : undefined
161
+ const requiredModule = requireSpecifier(access.object)
162
+ const requiredKind = requiredModule ? moduleKind(requiredModule) : undefined
163
+ const kind = namespaceKind ?? requiredKind
164
+
165
+ if (kind && readerNameAllowed(kind, access.name)) {
166
+ directReaders.add(localName)
167
+ changed = true
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return { directReaders, initializers, namespaces }
174
+ }
175
+
176
+ function collectPathCandidates(node, initializers, seen = new Set()) {
177
+ if (ts.isStringLiteralLike(node))
178
+ return [node.text]
179
+
180
+ if (ts.isIdentifier(node) && initializers.has(node.text) && !seen.has(node.text)) {
181
+ seen.add(node.text)
182
+ return collectPathCandidates(initializers.get(node.text), initializers, seen)
183
+ }
184
+
185
+ const candidates = []
186
+ ts.forEachChild(node, child =>
187
+ candidates.push(...collectPathCandidates(child, initializers, new Set(seen))))
188
+ return candidates
189
+ }
190
+
191
+ function extensionOf(path) {
192
+ const match = path.toLowerCase().match(/(\.[a-z0-9]+)$/u)
193
+ return match?.[1]
194
+ }
195
+
196
+ function rawReaderKind(call, bindings) {
197
+ if (ts.isIdentifier(call.expression) && bindings.directReaders.has(call.expression.text))
198
+ return true
199
+
200
+ const access = memberAccess(call.expression)
201
+ if (!access)
202
+ return false
203
+
204
+ const namespaceKind = ts.isIdentifier(access.object)
205
+ ? bindings.namespaces.get(access.object.text)
206
+ : undefined
207
+ const requiredModule = requireSpecifier(access.object)
208
+ const requiredKind = requiredModule ? moduleKind(requiredModule) : undefined
209
+ const kind = namespaceKind ?? requiredKind
210
+
211
+ return Boolean(kind && readerNameAllowed(kind, access.name))
212
+ }
213
+
214
+ export function findDisallowedRawSourceReads(source, relativeFile = 'test.cjs') {
215
+ const sourceFile = ts.createSourceFile(
216
+ relativeFile,
217
+ source,
218
+ ts.ScriptTarget.Latest,
219
+ true,
220
+ ts.ScriptKind.JS,
221
+ )
222
+ const bindings = collectBindings(sourceFile)
223
+ const violations = []
224
+
225
+ function visit(node) {
226
+ if (ts.isCallExpression(node) && rawReaderKind(node, bindings)) {
227
+ const candidates = node.arguments[0]
228
+ ? collectPathCandidates(node.arguments[0], bindings.initializers)
229
+ : []
230
+ const pathCandidates = candidates.filter(candidate => extensionOf(candidate))
231
+ const disallowedPath = pathCandidates.find(path =>
232
+ !allowedRawExtensions.has(extensionOf(path)))
233
+
234
+ if (disallowedPath || pathCandidates.length === 0) {
235
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
236
+ violations.push(`${disallowedPath ?? '<динамический путь>'}:${line}`)
237
+ }
238
+ }
239
+
240
+ ts.forEachChild(node, visit)
241
+ }
242
+
243
+ visit(sourceFile)
244
+ return violations
245
+ }
@@ -0,0 +1,83 @@
1
+ import { lstat, readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export const EXPECTED_THEME_PHOTOS = Object.freeze(
5
+ Array.from({ length: 30 }, (_, index) => `photos/photo-${index + 1}.webp`),
6
+ )
7
+
8
+ export const EXPECTED_THEME_DECORS = Object.freeze([
9
+ 'decor/decor-1.svg',
10
+ 'decor/decor-2.png',
11
+ 'decor/decor-3.svg',
12
+ 'decor/decor-4.svg',
13
+ 'decor/decor-5.png',
14
+ 'decor/decor-6.png',
15
+ 'decor/decor-7.png',
16
+ 'decor/decor-8.svg',
17
+ 'decor/decor-9.svg',
18
+ 'decor/decor-10.svg',
19
+ 'decor/decor-11.svg',
20
+ 'decor/decor-12.svg',
21
+ 'decor/decor-13.svg',
22
+ 'decor/decor-14.svg',
23
+ 'decor/decor-15.svg',
24
+ 'decor/decor-16.svg',
25
+ 'decor/decor-17.png',
26
+ 'decor/decor-18.png',
27
+ 'decor/decor-19.svg',
28
+ ])
29
+
30
+ export const EXPECTED_THEME_ASSETS = Object.freeze([
31
+ 'favicon.svg',
32
+ ...EXPECTED_THEME_DECORS,
33
+ ...EXPECTED_THEME_PHOTOS,
34
+ ])
35
+
36
+ /**
37
+ * @param {string} path
38
+ */
39
+ export function toPosixPath(path) {
40
+ return String(path).replaceAll('\\', '/')
41
+ }
42
+
43
+ /**
44
+ * @param {string} directory
45
+ * @param {readonly string[]} expectedNames
46
+ */
47
+ export async function validateAssetDirectory(directory, expectedNames) {
48
+ const directoryStat = await lstat(directory)
49
+
50
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
51
+ throw new Error(`source inventory должен быть обычной директорией: ${directory}`)
52
+
53
+ const names = await readdir(directory)
54
+ const actual = new Set(names)
55
+ const expected = new Set(expectedNames)
56
+ const errors = []
57
+
58
+ if (expected.size !== expectedNames.length)
59
+ errors.push('ожидаемый inventory содержит дубли')
60
+
61
+ const missing = expectedNames.filter(name => !actual.has(name))
62
+ const unexpected = names.filter(name => !expected.has(name))
63
+
64
+ if (missing.length > 0)
65
+ errors.push(`отсутствуют файлы: ${missing.join(', ')}`)
66
+
67
+ if (unexpected.length > 0)
68
+ errors.push(`неожиданные файлы: ${unexpected.join(', ')}`)
69
+
70
+ await Promise.all(names.map(async (name) => {
71
+ const fileStat = await lstat(join(directory, name))
72
+
73
+ if (fileStat.isSymbolicLink())
74
+ errors.push(`файл не должен быть symlink: ${name}`)
75
+ else if (!fileStat.isFile())
76
+ errors.push(`должен быть обычным файлом: ${name}`)
77
+ }))
78
+
79
+ if (errors.length > 0)
80
+ throw new Error(`source inventory не прошёл проверку: ${errors.join('; ')}`)
81
+
82
+ return names.sort((left, right) => left.localeCompare(right, 'en', { numeric: true }))
83
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ const { resolve } = require('node:path')
3
+ const { formatDeckLayoutIssues, validateDeckLayouts } = require('../composables/validate-deck-layouts.cjs')
4
+
5
+ const deckPath = resolve(process.argv[2] ?? 'example.md')
6
+
7
+ validateDeckLayouts(deckPath).then((issues) => {
8
+ if (!issues.length) {
9
+ console.log(`OK: ${deckPath} — контракты layout/markdown соблюдены.`)
10
+ process.exit(0)
11
+ }
12
+
13
+ console.error(formatDeckLayoutIssues(issues, deckPath))
14
+ process.exit(1)
15
+ }).catch((error) => {
16
+ console.error(error)
17
+ process.exit(1)
18
+ })
@@ -0,0 +1,244 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { DecorStore, DecorVariant } from '../composables/decor-store'
3
+
4
+ type Next = () => void
5
+
6
+ type DecorSaveMiddlewareOptions = {
7
+ store: DecorStore
8
+ expectedOrigin?: string
9
+ maxBytes?: number
10
+ }
11
+
12
+ export const MAX_DECOR_SAVE_BYTES = 1024 * 1024
13
+ const SAVE_PATH = '/__decor-library/save'
14
+
15
+ class RequestBodyTooLargeError extends Error {}
16
+
17
+ function drainRequest(req: IncomingMessage) {
18
+ const cleanup = () => {
19
+ req.off('error', cleanup)
20
+ req.off('end', cleanup)
21
+ req.off('close', cleanup)
22
+ }
23
+
24
+ req.once('error', cleanup)
25
+ req.once('end', cleanup)
26
+ req.once('close', cleanup)
27
+ req.resume()
28
+ }
29
+
30
+ function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
31
+ return new Promise((resolve, reject) => {
32
+ const chunks: Buffer[] = []
33
+ let bytes = 0
34
+
35
+ const cleanup = () => {
36
+ req.off('data', onData)
37
+ req.off('end', onEnd)
38
+ req.off('error', onError)
39
+ req.off('aborted', onAborted)
40
+ }
41
+ const fail = (error: Error) => {
42
+ cleanup()
43
+ reject(error)
44
+ }
45
+ const onData = (chunk: Buffer | string) => {
46
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
47
+ bytes += buffer.byteLength
48
+
49
+ if (bytes > maxBytes) {
50
+ chunks.length = 0
51
+ cleanup()
52
+ drainRequest(req)
53
+ reject(new RequestBodyTooLargeError('Request body exceeds 1 MiB'))
54
+ return
55
+ }
56
+
57
+ chunks.push(buffer)
58
+ }
59
+ const onEnd = () => {
60
+ cleanup()
61
+
62
+ try {
63
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown)
64
+ }
65
+ catch (error) {
66
+ reject(error)
67
+ }
68
+ }
69
+ const onError = (error: Error) => fail(error)
70
+ const onAborted = () => fail(new Error('Request aborted'))
71
+
72
+ req.on('end', onEnd)
73
+ req.on('error', onError)
74
+ req.on('aborted', onAborted)
75
+ req.on('data', onData)
76
+ })
77
+ }
78
+
79
+ function sendJson(res: ServerResponse, status: number, data: unknown) {
80
+ res.statusCode = status
81
+ res.setHeader('content-type', 'application/json')
82
+ res.end(JSON.stringify(data))
83
+ }
84
+
85
+ function sendError(res: ServerResponse, status: number, error: string) {
86
+ sendJson(res, status, { ok: false, error })
87
+ }
88
+
89
+ function sendInternalError(res: ServerResponse, error: unknown) {
90
+ sendError(
91
+ res,
92
+ 500,
93
+ error instanceof Error ? error.message : 'Unknown save error',
94
+ )
95
+ }
96
+
97
+ function isRecord(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
99
+ }
100
+
101
+ function getHeader(req: IncomingMessage, name: string) {
102
+ const value = req.headers[name.toLowerCase()]
103
+
104
+ return Array.isArray(value) ? value[0] : value
105
+ }
106
+
107
+ function isJsonRequest(req: IncomingMessage) {
108
+ const contentType = getHeader(req, 'content-type')
109
+
110
+ return contentType?.split(';', 1)[0]?.trim().toLowerCase() === 'application/json'
111
+ }
112
+
113
+ function normalizeOrigin(value: string) {
114
+ const url = new URL(value)
115
+
116
+ if (
117
+ url.origin === 'null'
118
+ || url.username
119
+ || url.password
120
+ || url.pathname !== '/'
121
+ || url.search
122
+ || url.hash
123
+ ) {
124
+ return null
125
+ }
126
+
127
+ return url.origin
128
+ }
129
+
130
+ function readRequestOrigin(req: IncomingMessage) {
131
+ const host = getHeader(req, 'host')
132
+ if (!host)
133
+ return null
134
+
135
+ const protocol = (req.socket as { encrypted?: boolean } | undefined)?.encrypted
136
+ ? 'https'
137
+ : 'http'
138
+
139
+ try {
140
+ return normalizeOrigin(`${protocol}://${host}`)
141
+ }
142
+ catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ function isSameOriginRequest(req: IncomingMessage, expectedOrigin?: string) {
148
+ const origin = getHeader(req, 'origin')
149
+ if (!origin)
150
+ return false
151
+
152
+ try {
153
+ const normalizedOrigin = normalizeOrigin(origin)
154
+ const normalizedExpectedOrigin = expectedOrigin == null
155
+ ? readRequestOrigin(req)
156
+ : normalizeOrigin(expectedOrigin)
157
+
158
+ return normalizedOrigin !== null && normalizedOrigin === normalizedExpectedOrigin
159
+ }
160
+ catch {
161
+ return false
162
+ }
163
+ }
164
+
165
+ function isCrossSiteRequest(req: IncomingMessage) {
166
+ return getHeader(req, 'sec-fetch-site') === 'cross-site'
167
+ }
168
+
169
+ function parseRequestPathname(req: IncomingMessage) {
170
+ try {
171
+ return new URL(req.url ?? '/', 'http://localhost').pathname
172
+ }
173
+ catch {
174
+ return null
175
+ }
176
+ }
177
+
178
+ export function createDecorSaveMiddleware({
179
+ store,
180
+ expectedOrigin,
181
+ maxBytes = MAX_DECOR_SAVE_BYTES,
182
+ }: DecorSaveMiddlewareOptions) {
183
+ return async (req: IncomingMessage, res: ServerResponse, next: Next): Promise<void> => {
184
+ const pathname = parseRequestPathname(req)
185
+
186
+ if (pathname === null) {
187
+ sendError(res, 400, 'Invalid request URL')
188
+ return
189
+ }
190
+
191
+ if (pathname !== SAVE_PATH) {
192
+ next()
193
+ return
194
+ }
195
+
196
+ if (req.method !== 'POST') {
197
+ sendError(res, 405, 'Method not allowed')
198
+ return
199
+ }
200
+
201
+ if (!isJsonRequest(req)) {
202
+ sendError(res, 415, 'Expected application/json')
203
+ return
204
+ }
205
+
206
+ if (!isSameOriginRequest(req, expectedOrigin) || isCrossSiteRequest(req)) {
207
+ sendError(res, 403, 'Forbidden origin')
208
+ return
209
+ }
210
+
211
+ let payload: unknown
212
+
213
+ try {
214
+ payload = await readJsonBody(req, maxBytes)
215
+ }
216
+ catch (error) {
217
+ if (error instanceof RequestBodyTooLargeError) {
218
+ sendError(res, 413, error.message)
219
+ return
220
+ }
221
+
222
+ if (error instanceof SyntaxError) {
223
+ sendError(res, 400, 'Invalid JSON')
224
+ return
225
+ }
226
+
227
+ sendInternalError(res, error)
228
+ return
229
+ }
230
+
231
+ const overrides = isRecord(payload) ? payload.overrides : null
232
+ if (!Array.isArray(overrides)) {
233
+ sendError(res, 400, 'Expected overrides array')
234
+ return
235
+ }
236
+
237
+ try {
238
+ sendJson(res, 200, await store.save(overrides as DecorVariant[]))
239
+ }
240
+ catch (error) {
241
+ sendInternalError(res, error)
242
+ }
243
+ }
244
+ }
@@ -1,67 +1,24 @@
1
1
  import { resolve } from 'node:path'
2
- import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { ResolvedSlidevOptions } from '@slidev/types'
3
3
  import type { Plugin } from 'vite'
4
4
  import { createFileDecorStore } from '../composables/decor-file-store'
5
- import type { DecorVariant } from '../composables/decor-store'
6
-
7
- type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
5
+ import { createDecorSaveMiddleware } from './decor-save-middleware'
8
6
 
9
7
  const OUTPUT_PATH = resolve(process.cwd(), 'composables/decor-tuning-overrides.mjs')
10
- const SAVE_PATH = '/__decor-library/save'
11
-
12
- function isRecord(value: unknown): value is Record<string, unknown> {
13
- return typeof value === 'object' && value !== null && !Array.isArray(value)
14
- }
15
-
16
- function getHeader(req: IncomingMessage, name: string) {
17
- const value = req.headers[name.toLowerCase()]
18
-
19
- return Array.isArray(value) ? value[0] : value
20
- }
21
8
 
22
- function isJsonRequest(req: IncomingMessage) {
23
- const contentType = getHeader(req, 'content-type')
9
+ type DecorLibraryVitePluginContext = Pick<ResolvedSlidevOptions, 'data'>
24
10
 
25
- return contentType?.split(';', 1)[0]?.trim().toLowerCase() === 'application/json'
26
- }
27
-
28
- function isSameOriginRequest(req: IncomingMessage) {
29
- const origin = getHeader(req, 'origin')
30
- if (!origin)
31
- return true
32
-
33
- const host = getHeader(req, 'host')
34
- if (!host)
35
- return false
11
+ function readExpectedOrigin(options?: DecorLibraryVitePluginContext) {
12
+ const value = options?.data.config.themeConfig.decorSaveOrigin
36
13
 
37
- try {
38
- return new URL(origin).host === host
39
- }
40
- catch {
41
- return false
42
- }
14
+ return typeof value === 'string' && value.trim() ? value : undefined
43
15
  }
44
16
 
45
- function isCrossSiteRequest(req: IncomingMessage) {
46
- return getHeader(req, 'sec-fetch-site') === 'cross-site'
47
- }
48
-
49
- async function readJsonBody(req: IncomingMessage) {
50
- let body = ''
51
-
52
- for await (const chunk of req)
53
- body += chunk
17
+ export default function decorLibraryVitePlugins(
18
+ options?: DecorLibraryVitePluginContext,
19
+ ): Plugin[] {
20
+ const expectedOrigin = readExpectedOrigin(options)
54
21
 
55
- return JSON.parse(body)
56
- }
57
-
58
- function sendJson(res: ServerResponse, status: number, data: JsonValue) {
59
- res.statusCode = status
60
- res.setHeader('content-type', 'application/json')
61
- res.end(JSON.stringify(data))
62
- }
63
-
64
- export default function decorLibraryVitePlugins(): Plugin[] {
65
22
  return [
66
23
  {
67
24
  name: 'practicum:decor-library-save',
@@ -69,37 +26,10 @@ export default function decorLibraryVitePlugins(): Plugin[] {
69
26
  configureServer(server) {
70
27
  const store = createFileDecorStore({ output: OUTPUT_PATH })
71
28
 
72
- server.middlewares.use(async (req, res, next) => {
73
- const url = new URL(req.url ?? '/', 'http://localhost')
74
-
75
- if (url.pathname !== SAVE_PATH)
76
- return next()
77
-
78
- if (req.method !== 'POST')
79
- return sendJson(res, 405, { ok: false, error: 'Method not allowed' })
80
-
81
- if (!isJsonRequest(req))
82
- return sendJson(res, 415, { ok: false, error: 'Expected application/json' })
83
-
84
- if (!isSameOriginRequest(req) || isCrossSiteRequest(req))
85
- return sendJson(res, 403, { ok: false, error: 'Forbidden origin' })
86
-
87
- try {
88
- const payload = await readJsonBody(req)
89
- const overrides = isRecord(payload) ? payload.overrides : null
90
-
91
- if (!Array.isArray(overrides))
92
- return sendJson(res, 400, { ok: false, error: 'Expected overrides array' })
93
-
94
- sendJson(res, 200, await store.save(overrides as DecorVariant[]))
95
- }
96
- catch (error) {
97
- sendJson(res, 500, {
98
- ok: false,
99
- error: error instanceof Error ? error.message : 'Unknown save error',
100
- })
101
- }
102
- })
29
+ server.middlewares.use(createDecorSaveMiddleware({
30
+ expectedOrigin,
31
+ store,
32
+ }))
103
33
  },
104
34
  },
105
35
  ]