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,243 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import {
|
|
5
|
+
EXPECTED_THEME_ASSETS,
|
|
6
|
+
EXPECTED_THEME_DECORS,
|
|
7
|
+
EXPECTED_THEME_PHOTOS,
|
|
8
|
+
toPosixPath,
|
|
9
|
+
} from './theme-asset-inventory.mjs'
|
|
10
|
+
import {
|
|
11
|
+
findThemeAssetOccurrences,
|
|
12
|
+
readArtifactContentsWithinLimit,
|
|
13
|
+
} from './build-artifact-assets.mjs'
|
|
14
|
+
|
|
15
|
+
const MAX_BUILD_SIZE = 20 * 1024 * 1024
|
|
16
|
+
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
17
|
+
const DIST_DIR = join(PROJECT_ROOT, 'dist')
|
|
18
|
+
const errors = []
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} root
|
|
22
|
+
* @param {string} path
|
|
23
|
+
*/
|
|
24
|
+
function isContained(root, path) {
|
|
25
|
+
const relativePath = toPosixPath(relative(root, path))
|
|
26
|
+
|
|
27
|
+
return relativePath === ''
|
|
28
|
+
|| (relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} directory
|
|
33
|
+
* @param {string} distRealPath
|
|
34
|
+
* @returns {Promise<Array<{ path: string, relativePath: string, size: number }>>}
|
|
35
|
+
*/
|
|
36
|
+
async function listFiles(directory, distRealPath) {
|
|
37
|
+
const entries = await readdir(directory)
|
|
38
|
+
const nestedFiles = await Promise.all(entries.map(async (entry) => {
|
|
39
|
+
const path = join(directory, entry)
|
|
40
|
+
const fileStat = await lstat(path)
|
|
41
|
+
const relativePath = toPosixPath(relative(DIST_DIR, path))
|
|
42
|
+
|
|
43
|
+
if (fileStat.isSymbolicLink()) {
|
|
44
|
+
errors.push(`dist не должен содержать symlink: ${relativePath}`)
|
|
45
|
+
return []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const realPath = await realpath(path)
|
|
49
|
+
if (!isContained(distRealPath, realPath)) {
|
|
50
|
+
errors.push(`dist asset выходит за пределы dist: ${relativePath}`)
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (fileStat.isDirectory())
|
|
55
|
+
return listFiles(path, distRealPath)
|
|
56
|
+
|
|
57
|
+
if (!fileStat.isFile()) {
|
|
58
|
+
errors.push(`dist содержит недопустимый тип файла: ${relativePath}`)
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [{
|
|
63
|
+
path,
|
|
64
|
+
relativePath,
|
|
65
|
+
size: fileStat.size,
|
|
66
|
+
}]
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
return nestedFiles.flat()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} relativePath
|
|
74
|
+
* @param {string} distRealPath
|
|
75
|
+
*/
|
|
76
|
+
async function validateRequiredAsset(relativePath, distRealPath) {
|
|
77
|
+
const path = resolve(DIST_DIR, relativePath)
|
|
78
|
+
|
|
79
|
+
if (!isContained(DIST_DIR, path)) {
|
|
80
|
+
errors.push(`обязательный asset выходит за пределы dist: ${relativePath}`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const fileStat = await lstat(path)
|
|
86
|
+
|
|
87
|
+
if (fileStat.isSymbolicLink()) {
|
|
88
|
+
errors.push(`обязательный asset не должен быть symlink: ${relativePath}`)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!fileStat.isFile()) {
|
|
93
|
+
errors.push(`обязательный asset должен быть файлом: ${relativePath}`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const realPath = await realpath(path)
|
|
98
|
+
if (!isContained(distRealPath, realPath))
|
|
99
|
+
errors.push(`обязательный asset выходит за пределы dist: ${relativePath}`)
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
errors.push(`обязательный asset отсутствует: ${relativePath}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} label
|
|
108
|
+
* @param {string[]} actualPaths
|
|
109
|
+
* @param {readonly string[]} expectedPaths
|
|
110
|
+
*/
|
|
111
|
+
function compareInventory(label, actualPaths, expectedPaths) {
|
|
112
|
+
const actual = new Set(actualPaths)
|
|
113
|
+
const expected = new Set(expectedPaths)
|
|
114
|
+
const missing = expectedPaths.filter(path => !actual.has(path))
|
|
115
|
+
const unexpected = actualPaths.filter(path => !expected.has(path))
|
|
116
|
+
|
|
117
|
+
if (missing.length > 0)
|
|
118
|
+
errors.push(`${label} не содержит: ${missing.join(', ')}`)
|
|
119
|
+
|
|
120
|
+
if (unexpected.length > 0)
|
|
121
|
+
errors.push(`${label} содержит неожиданные файлы: ${unexpected.join(', ')}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let files = []
|
|
125
|
+
let distRealPath = ''
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const distStat = await lstat(DIST_DIR)
|
|
129
|
+
|
|
130
|
+
if (distStat.isSymbolicLink())
|
|
131
|
+
errors.push('dist не должен быть symlink')
|
|
132
|
+
else if (!distStat.isDirectory())
|
|
133
|
+
errors.push('dist должен быть директорией')
|
|
134
|
+
else {
|
|
135
|
+
distRealPath = await realpath(DIST_DIR)
|
|
136
|
+
files = await listFiles(DIST_DIR, distRealPath)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
errors.push('dist отсутствует: сначала запустите npm run test:build')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (distRealPath) {
|
|
144
|
+
const expectedAssets = EXPECTED_THEME_ASSETS.map(path => `theme/${path}`)
|
|
145
|
+
await Promise.all(expectedAssets.map(path => validateRequiredAsset(path, distRealPath)))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const relativeFiles = files.map(file => file.relativePath)
|
|
149
|
+
const relativeFileSet = new Set(relativeFiles)
|
|
150
|
+
const expectedPhotoFiles = EXPECTED_THEME_PHOTOS.map(path => `theme/${path}`)
|
|
151
|
+
const expectedDecorFiles = EXPECTED_THEME_DECORS.map(path => `theme/${path}`)
|
|
152
|
+
const actualPhotoFiles = relativeFiles.filter(path => path.startsWith('theme/photos/'))
|
|
153
|
+
const actualDecorFiles = relativeFiles.filter(path => path.startsWith('theme/decor/'))
|
|
154
|
+
const {
|
|
155
|
+
filesWithContent,
|
|
156
|
+
overLimit,
|
|
157
|
+
totalBytes,
|
|
158
|
+
} = await readArtifactContentsWithinLimit(
|
|
159
|
+
files,
|
|
160
|
+
MAX_BUILD_SIZE,
|
|
161
|
+
async file => await readFile(file.path),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if (overLimit) {
|
|
165
|
+
errors.push(`dist ${totalBytes} байт превышает лимит ${MAX_BUILD_SIZE} байт`)
|
|
166
|
+
for (const error of errors)
|
|
167
|
+
console.error(`- ${error}`)
|
|
168
|
+
process.exit(1)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
compareInventory('dist/theme/photos', actualPhotoFiles, expectedPhotoFiles)
|
|
172
|
+
compareInventory('dist/theme/decor', actualDecorFiles, expectedDecorFiles)
|
|
173
|
+
|
|
174
|
+
for (let number = 1; number <= 30; number++) {
|
|
175
|
+
for (const path of [
|
|
176
|
+
`photos/photo-${number}.png`,
|
|
177
|
+
`theme/photos/photo-${number}.png`,
|
|
178
|
+
]) {
|
|
179
|
+
if (relativeFileSet.has(path))
|
|
180
|
+
errors.push(`dist не должен содержать ${path}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await lstat(join(DIST_DIR, 'photos'))
|
|
186
|
+
errors.push('dist/photos не должен существовать')
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Ожидаем отсутствие deck-level photos.
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const duplicatePaths = relativeFiles
|
|
193
|
+
.filter(path => path.startsWith('theme/'))
|
|
194
|
+
.map(path => path.slice('theme/'.length))
|
|
195
|
+
.filter(path => relativeFileSet.has(path))
|
|
196
|
+
|
|
197
|
+
if (duplicatePaths.length > 0)
|
|
198
|
+
errors.push(`dist содержит дубли theme assets: ${duplicatePaths.join(', ')}`)
|
|
199
|
+
|
|
200
|
+
const expectedThemeAssetPaths = new Set(EXPECTED_THEME_ASSETS.map(path => `theme/${path}`))
|
|
201
|
+
const themeAssetsWithContent = filesWithContent.filter(file =>
|
|
202
|
+
expectedThemeAssetPaths.has(file.relativePath),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
for (const { assetPath, occurrences } of findThemeAssetOccurrences(
|
|
206
|
+
themeAssetsWithContent,
|
|
207
|
+
filesWithContent,
|
|
208
|
+
)) {
|
|
209
|
+
if (occurrences.length !== 1) {
|
|
210
|
+
errors.push(
|
|
211
|
+
`${assetPath} должен встречаться в dist по содержимому ровно один раз; `
|
|
212
|
+
+ `найдено ${occurrences.length}: ${occurrences.join(', ')}`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const htmlContents = filesWithContent
|
|
218
|
+
.filter(file => file.relativePath.endsWith('.html'))
|
|
219
|
+
.map(file => ({
|
|
220
|
+
path: file.relativePath,
|
|
221
|
+
content: file.content.toString(),
|
|
222
|
+
}))
|
|
223
|
+
const rootFaviconReferences = htmlContents
|
|
224
|
+
.filter(file => /(?:href|src)=["']\/favicon\.svg(?:[?#][^"']*)?["']/.test(file.content))
|
|
225
|
+
.map(file => file.path)
|
|
226
|
+
|
|
227
|
+
if (rootFaviconReferences.length > 0)
|
|
228
|
+
errors.push(`HTML ссылается на /favicon.svg: ${rootFaviconReferences.join(', ')}`)
|
|
229
|
+
|
|
230
|
+
if (!htmlContents.some(file => file.content.includes('/theme/favicon.svg')))
|
|
231
|
+
errors.push('HTML не содержит ссылку на /theme/favicon.svg')
|
|
232
|
+
|
|
233
|
+
if (errors.length > 0) {
|
|
234
|
+
for (const error of errors)
|
|
235
|
+
console.error(`- ${error}`)
|
|
236
|
+
|
|
237
|
+
process.exit(1)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(
|
|
241
|
+
`Артефакт сборки: ${totalBytes} байт, ${files.length} файлов, `
|
|
242
|
+
+ `фото=${actualPhotoFiles.length}, декоров=${actualDecorFiles.length}`,
|
|
243
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import {
|
|
7
|
+
EXPECTED_THEME_ASSETS,
|
|
8
|
+
EXPECTED_THEME_DECORS,
|
|
9
|
+
EXPECTED_THEME_PHOTOS,
|
|
10
|
+
validateAssetDirectory,
|
|
11
|
+
} from './theme-asset-inventory.mjs'
|
|
12
|
+
import { getNpmSpawnConfig, normalizeSpawnFailure } from './npm-spawn.mjs'
|
|
13
|
+
|
|
14
|
+
const MAX_PACKAGE_SIZE = 10 * 1024 * 1024
|
|
15
|
+
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
16
|
+
const cacheDir = mkdtempSync(join(tmpdir(), 'slidev-theme-practicum-pack-'))
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const {
|
|
20
|
+
command: npmCommand,
|
|
21
|
+
args: npmArgs,
|
|
22
|
+
shell,
|
|
23
|
+
env,
|
|
24
|
+
} = getNpmSpawnConfig(process.platform, cacheDir)
|
|
25
|
+
const result = spawnSync(npmCommand, npmArgs, {
|
|
26
|
+
cwd: PROJECT_ROOT,
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
env,
|
|
29
|
+
shell,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (result.error || result.status !== 0) {
|
|
33
|
+
const failure = normalizeSpawnFailure(result)
|
|
34
|
+
process.stderr.write(failure.message.endsWith('\n') ? failure.message : `${failure.message}\n`)
|
|
35
|
+
process.exitCode = failure.status
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const [pack] = JSON.parse(result.stdout)
|
|
39
|
+
const files = new Set(pack.files.map(file => file.path))
|
|
40
|
+
const manifest = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8'))
|
|
41
|
+
const errors = []
|
|
42
|
+
const expectedPhotoFiles = EXPECTED_THEME_PHOTOS.map(file => `public/${file}`)
|
|
43
|
+
const expectedDecorFiles = EXPECTED_THEME_DECORS.map(file => `public/${file}`)
|
|
44
|
+
const expectedAssetFiles = EXPECTED_THEME_ASSETS.map(file => `public/${file}`)
|
|
45
|
+
const packedPhotoFiles = [...files].filter(file => file.startsWith('public/photos/'))
|
|
46
|
+
const packedDecorFiles = [...files].filter(file => file.startsWith('public/decor/'))
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await validateAssetDirectory(
|
|
50
|
+
join(PROJECT_ROOT, 'public/photos'),
|
|
51
|
+
EXPECTED_THEME_PHOTOS.map(file => file.slice('photos/'.length)),
|
|
52
|
+
)
|
|
53
|
+
await validateAssetDirectory(
|
|
54
|
+
join(PROJECT_ROOT, 'public/decor'),
|
|
55
|
+
EXPECTED_THEME_DECORS.map(file => file.slice('decor/'.length)),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
errors.push(error instanceof Error ? error.message : 'source inventory не прошёл проверку')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const file of expectedAssetFiles) {
|
|
63
|
+
if (!files.has(file))
|
|
64
|
+
errors.push(`package не содержит обязательный theme asset: ${file}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const file of packedPhotoFiles) {
|
|
68
|
+
if (!expectedPhotoFiles.includes(file))
|
|
69
|
+
errors.push(`package содержит неожиданный photo asset: ${file}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const file of packedDecorFiles) {
|
|
73
|
+
if (!expectedDecorFiles.includes(file))
|
|
74
|
+
errors.push(`package содержит неожиданный decor asset: ${file}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let number = 1; number <= 30; number++) {
|
|
78
|
+
const pngPath = `public/photos/photo-${number}.png`
|
|
79
|
+
if (files.has(pngPath))
|
|
80
|
+
errors.push(`package не должен содержать ${pngPath}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!files.has('LICENSE'))
|
|
84
|
+
errors.push('package должен содержать LICENSE')
|
|
85
|
+
|
|
86
|
+
if (!files.has('skills/slidev-practicum/SKILL.md'))
|
|
87
|
+
errors.push('package должен содержать skills/slidev-practicum/SKILL.md')
|
|
88
|
+
|
|
89
|
+
if (!files.has('example.md'))
|
|
90
|
+
errors.push('package должен содержать example.md как каноническую колоду для авторов')
|
|
91
|
+
|
|
92
|
+
const defaultDeckTargets = new Set(
|
|
93
|
+
Object.values(manifest.scripts ?? {})
|
|
94
|
+
.flatMap(script => String(script).match(/\b[\w-]+\.md\b/g) ?? []),
|
|
95
|
+
)
|
|
96
|
+
for (const target of defaultDeckTargets) {
|
|
97
|
+
if (!files.has(target))
|
|
98
|
+
errors.push(`package script ссылается на отсутствующий deck target: ${target}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const guidancePath of ['README.md', 'skills/slidev-practicum/SKILL.md']) {
|
|
102
|
+
const guidance = readFileSync(join(PROJECT_ROOT, guidancePath), 'utf8')
|
|
103
|
+
if (!guidance.includes('example.md'))
|
|
104
|
+
errors.push(`${guidancePath} должен ссылаться на канонический example.md`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (files.has('vite.config.mjs'))
|
|
108
|
+
errors.push('package не должен содержать локальный vite.config.mjs')
|
|
109
|
+
|
|
110
|
+
if (manifest.slidev?.defaults?.favicon !== '/theme/favicon.svg')
|
|
111
|
+
errors.push('package manifest должен задавать favicon как /theme/favicon.svg')
|
|
112
|
+
|
|
113
|
+
if (pack.size > MAX_PACKAGE_SIZE)
|
|
114
|
+
errors.push(`package ${pack.size} bytes превышает лимит ${MAX_PACKAGE_SIZE} bytes`)
|
|
115
|
+
|
|
116
|
+
if (errors.length > 0) {
|
|
117
|
+
for (const error of errors)
|
|
118
|
+
console.error(`- ${error}`)
|
|
119
|
+
|
|
120
|
+
process.exitCode = 1
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
console.log(
|
|
124
|
+
`Артефакт пакета: ${pack.size} байт, ${pack.files.length} файлов, `
|
|
125
|
+
+ `фото=${expectedPhotoFiles.length}, декоров=${expectedDecorFiles.length}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
rmSync(cacheDir, { recursive: true, force: true })
|
|
132
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import ts from 'typescript'
|
|
5
|
+
import { findDisallowedRawSourceReads } from './test-architecture-policy.mjs'
|
|
6
|
+
|
|
7
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
8
|
+
const testsDirectory = join(projectRoot, 'tests')
|
|
9
|
+
const testFiles = readdirSync(testsDirectory)
|
|
10
|
+
.filter(file => file.endsWith('.test.cjs'))
|
|
11
|
+
.sort()
|
|
12
|
+
|
|
13
|
+
function listHelperFiles(directory) {
|
|
14
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
15
|
+
.flatMap((entry) => {
|
|
16
|
+
const path = join(directory, entry.name)
|
|
17
|
+
|
|
18
|
+
if (entry.isDirectory())
|
|
19
|
+
return listHelperFiles(path)
|
|
20
|
+
return entry.isFile() && entry.name.endsWith('.cjs') ? [path] : []
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const helperFiles = listHelperFiles(join(testsDirectory, 'helpers')).sort()
|
|
25
|
+
|
|
26
|
+
function parseTestFile(relativeFile, source) {
|
|
27
|
+
return ts.createSourceFile(
|
|
28
|
+
relativeFile,
|
|
29
|
+
source,
|
|
30
|
+
ts.ScriptTarget.Latest,
|
|
31
|
+
true,
|
|
32
|
+
ts.ScriptKind.JS,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function countRegexLiterals(sourceFile) {
|
|
37
|
+
let count = 0
|
|
38
|
+
|
|
39
|
+
function visit(node) {
|
|
40
|
+
if (ts.isRegularExpressionLiteral(node))
|
|
41
|
+
count += 1
|
|
42
|
+
ts.forEachChild(node, visit)
|
|
43
|
+
}
|
|
44
|
+
visit(sourceFile)
|
|
45
|
+
|
|
46
|
+
return count
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function analyseReferences(sourceFile) {
|
|
50
|
+
const calledMethods = new Set()
|
|
51
|
+
const identifiers = new Set()
|
|
52
|
+
const imports = new Set()
|
|
53
|
+
|
|
54
|
+
function visit(node) {
|
|
55
|
+
if (ts.isIdentifier(node))
|
|
56
|
+
identifiers.add(node.text)
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
ts.isCallExpression(node)
|
|
60
|
+
&& ts.isIdentifier(node.expression)
|
|
61
|
+
&& node.expression.text === 'require'
|
|
62
|
+
&& node.arguments.length === 1
|
|
63
|
+
&& ts.isStringLiteral(node.arguments[0])
|
|
64
|
+
) {
|
|
65
|
+
imports.add(node.arguments[0].text)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
ts.isCallExpression(node)
|
|
70
|
+
&& ts.isPropertyAccessExpression(node.expression)
|
|
71
|
+
) {
|
|
72
|
+
calledMethods.add(node.expression.name.text)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ts.forEachChild(node, visit)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
visit(sourceFile)
|
|
79
|
+
return { calledMethods, identifiers, imports }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const measurements = testFiles.map((file) => {
|
|
83
|
+
const absoluteFile = join(testsDirectory, file)
|
|
84
|
+
const source = readFileSync(absoluteFile, 'utf8')
|
|
85
|
+
const sourceFile = parseTestFile(relative(projectRoot, absoluteFile), source)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
...analyseReferences(sourceFile),
|
|
89
|
+
file,
|
|
90
|
+
lines: source.split('\n').length,
|
|
91
|
+
regexLiterals: countRegexLiterals(sourceFile),
|
|
92
|
+
disallowedRawReads: file === 'text-fit-contract.test.cjs'
|
|
93
|
+
? findDisallowedRawSourceReads(source, file)
|
|
94
|
+
: [],
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
const helperMeasurements = helperFiles.map((absoluteFile) => {
|
|
98
|
+
const source = readFileSync(absoluteFile, 'utf8')
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
file: relative(testsDirectory, absoluteFile),
|
|
102
|
+
lines: source.split('\n').length,
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
const lineMeasurements = [...measurements, ...helperMeasurements]
|
|
106
|
+
const totalRegexLiterals = measurements.reduce((total, measurement) => total + measurement.regexLiterals, 0)
|
|
107
|
+
const foundationRegexLiterals = measurements
|
|
108
|
+
.filter(({ file }) => file.startsWith('foundation-') || file === 'layout-public-api.test.cjs')
|
|
109
|
+
.reduce((total, measurement) => total + measurement.regexLiterals, 0)
|
|
110
|
+
const longest = lineMeasurements.reduce(
|
|
111
|
+
(current, measurement) => measurement.lines > current.lines ? measurement : current,
|
|
112
|
+
{ file: '', lines: 0 },
|
|
113
|
+
)
|
|
114
|
+
const failures = []
|
|
115
|
+
|
|
116
|
+
for (const measurement of lineMeasurements) {
|
|
117
|
+
if (measurement.lines > 500)
|
|
118
|
+
failures.push(`${measurement.file}: ${measurement.lines} строк (максимум 500)`)
|
|
119
|
+
}
|
|
120
|
+
for (const measurement of measurements) {
|
|
121
|
+
for (const rawRead of measurement.disallowedRawReads)
|
|
122
|
+
failures.push(`${measurement.file}: запрещено читать исходник .vue/.ts как обычный текст (${rawRead})`)
|
|
123
|
+
}
|
|
124
|
+
if (totalRegexLiterals > 250)
|
|
125
|
+
failures.push(`tests/*.test.cjs: ${totalRegexLiterals} литералов регулярных выражений (максимум 250)`)
|
|
126
|
+
if (foundationRegexLiterals > 70)
|
|
127
|
+
failures.push(`foundation/layout: ${foundationRegexLiterals} литералов регулярных выражений (максимум 70)`)
|
|
128
|
+
|
|
129
|
+
const textFitFixtureImport = './helpers/text-fit-runtime-fixtures.cjs'
|
|
130
|
+
const textFitResponsibilitySuites = measurements.filter(measurement =>
|
|
131
|
+
measurement.imports.has(textFitFixtureImport),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if (!textFitResponsibilitySuites.length)
|
|
135
|
+
failures.push(`наборы тестов ответственности text-fit должны импортировать ${textFitFixtureImport}`)
|
|
136
|
+
|
|
137
|
+
const coveredEntrypoints = new Set()
|
|
138
|
+
for (const suite of textFitResponsibilitySuites) {
|
|
139
|
+
const callsElement = suite.calledMethods.has('useElement')
|
|
140
|
+
const callsGroup = suite.calledMethods.has('useGroup')
|
|
141
|
+
const elementOnlySuite = /-element(?:[.-])/.test(suite.file)
|
|
142
|
+
const groupOnlySuite = /-group(?:[.-])/.test(suite.file)
|
|
143
|
+
const browserSuite = /-browser(?:[.-])/.test(suite.file)
|
|
144
|
+
|
|
145
|
+
if (callsElement)
|
|
146
|
+
coveredEntrypoints.add('useElement')
|
|
147
|
+
if (callsGroup)
|
|
148
|
+
coveredEntrypoints.add('useGroup')
|
|
149
|
+
if (elementOnlySuite && (callsGroup || suite.identifiers.has('handleType')))
|
|
150
|
+
failures.push(`${suite.file} смешивает тесты useElement с координацией групп`)
|
|
151
|
+
if (groupOnlySuite && (callsElement || suite.identifiers.has('handleType')))
|
|
152
|
+
failures.push(`${suite.file} смешивает тесты useGroup с жизненным циклом элемента`)
|
|
153
|
+
if (callsElement && callsGroup && !browserSuite)
|
|
154
|
+
failures.push(`${suite.file} смешивает useElement/useGroup вне браузерной ответственности`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const entrypoint of ['useElement', 'useGroup']) {
|
|
158
|
+
if (!coveredEntrypoints.has(entrypoint))
|
|
159
|
+
failures.push(`наборы тестов ответственности text-fit не покрывают ${entrypoint}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(
|
|
163
|
+
`Архитектура тестов: ${measurements.length} тестовых файлов и `
|
|
164
|
+
+ `${helperMeasurements.length} вспомогательных файлов; максимум ${longest.lines} строк (${longest.file}).`,
|
|
165
|
+
)
|
|
166
|
+
console.log(
|
|
167
|
+
`Регулярные выражения: ${totalRegexLiterals}/250 всего; `
|
|
168
|
+
+ `${foundationRegexLiterals}/70 в foundation/layout.`,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if (failures.length) {
|
|
172
|
+
console.error('Нарушения архитектуры тестов:')
|
|
173
|
+
for (const failure of failures)
|
|
174
|
+
console.error(`- ${failure}`)
|
|
175
|
+
process.exitCode = 1
|
|
176
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
export function getNpmSpawnConfig(platform, cacheDir, environment = process.env) {
|
|
4
|
+
const isWindows = platform === 'win32'
|
|
5
|
+
const env = Object.fromEntries(
|
|
6
|
+
Object.entries(environment).filter(([key]) => key.toLowerCase() !== 'npm_config_cache'),
|
|
7
|
+
)
|
|
8
|
+
env.npm_config_cache = cacheDir
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
command: isWindows ? 'npm.cmd' : 'npm',
|
|
12
|
+
args: ['pack', '--dry-run', '--json'],
|
|
13
|
+
shell: isWindows,
|
|
14
|
+
env,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeSpawnFailure(result) {
|
|
19
|
+
const errorMessage = result.error instanceof Error ? result.error.message : ''
|
|
20
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr : ''
|
|
21
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : ''
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
message:
|
|
25
|
+
errorMessage
|
|
26
|
+
|| stderr
|
|
27
|
+
|| stdout
|
|
28
|
+
|| 'npm pack завершился без диагностического вывода',
|
|
29
|
+
status: typeof result.status === 'number' ? result.status : 1,
|
|
30
|
+
}
|
|
31
|
+
}
|