@vibecuting/video-project-core 0.1.41 → 0.1.43
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/bin/vibecuting-lint-scenes.mjs +61 -0
- package/package.json +6 -5
- package/src/lint/index.ts +5 -1
- package/src/lint/scene-convention-lint-runtime.d.mts +23 -0
- package/src/lint/scene-convention-lint-runtime.mjs +280 -0
- package/src/lint/scene-convention-lint.test.ts +54 -30
- package/src/lint/scene-convention-lint.ts +6 -247
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import { lintSceneConvention } from '../src/lint/scene-convention-lint-runtime.mjs'
|
|
6
|
+
|
|
7
|
+
function readArgValue(argv, flag) {
|
|
8
|
+
const index = argv.indexOf(flag)
|
|
9
|
+
return index >= 0 ? argv[index + 1] : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildSourceRoots(profile) {
|
|
13
|
+
if (profile === 'video-project-core') {
|
|
14
|
+
return [
|
|
15
|
+
{ sourceRoot: 'src/core/intro', titleRoot: 'core/intro', sharedFolderName: '_shared' },
|
|
16
|
+
{ sourceRoot: 'src/core/outro', titleRoot: 'core/outro', sharedFolderName: '_shared' },
|
|
17
|
+
{ sourceRoot: 'src/core/scene', titleRoot: 'core/scene', sharedFolderName: '_shared' },
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (profile === 'component-project') {
|
|
22
|
+
return [
|
|
23
|
+
{ sourceRoot: 'src/components', titleRoot: 'components', sharedFolderName: '_shared' },
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
throw new Error(`Unknown scene lint profile: ${profile}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function main() {
|
|
31
|
+
const argv = process.argv.slice(2)
|
|
32
|
+
const fix = argv.includes('--fix')
|
|
33
|
+
const all = argv.includes('--all')
|
|
34
|
+
|
|
35
|
+
if (!all) {
|
|
36
|
+
process.exit(0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const profile = readArgValue(argv, '--profile') ?? 'component-project'
|
|
40
|
+
const rootDir = readArgValue(argv, '--root') ?? process.cwd()
|
|
41
|
+
|
|
42
|
+
const result = lintSceneConvention({
|
|
43
|
+
rootDir,
|
|
44
|
+
sourceRoots: buildSourceRoots(profile),
|
|
45
|
+
fix,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
if (result.errors.length > 0) {
|
|
49
|
+
console.error('Scene convention lint failed:')
|
|
50
|
+
for (const error of result.errors) {
|
|
51
|
+
console.error(`- ${error}`)
|
|
52
|
+
}
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(
|
|
57
|
+
`Scene convention lint passed for ${result.checkedSceneFolders.length} scene folder(s), ${result.checkedStoryFiles.length} story file(s).`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecuting/video-project-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./storybook": "./src/storybook/index.ts",
|
|
11
11
|
"./storybook/remotion-preview": "./src/storybook/remotion-preview.tsx",
|
|
12
|
-
"./lint": "./src/lint/index.ts"
|
|
12
|
+
"./lint": "./src/lint/index.ts",
|
|
13
|
+
"./lint/runtime": "./src/lint/scene-convention-lint-runtime.mjs"
|
|
13
14
|
},
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"registry": "https://registry.npmjs.org",
|
|
@@ -19,13 +20,13 @@
|
|
|
19
20
|
"dev": "storybook dev -p 6010 --host 0.0.0.0",
|
|
20
21
|
"preview": "storybook dev -p 6010 --host 0.0.0.0",
|
|
21
22
|
"build:storybook": "storybook build",
|
|
22
|
-
"lint": "node ../../../scripts/run-eslint.mjs src .storybook && node
|
|
23
|
-
"lint:fix": "node ../../../scripts/run-eslint.mjs src .storybook --fix && node
|
|
23
|
+
"lint": "node ../../../scripts/run-eslint.mjs src .storybook && node bin/vibecuting-lint-scenes.mjs --profile video-project-core --all",
|
|
24
|
+
"lint:fix": "node ../../../scripts/run-eslint.mjs src .storybook --fix && node bin/vibecuting-lint-scenes.mjs --profile video-project-core --all --fix",
|
|
24
25
|
"typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
|
|
25
26
|
"test": "pnpm exec vitest run"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@vibecuting/component-project-helper": "0.1.
|
|
29
|
+
"@vibecuting/component-project-helper": "0.1.43",
|
|
29
30
|
"@remotion/transitions": "4.0.473",
|
|
30
31
|
"mermaid": "^11.12.2",
|
|
31
32
|
"react": "^19.0.0",
|
package/src/lint/index.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export { lintSceneConvention } from './scene-convention-lint'
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
SceneConventionLintOptions,
|
|
4
|
+
SceneConventionLintResult,
|
|
5
|
+
SceneConventionSourceRoot,
|
|
6
|
+
} from './scene-convention-lint'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SceneConventionSourceRoot = {
|
|
2
|
+
sourceRoot: string
|
|
3
|
+
titleRoot: string
|
|
4
|
+
sharedFolderName?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type SceneConventionLintOptions = {
|
|
8
|
+
rootDir: string
|
|
9
|
+
sourceRoot?: string
|
|
10
|
+
titleRoot?: string
|
|
11
|
+
checkedFolderPrefix?: string
|
|
12
|
+
sharedFolderName?: string
|
|
13
|
+
sourceRoots?: SceneConventionSourceRoot[]
|
|
14
|
+
fix?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SceneConventionLintResult = {
|
|
18
|
+
checkedSceneFolders: string[]
|
|
19
|
+
checkedStoryFiles: string[]
|
|
20
|
+
errors: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export declare function lintSceneConvention(options: SceneConventionLintOptions): SceneConventionLintResult
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const toPosix = (value) => value.split(path.sep).join('/')
|
|
5
|
+
|
|
6
|
+
function toRootRelative(rootDir, file) {
|
|
7
|
+
if (path.isAbsolute(file)) {
|
|
8
|
+
return toPosix(path.relative(rootDir, file))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return toPosix(file).replace(/^\.\//, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveRootPath(rootDir, file) {
|
|
15
|
+
return path.resolve(rootDir, toRootRelative(rootDir, file))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readText(rootDir, file) {
|
|
19
|
+
return fs.readFileSync(resolveRootPath(rootDir, file), 'utf8')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeText(rootDir, file, text) {
|
|
23
|
+
fs.writeFileSync(resolveRootPath(rootDir, file), text)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readStoryTitle(text) {
|
|
27
|
+
const titleMatch = text.match(/const\s+meta\s*=\s*{[\s\S]*?title:\s*['"`]([^'"`]+)['"`]/m)
|
|
28
|
+
return titleMatch?.[1] ?? ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function replaceStoryTitlePrefix(text, expectedPrefix) {
|
|
32
|
+
return text.replace(
|
|
33
|
+
/(const\s+meta\s*=\s*{[\s\S]*?title:\s*['"`])([^'"`]+)(['"`])/m,
|
|
34
|
+
(_match, before, currentTitle, after) => {
|
|
35
|
+
const titleLabel = currentTitle.includes('/') ? currentTitle.split('/').filter(Boolean).at(-1) : currentTitle
|
|
36
|
+
return `${before}${expectedPrefix}${titleLabel}${after}`
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectFiles(rootDir, dir, suffix) {
|
|
42
|
+
const absoluteDir = resolveRootPath(rootDir, dir)
|
|
43
|
+
const results = []
|
|
44
|
+
const stack = [absoluteDir]
|
|
45
|
+
|
|
46
|
+
while (stack.length > 0) {
|
|
47
|
+
const current = stack.pop()
|
|
48
|
+
if (!current || !fs.existsSync(current)) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
53
|
+
const fullPath = path.join(current, entry.name)
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
stack.push(fullPath)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
60
|
+
results.push(toRootRelative(rootDir, fullPath))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return results.sort()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectSceneFolders(rootDir, sourceRoot, sharedFolderName = '_shared', checkedFolderPrefix) {
|
|
69
|
+
const sourceRootPath = resolveRootPath(rootDir, sourceRoot)
|
|
70
|
+
const results = []
|
|
71
|
+
const stack = [sourceRootPath]
|
|
72
|
+
|
|
73
|
+
while (stack.length > 0) {
|
|
74
|
+
const current = stack.pop()
|
|
75
|
+
if (!current || !fs.existsSync(current)) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const entries = fs.readdirSync(current, { withFileTypes: true })
|
|
80
|
+
const slug = path.basename(current)
|
|
81
|
+
const relative = toRootRelative(rootDir, current)
|
|
82
|
+
const hasBaseScene = entries.some((entry) => entry.isFile() && entry.name === `${slug}.tsx`)
|
|
83
|
+
const isSharedFolder = relative.includes(`/${sharedFolderName}/`)
|
|
84
|
+
const matchesPrefix = !checkedFolderPrefix || slug.startsWith(checkedFolderPrefix)
|
|
85
|
+
|
|
86
|
+
if (hasBaseScene && !isSharedFolder && matchesPrefix) {
|
|
87
|
+
results.push(relative)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.isDirectory() && entry.name !== sharedFolderName) {
|
|
93
|
+
stack.push(path.join(current, entry.name))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results.sort()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function lintSceneFolder(rootDir, folder) {
|
|
102
|
+
const slug = path.posix.basename(folder)
|
|
103
|
+
const errors = []
|
|
104
|
+
const requiredFiles = [
|
|
105
|
+
`${slug}.tsx`,
|
|
106
|
+
`${slug}-Landscape.tsx`,
|
|
107
|
+
`${slug}-Horizontal.tsx`,
|
|
108
|
+
`${slug}.stories.tsx`,
|
|
109
|
+
`${slug}.animated.stories.tsx`,
|
|
110
|
+
`${slug}-Horizontal.stories.tsx`,
|
|
111
|
+
`${slug}-Horizontal.animated.stories.tsx`,
|
|
112
|
+
`${slug}.test.tsx`,
|
|
113
|
+
'index.ts',
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
for (const fileName of requiredFiles) {
|
|
117
|
+
const requiredPath = `${folder}/${fileName}`
|
|
118
|
+
if (!fs.existsSync(resolveRootPath(rootDir, requiredPath))) {
|
|
119
|
+
errors.push(`${folder}: missing ${fileName}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const filesToCheck = [
|
|
124
|
+
`${folder}/${slug}.tsx`,
|
|
125
|
+
`${folder}/${slug}-Landscape.tsx`,
|
|
126
|
+
`${folder}/${slug}-Horizontal.tsx`,
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
for (const file of filesToCheck) {
|
|
130
|
+
if (!fs.existsSync(resolveRootPath(rootDir, file))) {
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const text = readText(rootDir, file)
|
|
135
|
+
const sourceFileMatch = text.match(/sourceFile:\s*['"`]([^'"`]+)['"`]/)
|
|
136
|
+
if (!sourceFileMatch) {
|
|
137
|
+
errors.push(`${file}: missing componentMetadata.sourceFile`)
|
|
138
|
+
} else if (sourceFileMatch[1] !== file) {
|
|
139
|
+
errors.push(`${file}: sourceFile must be "${file}", got "${sourceFileMatch[1]}"`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const baseFile = `${folder}/${slug}.tsx`
|
|
144
|
+
if (fs.existsSync(resolveRootPath(rootDir, baseFile))) {
|
|
145
|
+
const text = readText(rootDir, baseFile)
|
|
146
|
+
if (!/defineSceneComponent\s*\(/.test(text)) {
|
|
147
|
+
errors.push(`${baseFile}: base scene must use defineSceneComponent(...)`)
|
|
148
|
+
}
|
|
149
|
+
if (!/propsSchema:\s*z\.object\s*\(/.test(text)) {
|
|
150
|
+
errors.push(`${baseFile}: base scene must colocate propsSchema with z.object(...)`)
|
|
151
|
+
}
|
|
152
|
+
if (!/useRemotionSceneRuntime\s*\(/.test(text)) {
|
|
153
|
+
errors.push(`${baseFile}: base scene must use useRemotionSceneRuntime()`)
|
|
154
|
+
}
|
|
155
|
+
if (!/SceneMotionLayer|SceneMotionStack|useSceneMotion/.test(text)) {
|
|
156
|
+
errors.push(`${baseFile}: scene animation must use SceneMotionLayer, SceneMotionStack, or useSceneMotion`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const landscapeFile = `${folder}/${slug}-Landscape.tsx`
|
|
161
|
+
if (
|
|
162
|
+
fs.existsSync(resolveRootPath(rootDir, landscapeFile)) &&
|
|
163
|
+
!/aspectRatio:\s*['"]16:9['"]/.test(readText(rootDir, landscapeFile))
|
|
164
|
+
) {
|
|
165
|
+
errors.push(`${landscapeFile}: Landscape variant must set aspectRatio: '16:9'`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const horizontalFile = `${folder}/${slug}-Horizontal.tsx`
|
|
169
|
+
if (
|
|
170
|
+
fs.existsSync(resolveRootPath(rootDir, horizontalFile)) &&
|
|
171
|
+
!/aspectRatio:\s*['"]9:16['"]/.test(readText(rootDir, horizontalFile))
|
|
172
|
+
) {
|
|
173
|
+
errors.push(`${horizontalFile}: Horizontal variant must set aspectRatio: '9:16'`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return errors
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function lintStoryFile(rootDir, sourceRoot, titleRoot, storyFile, fix = false) {
|
|
180
|
+
const normalized = toRootRelative(rootDir, storyFile)
|
|
181
|
+
const text = readText(rootDir, normalized)
|
|
182
|
+
const errors = []
|
|
183
|
+
const storyDir = path.posix.dirname(normalized.slice(`${sourceRoot}/`.length))
|
|
184
|
+
const expectedPrefix = `${titleRoot}/${storyDir}/`
|
|
185
|
+
const title = readStoryTitle(text)
|
|
186
|
+
const isAnimatedStory = normalized.endsWith('.animated.stories.tsx')
|
|
187
|
+
const isHorizontalStory = /-Horizontal(\.animated)?\.stories\.tsx$/.test(normalized)
|
|
188
|
+
const isLandscapeStory = !isHorizontalStory
|
|
189
|
+
|
|
190
|
+
if (!title.startsWith(expectedPrefix)) {
|
|
191
|
+
if (fix) {
|
|
192
|
+
writeText(rootDir, normalized, replaceStoryTitlePrefix(text, expectedPrefix))
|
|
193
|
+
} else {
|
|
194
|
+
errors.push(`${normalized}: story title must start with "${expectedPrefix}"`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isAnimatedStory && !/scenePreviewMode:\s*['"]animation['"]/.test(text)) {
|
|
199
|
+
errors.push(`${normalized}: animated stories must set scenePreviewMode: 'animation'`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (isLandscapeStory && !/scenePreviewAspectRatio:\s*['"]16:9['"]/.test(text)) {
|
|
203
|
+
errors.push(`${normalized}: landscape stories must set scenePreviewAspectRatio: '16:9'`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (isHorizontalStory && !/scenePreviewAspectRatio:\s*['"]9:16['"]/.test(text)) {
|
|
207
|
+
errors.push(`${normalized}: horizontal stories must set scenePreviewAspectRatio: '9:16'`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return errors
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function lintSharedFolder(rootDir, sourceRoot, sharedFolderName = '_shared') {
|
|
214
|
+
const sharedRoot = `${sourceRoot}/${sharedFolderName}`
|
|
215
|
+
const sharedRootPath = resolveRootPath(rootDir, sharedRoot)
|
|
216
|
+
const errors = []
|
|
217
|
+
|
|
218
|
+
if (!fs.existsSync(sharedRootPath)) {
|
|
219
|
+
return errors
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const file of collectFiles(rootDir, sharedRoot, '.tsx')) {
|
|
223
|
+
const text = readText(rootDir, file)
|
|
224
|
+
if (/componentMetadata|defineComponentProjectComponentMetadata|defineScenePluginMetadata/.test(text)) {
|
|
225
|
+
errors.push(`${file}: shared files must not declare publishable component metadata`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return errors
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeSourceRoots(options) {
|
|
233
|
+
if (Array.isArray(options.sourceRoots) && options.sourceRoots.length > 0) {
|
|
234
|
+
return options.sourceRoots
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (options.sourceRoot && options.titleRoot) {
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
sourceRoot: options.sourceRoot,
|
|
241
|
+
titleRoot: options.titleRoot,
|
|
242
|
+
sharedFolderName: options.sharedFolderName,
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return []
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function lintSceneConvention(options) {
|
|
251
|
+
const sourceRoots = normalizeSourceRoots(options)
|
|
252
|
+
const checkedSceneFolders = []
|
|
253
|
+
const checkedStoryFiles = []
|
|
254
|
+
const errors = []
|
|
255
|
+
|
|
256
|
+
for (const rootConfig of sourceRoots) {
|
|
257
|
+
const sharedFolderName = rootConfig.sharedFolderName ?? '_shared'
|
|
258
|
+
const sceneFolders = collectSceneFolders(
|
|
259
|
+
options.rootDir,
|
|
260
|
+
rootConfig.sourceRoot,
|
|
261
|
+
sharedFolderName,
|
|
262
|
+
options.checkedFolderPrefix,
|
|
263
|
+
)
|
|
264
|
+
const storyFiles = collectFiles(options.rootDir, rootConfig.sourceRoot, '.stories.tsx').filter(
|
|
265
|
+
(file) => !file.includes(`/${sharedFolderName}/`),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
checkedSceneFolders.push(...sceneFolders)
|
|
269
|
+
checkedStoryFiles.push(...storyFiles)
|
|
270
|
+
errors.push(...sceneFolders.flatMap((folder) => lintSceneFolder(options.rootDir, folder)))
|
|
271
|
+
errors.push(...storyFiles.flatMap((file) => lintStoryFile(options.rootDir, rootConfig.sourceRoot, rootConfig.titleRoot, file, options.fix)))
|
|
272
|
+
errors.push(...lintSharedFolder(options.rootDir, rootConfig.sourceRoot, sharedFolderName))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
checkedSceneFolders: checkedSceneFolders.sort(),
|
|
277
|
+
checkedStoryFiles: checkedStoryFiles.sort(),
|
|
278
|
+
errors,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -6,79 +6,103 @@ import { describe, expect, it } from 'vitest'
|
|
|
6
6
|
|
|
7
7
|
import { lintSceneConvention } from './scene-convention-lint'
|
|
8
8
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
function sceneNameFromSlug(slug: string): string {
|
|
10
|
+
return slug
|
|
11
|
+
.split(/[-/]/)
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
|
|
14
|
+
.join('')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeSceneFixture(rootDir: string, relativeFolder: string, sourceRoot: string, titleRoot: string) {
|
|
18
|
+
const folder = path.join(rootDir, relativeFolder)
|
|
19
|
+
const slug = path.basename(relativeFolder)
|
|
20
|
+
const sharedFolder = path.join(rootDir, sourceRoot, '_shared')
|
|
12
21
|
fs.mkdirSync(folder, { recursive: true })
|
|
13
22
|
fs.mkdirSync(sharedFolder, { recursive: true })
|
|
14
23
|
|
|
15
24
|
fs.writeFileSync(
|
|
16
|
-
path.join(folder,
|
|
25
|
+
path.join(folder, `${slug}.tsx`),
|
|
17
26
|
[
|
|
18
27
|
"import { z } from 'zod'",
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
`export const componentMetadata = { sourceFile: '${relativeFolder}/${slug}.tsx' }`,
|
|
29
|
+
`export const ${sceneNameFromSlug(slug)} = defineSceneComponent({ propsSchema: z.object({ title: z.string() }), component: function ${sceneNameFromSlug(slug)}() { useRemotionSceneRuntime(); return <SceneMotionLayer /> } })`,
|
|
21
30
|
'',
|
|
22
31
|
].join('\n'),
|
|
23
32
|
)
|
|
24
33
|
fs.writeFileSync(
|
|
25
|
-
path.join(folder,
|
|
26
|
-
|
|
34
|
+
path.join(folder, `${slug}-Landscape.tsx`),
|
|
35
|
+
`export const componentMetadata = { sourceFile: '${relativeFolder}/${slug}-Landscape.tsx', aspectRatio: '16:9' }\n`,
|
|
27
36
|
)
|
|
28
37
|
fs.writeFileSync(
|
|
29
|
-
path.join(folder,
|
|
30
|
-
|
|
38
|
+
path.join(folder, `${slug}-Horizontal.tsx`),
|
|
39
|
+
`export const componentMetadata = { sourceFile: '${relativeFolder}/${slug}-Horizontal.tsx', aspectRatio: '9:16' }\n`,
|
|
31
40
|
)
|
|
32
41
|
fs.writeFileSync(
|
|
33
|
-
path.join(folder,
|
|
34
|
-
|
|
42
|
+
path.join(folder, `${slug}.stories.tsx`),
|
|
43
|
+
`const meta = { title: '${titleRoot}/${relativeFolder.replace(`${sourceRoot}/`, '')}/SceneLandscape' }\nexport default meta\nexport const Default = { parameters: { scenePreviewAspectRatio: '16:9' } }\n`,
|
|
35
44
|
)
|
|
36
45
|
fs.writeFileSync(
|
|
37
|
-
path.join(folder,
|
|
38
|
-
|
|
46
|
+
path.join(folder, `${slug}.animated.stories.tsx`),
|
|
47
|
+
`const meta = { title: '${titleRoot}/${relativeFolder.replace(`${sourceRoot}/`, '')}/SceneLandscape' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '16:9', scenePreviewMode: 'animation' } }\n`,
|
|
39
48
|
)
|
|
40
49
|
fs.writeFileSync(
|
|
41
|
-
path.join(folder,
|
|
42
|
-
|
|
50
|
+
path.join(folder, `${slug}-Horizontal.stories.tsx`),
|
|
51
|
+
`const meta = { title: '${titleRoot}/${relativeFolder.replace(`${sourceRoot}/`, '')}/SceneHorizontal' }\nexport default meta\nexport const Default = { parameters: { scenePreviewAspectRatio: '9:16' } }\n`,
|
|
43
52
|
)
|
|
44
53
|
fs.writeFileSync(
|
|
45
|
-
path.join(folder,
|
|
46
|
-
|
|
54
|
+
path.join(folder, `${slug}-Horizontal.animated.stories.tsx`),
|
|
55
|
+
`const meta = { title: '${titleRoot}/${relativeFolder.replace(`${sourceRoot}/`, '')}/SceneHorizontal' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '9:16', scenePreviewMode: 'animation' } }\n`,
|
|
47
56
|
)
|
|
48
|
-
fs.writeFileSync(path.join(folder,
|
|
49
|
-
fs.writeFileSync(path.join(folder, 'index.ts'),
|
|
57
|
+
fs.writeFileSync(path.join(folder, `${slug}.test.tsx`), "import test from 'node:test'\ntest('ok', () => {})\n")
|
|
58
|
+
fs.writeFileSync(path.join(folder, 'index.ts'), `export * from './${slug}'\n`)
|
|
50
59
|
fs.writeFileSync(path.join(sharedFolder, 'scene-frame.tsx'), 'export function SceneFrame() { return null }\n')
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
describe('scene convention lint', () => {
|
|
54
|
-
it('passes on the expected folder structure', () => {
|
|
63
|
+
it('passes on the expected component folder structure', () => {
|
|
55
64
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scene-convention-lint-'))
|
|
56
|
-
writeSceneFixture(rootDir)
|
|
65
|
+
writeSceneFixture(rootDir, 'src/components/default-component-scene', 'src/components', 'components')
|
|
57
66
|
|
|
58
67
|
const result = lintSceneConvention({
|
|
59
68
|
rootDir,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
sourceRoots: [
|
|
70
|
+
{ sourceRoot: 'src/components', titleRoot: 'components', sharedFolderName: '_shared' },
|
|
71
|
+
],
|
|
63
72
|
})
|
|
64
73
|
|
|
65
74
|
expect(result.errors).toEqual([])
|
|
66
75
|
expect(result.checkedSceneFolders).toContain('src/components/default-component-scene')
|
|
67
76
|
})
|
|
68
77
|
|
|
78
|
+
it('finds nested core scene folders recursively', () => {
|
|
79
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scene-convention-lint-nested-'))
|
|
80
|
+
writeSceneFixture(rootDir, 'src/core/scene/slide/gallery/default-gallery-slide', 'src/core/scene', 'core/scene')
|
|
81
|
+
|
|
82
|
+
const result = lintSceneConvention({
|
|
83
|
+
rootDir,
|
|
84
|
+
sourceRoots: [
|
|
85
|
+
{ sourceRoot: 'src/core/scene', titleRoot: 'core/scene', sharedFolderName: '_shared' },
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(result.errors).toEqual([])
|
|
90
|
+
expect(result.checkedSceneFolders).toContain('src/core/scene/slide/gallery/default-gallery-slide')
|
|
91
|
+
})
|
|
92
|
+
|
|
69
93
|
it('reports missing animation mode', () => {
|
|
70
94
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scene-convention-lint-missing-animation-'))
|
|
71
|
-
writeSceneFixture(rootDir)
|
|
95
|
+
writeSceneFixture(rootDir, 'src/components/default-component-scene', 'src/components', 'components')
|
|
72
96
|
fs.writeFileSync(
|
|
73
97
|
path.join(rootDir, 'src/components/default-component-scene/default-component-scene.animated.stories.tsx'),
|
|
74
|
-
"const meta = { title: 'components/default-component-scene/
|
|
98
|
+
"const meta = { title: 'components/default-component-scene/SceneLandscape' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '16:9' } }\n",
|
|
75
99
|
)
|
|
76
100
|
|
|
77
101
|
const result = lintSceneConvention({
|
|
78
102
|
rootDir,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
sourceRoots: [
|
|
104
|
+
{ sourceRoot: 'src/components', titleRoot: 'components', sharedFolderName: '_shared' },
|
|
105
|
+
],
|
|
82
106
|
})
|
|
83
107
|
|
|
84
108
|
expect(result.errors.some((error) => error.includes("scenePreviewMode: 'animation'"))).toBe(true)
|
|
@@ -1,247 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
titleRoot: string
|
|
8
|
-
checkedFolderPrefix?: string
|
|
9
|
-
sharedFolderName?: string
|
|
10
|
-
fix?: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export type SceneConventionLintResult = {
|
|
14
|
-
checkedSceneFolders: string[]
|
|
15
|
-
checkedStoryFiles: string[]
|
|
16
|
-
errors: string[]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const toPosix = (value: string) => value.split(path.sep).join('/')
|
|
20
|
-
|
|
21
|
-
function toRootRelative(rootDir: string, file: string) {
|
|
22
|
-
if (path.isAbsolute(file)) {
|
|
23
|
-
return toPosix(path.relative(rootDir, file))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return toPosix(file).replace(/^\.\//, '')
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function resolveRootPath(rootDir: string, file: string) {
|
|
30
|
-
return path.resolve(rootDir, toRootRelative(rootDir, file))
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function readText(rootDir: string, file: string) {
|
|
34
|
-
return fs.readFileSync(resolveRootPath(rootDir, file), 'utf8')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function writeText(rootDir: string, file: string, text: string) {
|
|
38
|
-
fs.writeFileSync(resolveRootPath(rootDir, file), text)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function readStoryTitle(text: string) {
|
|
42
|
-
const titleMatch = text.match(/const\s+meta\s*=\s*{[\s\S]*?title:\s*['"`]([^'"`]+)['"`]/m)
|
|
43
|
-
return titleMatch?.[1] ?? ''
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function replaceStoryTitlePrefix(text: string, expectedPrefix: string) {
|
|
47
|
-
return text.replace(
|
|
48
|
-
/(const\s+meta\s*=\s*{[\s\S]*?title:\s*['"`])([^'"`]+)(['"`])/m,
|
|
49
|
-
(_match, before: string, currentTitle: string, after: string) => {
|
|
50
|
-
const titleLabel = currentTitle.includes('/') ? currentTitle.split('/').filter(Boolean).at(-1) : currentTitle
|
|
51
|
-
return `${before}${expectedPrefix}${titleLabel}${after}`
|
|
52
|
-
},
|
|
53
|
-
)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function collectFiles(rootDir: string, dir: string, suffix: string) {
|
|
57
|
-
const absoluteDir = resolveRootPath(rootDir, dir)
|
|
58
|
-
const results: string[] = []
|
|
59
|
-
const stack = [absoluteDir]
|
|
60
|
-
|
|
61
|
-
while (stack.length > 0) {
|
|
62
|
-
const current = stack.pop()
|
|
63
|
-
if (!current || !fs.existsSync(current)) {
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
68
|
-
const fullPath = path.join(current, entry.name)
|
|
69
|
-
if (entry.isDirectory()) {
|
|
70
|
-
stack.push(fullPath)
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
74
|
-
results.push(toRootRelative(rootDir, fullPath))
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return results.sort()
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function collectSceneFolders(options: SceneConventionLintOptions) {
|
|
83
|
-
const sourceRootPath = resolveRootPath(options.rootDir, options.sourceRoot)
|
|
84
|
-
const sharedFolderName = options.sharedFolderName ?? '_shared'
|
|
85
|
-
|
|
86
|
-
if (!fs.existsSync(sourceRootPath)) {
|
|
87
|
-
return []
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return fs
|
|
91
|
-
.readdirSync(sourceRootPath, { withFileTypes: true })
|
|
92
|
-
.filter((entry) => entry.isDirectory())
|
|
93
|
-
.filter((entry) => entry.name !== sharedFolderName)
|
|
94
|
-
.filter((entry) => !options.checkedFolderPrefix || entry.name.startsWith(options.checkedFolderPrefix))
|
|
95
|
-
.map((entry) => toRootRelative(options.rootDir, path.join(sourceRootPath, entry.name)))
|
|
96
|
-
.sort()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function lintSceneFolder(options: SceneConventionLintOptions, folder: string) {
|
|
100
|
-
const slug = path.posix.basename(folder)
|
|
101
|
-
const errors: string[] = []
|
|
102
|
-
const requiredFiles = [
|
|
103
|
-
`${slug}.tsx`,
|
|
104
|
-
`${slug}-Landscape.tsx`,
|
|
105
|
-
`${slug}-Horizontal.tsx`,
|
|
106
|
-
`${slug}.stories.tsx`,
|
|
107
|
-
`${slug}.animated.stories.tsx`,
|
|
108
|
-
`${slug}-Horizontal.stories.tsx`,
|
|
109
|
-
`${slug}-Horizontal.animated.stories.tsx`,
|
|
110
|
-
`${slug}.test.tsx`,
|
|
111
|
-
'index.ts',
|
|
112
|
-
]
|
|
113
|
-
|
|
114
|
-
for (const fileName of requiredFiles) {
|
|
115
|
-
const requiredPath = `${folder}/${fileName}`
|
|
116
|
-
if (!fs.existsSync(resolveRootPath(options.rootDir, requiredPath))) {
|
|
117
|
-
errors.push(`${folder}: missing ${fileName}`)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const filesToCheck = [
|
|
122
|
-
`${folder}/${slug}.tsx`,
|
|
123
|
-
`${folder}/${slug}-Landscape.tsx`,
|
|
124
|
-
`${folder}/${slug}-Horizontal.tsx`,
|
|
125
|
-
]
|
|
126
|
-
|
|
127
|
-
for (const file of filesToCheck) {
|
|
128
|
-
if (!fs.existsSync(resolveRootPath(options.rootDir, file))) {
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const text = readText(options.rootDir, file)
|
|
133
|
-
const sourceFileMatch = text.match(/sourceFile:\s*['"`]([^'"`]+)['"`]/)
|
|
134
|
-
if (!sourceFileMatch) {
|
|
135
|
-
errors.push(`${file}: missing componentMetadata.sourceFile`)
|
|
136
|
-
} else if (sourceFileMatch[1] !== file) {
|
|
137
|
-
errors.push(`${file}: sourceFile must be "${file}", got "${sourceFileMatch[1]}"`)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const baseFile = `${folder}/${slug}.tsx`
|
|
142
|
-
if (fs.existsSync(resolveRootPath(options.rootDir, baseFile))) {
|
|
143
|
-
const text = readText(options.rootDir, baseFile)
|
|
144
|
-
if (!/defineSceneComponent\s*\(/.test(text)) {
|
|
145
|
-
errors.push(`${baseFile}: base scene must use defineSceneComponent(...)`)
|
|
146
|
-
}
|
|
147
|
-
if (!/propsSchema:\s*z\.object\s*\(/.test(text)) {
|
|
148
|
-
errors.push(`${baseFile}: base scene must colocate propsSchema with z.object(...)`)
|
|
149
|
-
}
|
|
150
|
-
if (!/useRemotionSceneRuntime\s*\(/.test(text)) {
|
|
151
|
-
errors.push(`${baseFile}: base scene must use useRemotionSceneRuntime()`)
|
|
152
|
-
}
|
|
153
|
-
if (!/SceneMotionLayer|SceneMotionStack|useSceneMotion/.test(text)) {
|
|
154
|
-
errors.push(`${baseFile}: scene animation must use SceneMotionLayer, SceneMotionStack, or useSceneMotion`)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const landscapeFile = `${folder}/${slug}-Landscape.tsx`
|
|
159
|
-
if (
|
|
160
|
-
fs.existsSync(resolveRootPath(options.rootDir, landscapeFile)) &&
|
|
161
|
-
!/aspectRatio:\s*['"]16:9['"]/.test(readText(options.rootDir, landscapeFile))
|
|
162
|
-
) {
|
|
163
|
-
errors.push(`${landscapeFile}: Landscape variant must set aspectRatio: '16:9'`)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const horizontalFile = `${folder}/${slug}-Horizontal.tsx`
|
|
167
|
-
if (
|
|
168
|
-
fs.existsSync(resolveRootPath(options.rootDir, horizontalFile)) &&
|
|
169
|
-
!/aspectRatio:\s*['"]9:16['"]/.test(readText(options.rootDir, horizontalFile))
|
|
170
|
-
) {
|
|
171
|
-
errors.push(`${horizontalFile}: Horizontal variant must set aspectRatio: '9:16'`)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return errors
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function lintStoryFile(options: SceneConventionLintOptions, storyFile: string) {
|
|
178
|
-
const normalized = toRootRelative(options.rootDir, storyFile)
|
|
179
|
-
const text = readText(options.rootDir, normalized)
|
|
180
|
-
const errors: string[] = []
|
|
181
|
-
const storyDir = path.posix.dirname(normalized.slice(`${options.sourceRoot}/`.length))
|
|
182
|
-
const expectedPrefix = `${options.titleRoot}/${storyDir}/`
|
|
183
|
-
const title = readStoryTitle(text)
|
|
184
|
-
const isAnimatedStory = normalized.endsWith('.animated.stories.tsx')
|
|
185
|
-
const isHorizontalStory = /-Horizontal(\.animated)?\.stories\.tsx$/.test(normalized)
|
|
186
|
-
const isLandscapeStory = !isHorizontalStory
|
|
187
|
-
|
|
188
|
-
if (!title.startsWith(expectedPrefix)) {
|
|
189
|
-
if (options.fix) {
|
|
190
|
-
writeText(options.rootDir, normalized, replaceStoryTitlePrefix(text, expectedPrefix))
|
|
191
|
-
} else {
|
|
192
|
-
errors.push(`${normalized}: story title must start with "${expectedPrefix}"`)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (isAnimatedStory && !/scenePreviewMode:\s*['"]animation['"]/.test(text)) {
|
|
197
|
-
errors.push(`${normalized}: animated stories must set scenePreviewMode: 'animation'`)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (isLandscapeStory && !/scenePreviewAspectRatio:\s*['"]16:9['"]/.test(text)) {
|
|
201
|
-
errors.push(`${normalized}: landscape stories must set scenePreviewAspectRatio: '16:9'`)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (isHorizontalStory && !/scenePreviewAspectRatio:\s*['"]9:16['"]/.test(text)) {
|
|
205
|
-
errors.push(`${normalized}: horizontal stories must set scenePreviewAspectRatio: '9:16'`)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return errors
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function lintSharedFolder(options: SceneConventionLintOptions) {
|
|
212
|
-
const sharedFolderName = options.sharedFolderName ?? '_shared'
|
|
213
|
-
const sharedRoot = `${options.sourceRoot}/${sharedFolderName}`
|
|
214
|
-
const sharedRootPath = resolveRootPath(options.rootDir, sharedRoot)
|
|
215
|
-
const errors: string[] = []
|
|
216
|
-
|
|
217
|
-
if (!fs.existsSync(sharedRootPath)) {
|
|
218
|
-
return errors
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const file of collectFiles(options.rootDir, sharedRoot, '.tsx')) {
|
|
222
|
-
const text = readText(options.rootDir, file)
|
|
223
|
-
if (/componentMetadata|defineComponentProjectComponentMetadata|defineScenePluginMetadata/.test(text)) {
|
|
224
|
-
errors.push(`${file}: shared files must not declare publishable component metadata`)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return errors
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function lintSceneConvention(options: SceneConventionLintOptions): SceneConventionLintResult {
|
|
232
|
-
const sceneFolders = collectSceneFolders(options)
|
|
233
|
-
const storyFiles = collectFiles(options.rootDir, options.sourceRoot, '.stories.tsx').filter(
|
|
234
|
-
(file) => !file.includes(`/${options.sharedFolderName ?? '_shared'}/`),
|
|
235
|
-
)
|
|
236
|
-
const errors = [
|
|
237
|
-
...sceneFolders.flatMap((folder) => lintSceneFolder(options, folder)),
|
|
238
|
-
...storyFiles.flatMap((file) => lintStoryFile(options, file)),
|
|
239
|
-
...lintSharedFolder(options),
|
|
240
|
-
]
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
checkedSceneFolders: sceneFolders,
|
|
244
|
-
checkedStoryFiles: storyFiles,
|
|
245
|
-
errors,
|
|
246
|
-
}
|
|
247
|
-
}
|
|
1
|
+
export { lintSceneConvention } from './scene-convention-lint-runtime.mjs'
|
|
2
|
+
export type {
|
|
3
|
+
SceneConventionLintOptions,
|
|
4
|
+
SceneConventionLintResult,
|
|
5
|
+
SceneConventionSourceRoot,
|
|
6
|
+
} from './scene-convention-lint-runtime.mjs'
|