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,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
+ }