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.
- package/LICENSE +21 -0
- package/README.md +72 -10
- package/components/Slide.vue +81 -26
- package/components/Slot.vue +33 -48
- package/components/Text.vue +68 -2
- package/components/Timeline.vue +2 -23
- package/composables/deck-slot-markup.cjs +249 -0
- package/composables/decor-catalog.mjs +169 -47
- package/composables/layout-authoring.ts +6 -2
- package/composables/layout-markdown.mjs +33 -0
- package/composables/layout-recipes.ts +51 -5
- package/composables/layout-shorthands.ts +251 -81
- package/composables/markdown-to-vnodes.cjs +1 -0
- package/composables/markdown-to-vnodes.mjs +237 -0
- package/composables/slot-placement.ts +1 -1
- package/composables/slot-spacing.ts +66 -0
- package/composables/text-fit-runtime.ts +458 -76
- package/composables/text-fit-scope.ts +13 -0
- package/composables/text-slot-markdown.mjs +193 -0
- package/composables/text-typography.mjs +2 -0
- package/composables/theme-assets.mjs +60 -0
- package/composables/timeline-grid.ts +27 -0
- package/composables/typescript-require.cjs +29 -0
- package/composables/validate-deck-layouts.cjs +277 -0
- package/decor-library.md +2 -0
- package/example.md +132 -10
- package/package.json +25 -11
- package/public/photos/photo-1.webp +0 -0
- package/public/photos/photo-10.webp +0 -0
- package/public/photos/photo-11.webp +0 -0
- package/public/photos/photo-12.webp +0 -0
- package/public/photos/photo-13.webp +0 -0
- package/public/photos/photo-14.webp +0 -0
- package/public/photos/photo-15.webp +0 -0
- package/public/photos/photo-16.webp +0 -0
- package/public/photos/photo-17.webp +0 -0
- package/public/photos/photo-18.webp +0 -0
- package/public/photos/photo-19.webp +0 -0
- package/public/photos/photo-2.webp +0 -0
- package/public/photos/photo-20.webp +0 -0
- package/public/photos/photo-21.webp +0 -0
- package/public/photos/photo-22.webp +0 -0
- package/public/photos/photo-23.webp +0 -0
- package/public/photos/photo-24.webp +0 -0
- package/public/photos/photo-25.webp +0 -0
- package/public/photos/photo-26.webp +0 -0
- package/public/photos/photo-27.webp +0 -0
- package/public/photos/photo-28.webp +0 -0
- package/public/photos/photo-29.webp +0 -0
- package/public/photos/photo-3.webp +0 -0
- package/public/photos/photo-30.webp +0 -0
- package/public/photos/photo-4.webp +0 -0
- package/public/photos/photo-5.webp +0 -0
- package/public/photos/photo-6.webp +0 -0
- package/public/photos/photo-7.webp +0 -0
- package/public/photos/photo-8.webp +0 -0
- package/public/photos/photo-9.webp +0 -0
- package/scripts/browser-smoke-runtime.mjs +428 -0
- package/scripts/browser-smoke.mjs +424 -0
- package/scripts/build-artifact-assets.mjs +55 -0
- package/scripts/check-build-artifact.mjs +243 -0
- package/scripts/check-package.mjs +132 -0
- package/scripts/check-test-architecture.mjs +176 -0
- package/scripts/npm-spawn.mjs +31 -0
- package/scripts/test-architecture-policy.mjs +245 -0
- package/scripts/theme-asset-inventory.mjs +83 -0
- package/scripts/validate-deck.cjs +18 -0
- package/setup/decor-save-middleware.ts +244 -0
- package/setup/vite-plugins.ts +14 -84
- package/skills/slidev-practicum/SKILL.md +25 -0
- package/skills/slidev-practicum/agents/openai.yaml +4 -0
- package/styles/index.css +10 -1
- package/composables/layout-registry.ts +0 -47
- package/env.d.ts +0 -6
- package/public/photos/photo-1.png +0 -0
- package/public/photos/photo-10.png +0 -0
- package/public/photos/photo-2.png +0 -0
- package/public/photos/photo-3.png +0 -0
- package/public/photos/photo-4.png +0 -0
- package/public/photos/photo-5.png +0 -0
- package/public/photos/photo-6.png +0 -0
- package/public/photos/photo-7.png +0 -0
- package/public/photos/photo-8.png +0 -0
- package/public/photos/photo-9.png +0 -0
- package/slidev-theme-practicum.png +0 -0
- 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
|
+
}
|
package/setup/vite-plugins.ts
CHANGED
|
@@ -1,67 +1,24 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
|
-
import type {
|
|
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
|
|
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
|
-
|
|
23
|
-
const contentType = getHeader(req, 'content-type')
|
|
9
|
+
type DecorLibraryVitePluginContext = Pick<ResolvedSlidevOptions, 'data'>
|
|
24
10
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
]
|