@vibecuting/video-project-core 0.1.40 → 0.1.42

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.
@@ -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.40",
3
+ "version": "0.1.42",
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 ../../../scripts/lint-vibecuting-scenes.mjs --all",
23
- "lint:fix": "node ../../../scripts/run-eslint.mjs src .storybook --fix && node ../../../scripts/lint-vibecuting-scenes.mjs --all --fix",
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.40",
29
+ "@vibecuting/component-project-helper": "0.1.42",
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 { SceneConventionLintOptions, SceneConventionLintResult } from './scene-convention-lint'
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 writeSceneFixture(rootDir: string) {
10
- const folder = path.join(rootDir, 'src/components/default-component-scene')
11
- const sharedFolder = path.join(rootDir, 'src/components/_shared')
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, 'default-component-scene.tsx'),
25
+ path.join(folder, `${slug}.tsx`),
17
26
  [
18
27
  "import { z } from 'zod'",
19
- "export const componentMetadata = { sourceFile: 'src/components/default-component-scene/default-component-scene.tsx' }",
20
- "export const DefaultComponentScene = defineSceneComponent({ propsSchema: z.object({ title: z.string() }), component: function DefaultComponentScene() { useRemotionSceneRuntime(); return <SceneMotionLayer /> } })",
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, 'default-component-scene-Landscape.tsx'),
26
- "export const componentMetadata = { sourceFile: 'src/components/default-component-scene/default-component-scene-Landscape.tsx', aspectRatio: '16:9' }\n",
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, 'default-component-scene-Horizontal.tsx'),
30
- "export const componentMetadata = { sourceFile: 'src/components/default-component-scene/default-component-scene-Horizontal.tsx', aspectRatio: '9:16' }\n",
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, 'default-component-scene.stories.tsx'),
34
- "const meta = { title: 'components/default-component-scene/DefaultComponentSceneLandscape' }\nexport default meta\nexport const Default = { parameters: { scenePreviewAspectRatio: '16:9' } }\n",
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, 'default-component-scene.animated.stories.tsx'),
38
- "const meta = { title: 'components/default-component-scene/DefaultComponentSceneLandscape' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '16:9', scenePreviewMode: 'animation' } }\n",
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, 'default-component-scene-Horizontal.stories.tsx'),
42
- "const meta = { title: 'components/default-component-scene/DefaultComponentSceneHorizontal' }\nexport default meta\nexport const Default = { parameters: { scenePreviewAspectRatio: '9:16' } }\n",
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, 'default-component-scene-Horizontal.animated.stories.tsx'),
46
- "const meta = { title: 'components/default-component-scene/DefaultComponentSceneHorizontal' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '9:16', scenePreviewMode: 'animation' } }\n",
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, 'default-component-scene.test.tsx'), "import test from 'node:test'\ntest('ok', () => {})\n")
49
- fs.writeFileSync(path.join(folder, 'index.ts'), "export * from './default-component-scene'\n")
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
- sourceRoot: 'src/components',
61
- titleRoot: 'components',
62
- sharedFolderName: '_shared',
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/DefaultComponentSceneLandscape' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '16:9' } }\n",
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
- sourceRoot: 'src/components',
80
- titleRoot: 'components',
81
- sharedFolderName: '_shared',
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
- import fs from 'node:fs'
2
- import path from 'node:path'
3
-
4
- export type SceneConventionLintOptions = {
5
- rootDir: string
6
- sourceRoot: string
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'