@vibecuting/component-project-helper 0.1.7 → 0.1.9
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/README.md +5 -2
- package/package.json +1 -1
- package/scripts/discover-components.mjs +74 -20
- package/scripts/render-markdown.mjs +4 -0
- package/scripts/runtime-root.mjs +31 -2
- package/scripts/schemas.mjs +2 -0
- package/src/discovery/index.test.ts +153 -0
- package/src/discovery/index.ts +75 -20
- package/src/lifecycle/package-json.ts +47 -0
- package/src/lifecycle/postinstall.test.ts +55 -20
- package/src/lifecycle/postinstall.ts +2 -0
- package/src/lifecycle/preuninstall.ts +3 -0
- package/src/runtime/index.ts +31 -2
package/README.md
CHANGED
|
@@ -10,8 +10,10 @@ NPM 地址:
|
|
|
10
10
|
|
|
11
11
|
- 作为可发布的 npm 包承载组件工程辅助逻辑。
|
|
12
12
|
- 提供组件注解、schema、发现器、Markdown 生成和安装期生命周期逻辑。
|
|
13
|
-
- 安装时扫描 `src/components/**/*.tsx`,把组件元数据生成到 `.agents/skills/project-allow-component/components/`。
|
|
13
|
+
- 安装时扫描 `src/components/**/*.tsx`、`src/video/chapters/**/*.tsx` 以及 `node_modules/@vibecuting/*/src/**/*.tsx`,把组件元数据生成到 `.agents/skills/project-allow-component/components/`。
|
|
14
14
|
- 通过 `postinstall` / `preuninstall` 维护生成文件和清理清单。
|
|
15
|
+
- 安装时会给宿主工程的 `package.json` 补上 `update-component-skills`,它会调用同一套 `postinstall` 逻辑。
|
|
16
|
+
- 宿主工程也可以直接运行 `pnpm update-component-skills` 手动刷新同一套生成结果。
|
|
15
17
|
- 作为主工程之外的独立 helper 包,避免把辅助逻辑重新塞回主站代码里。
|
|
16
18
|
|
|
17
19
|
目录结构:
|
|
@@ -28,8 +30,9 @@ NPM 地址:
|
|
|
28
30
|
- `pnpm typecheck`
|
|
29
31
|
- `pnpm test`
|
|
30
32
|
- `node ./bin/component-project-helper.mjs postinstall`
|
|
33
|
+
- `pnpm update-component-skills`
|
|
31
34
|
|
|
32
35
|
发布与安装:
|
|
33
36
|
|
|
34
|
-
- 发布到
|
|
37
|
+
- 发布到 npmjs 公共 registry 时使用 `@vibecuting/component-project-helper`
|
|
35
38
|
- 组件模板和主工程只依赖这个包的已发布版本,不依赖 workspace 软链接
|
package/package.json
CHANGED
|
@@ -62,7 +62,79 @@ function parseMetadataLiteral(literal) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
async function collectComponentSourceFiles(directory) {
|
|
66
|
+
let entries = []
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
entries = await fs.readdir(directory, { withFileTypes: true })
|
|
70
|
+
} catch {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const files = []
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const entryPath = path.join(directory, entry.name)
|
|
78
|
+
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
files.push(...(await collectComponentSourceFiles(entryPath)))
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!entry.isFile()) {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (entry.name.endsWith('.d.ts')) {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
|
|
93
|
+
files.push(entryPath)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return files
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function collectNamespacedPackageSourceFiles(projectRoot) {
|
|
101
|
+
const namespaceRoot = path.join(projectRoot, 'node_modules', '@vibecuting')
|
|
102
|
+
let entries = []
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
entries = await fs.readdir(namespaceRoot, { withFileTypes: true })
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const packageRoots = entries
|
|
111
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
112
|
+
.map((entry) => path.join(namespaceRoot, entry.name, 'src'))
|
|
113
|
+
|
|
114
|
+
const files = await Promise.all(packageRoots.map((directory) => collectComponentSourceFiles(directory)))
|
|
115
|
+
return [...new Set(files.flat())]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function collectDiscoverableSourceFiles(projectRoot) {
|
|
119
|
+
const sourceRoots = [
|
|
120
|
+
path.join(projectRoot, 'src', 'components'),
|
|
121
|
+
path.join(projectRoot, 'src', 'video', 'chapters'),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const localFiles = await Promise.all(
|
|
125
|
+
sourceRoots.map((directory) => collectComponentSourceFiles(directory)),
|
|
126
|
+
)
|
|
127
|
+
const packageFiles = await collectNamespacedPackageSourceFiles(projectRoot)
|
|
128
|
+
|
|
129
|
+
return [...new Set([...localFiles.flat(), ...packageFiles])]
|
|
130
|
+
}
|
|
131
|
+
|
|
65
132
|
function readMetadataFromSource(source) {
|
|
133
|
+
const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
|
|
134
|
+
if (decoratorMatch) {
|
|
135
|
+
return parseMetadataLiteral(decoratorMatch[1])
|
|
136
|
+
}
|
|
137
|
+
|
|
66
138
|
const patterns = [
|
|
67
139
|
/componentMetadata\s*=\s*defineComponentProjectComponentMetadata\s*\(/m,
|
|
68
140
|
/componentMetadata\s*=\s*VideoComponent\s*\(/m,
|
|
@@ -91,32 +163,14 @@ function readMetadataFromSource(source) {
|
|
|
91
163
|
}
|
|
92
164
|
}
|
|
93
165
|
|
|
94
|
-
const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
|
|
95
|
-
if (decoratorMatch) {
|
|
96
|
-
return parseMetadataLiteral(decoratorMatch[1])
|
|
97
|
-
}
|
|
98
|
-
|
|
99
166
|
return undefined
|
|
100
167
|
}
|
|
101
168
|
|
|
102
169
|
export async function discoverComponentProjectComponents(projectRoot) {
|
|
103
|
-
const componentsDir = path.join(projectRoot, 'src', 'components')
|
|
104
|
-
let entries = []
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
entries = await fs.readdir(componentsDir)
|
|
108
|
-
} catch {
|
|
109
|
-
return []
|
|
110
|
-
}
|
|
111
|
-
|
|
112
170
|
const discovered = []
|
|
171
|
+
const sourceFiles = await collectDiscoverableSourceFiles(projectRoot)
|
|
113
172
|
|
|
114
|
-
for (const
|
|
115
|
-
if (!entry.endsWith('.tsx')) {
|
|
116
|
-
continue
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const absolutePath = path.join(componentsDir, entry)
|
|
173
|
+
for (const absolutePath of sourceFiles) {
|
|
120
174
|
const source = await fs.readFile(absolutePath, 'utf8')
|
|
121
175
|
const metadata = readMetadataFromSource(source)
|
|
122
176
|
if (!metadata) {
|
|
@@ -13,6 +13,8 @@ export function renderComponentProjectMarkdown(input) {
|
|
|
13
13
|
`name: ${metadata.name}`,
|
|
14
14
|
`description: ${metadata.description}`,
|
|
15
15
|
`sourceFile: ${metadata.sourceFile}`,
|
|
16
|
+
`aspectRatio: ${metadata.aspectRatio}`,
|
|
17
|
+
`sceneType: ${metadata.sceneType ?? ''}`,
|
|
16
18
|
`propsTypeName: ${metadata.propsTypeName ?? ''}`,
|
|
17
19
|
'tags:',
|
|
18
20
|
...tags.map((tag) => ` - ${tag}`),
|
|
@@ -25,6 +27,8 @@ export function renderComponentProjectMarkdown(input) {
|
|
|
25
27
|
'## Metadata',
|
|
26
28
|
'',
|
|
27
29
|
`- Source file: \`${escapeInline(metadata.sourceFile)}\``,
|
|
30
|
+
`- Aspect ratio: \`${escapeInline(metadata.aspectRatio)}\``,
|
|
31
|
+
`- Scene type: \`${escapeInline(metadata.sceneType ?? 'unknown')}\``,
|
|
28
32
|
`- Props type: \`${escapeInline(metadata.propsTypeName ?? 'unknown')}\``,
|
|
29
33
|
`- Tags: ${tags.map((tag) => `\`${escapeInline(tag)}\``).join(', ')}`,
|
|
30
34
|
'',
|
package/scripts/runtime-root.mjs
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs'
|
|
2
3
|
import process from 'node:process'
|
|
3
4
|
|
|
5
|
+
function hasComponentProjectSourceDir(directory) {
|
|
6
|
+
return fs.existsSync(path.join(directory, 'src', 'components'))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function findComponentProjectInstallRoot(startDirectory) {
|
|
10
|
+
let currentDirectory = path.resolve(startDirectory)
|
|
11
|
+
|
|
12
|
+
for (;;) {
|
|
13
|
+
if (hasComponentProjectSourceDir(currentDirectory)) {
|
|
14
|
+
return currentDirectory
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parentDirectory = path.dirname(currentDirectory)
|
|
18
|
+
if (parentDirectory === currentDirectory) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
currentDirectory = parentDirectory
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
4
26
|
export function resolveComponentProjectInstallRoot() {
|
|
5
27
|
const candidates = [
|
|
28
|
+
process.cwd(),
|
|
6
29
|
process.env.INIT_CWD,
|
|
7
30
|
process.env.npm_config_local_prefix,
|
|
8
|
-
process.cwd(),
|
|
9
31
|
].filter((value) => Boolean(value && String(value).trim()))
|
|
10
32
|
|
|
11
|
-
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
const resolved = findComponentProjectInstallRoot(candidate)
|
|
35
|
+
if (resolved) {
|
|
36
|
+
return resolved
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return path.resolve(process.cwd())
|
|
12
41
|
}
|
|
13
42
|
|
|
14
43
|
export function resolveComponentProjectGeneratedDocsDir(projectRoot = resolveComponentProjectInstallRoot()) {
|
package/scripts/schemas.mjs
CHANGED
|
@@ -4,6 +4,8 @@ export const ComponentProjectComponentMetadataSchema = z.object({
|
|
|
4
4
|
name: z.string().min(1),
|
|
5
5
|
description: z.string().min(1),
|
|
6
6
|
sourceFile: z.string().min(1),
|
|
7
|
+
aspectRatio: z.enum(['9:16', '16:9']),
|
|
8
|
+
sceneType: z.enum(['intro', 'scene', 'outro']).optional(),
|
|
7
9
|
tags: z.array(z.string().min(1)).default([]),
|
|
8
10
|
propsTypeName: z.string().min(1).optional(),
|
|
9
11
|
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
import { expect, test } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { discoverComponentProjectComponents } from './index'
|
|
8
|
+
|
|
9
|
+
test('discovers decorated components recursively', async () => {
|
|
10
|
+
const projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'component-project-helper-discovery-'))
|
|
11
|
+
const componentsDir = path.join(projectRoot, 'src', 'components', 'nested')
|
|
12
|
+
await fs.mkdir(componentsDir, { recursive: true })
|
|
13
|
+
|
|
14
|
+
await fs.writeFile(
|
|
15
|
+
path.join(projectRoot, 'src', 'components', 'SceneFrame.tsx'),
|
|
16
|
+
`
|
|
17
|
+
@VideoComponent({
|
|
18
|
+
name: 'SceneFrame',
|
|
19
|
+
description: 'Wraps the scene content in a consistent frame',
|
|
20
|
+
sourceFile: 'src/components/SceneFrame.tsx',
|
|
21
|
+
aspectRatio: '16:9',
|
|
22
|
+
sceneType: 'scene',
|
|
23
|
+
tags: ['layout', 'frame'],
|
|
24
|
+
propsTypeName: 'SceneFrameProps',
|
|
25
|
+
})
|
|
26
|
+
export const SceneFrame = () => null
|
|
27
|
+
`,
|
|
28
|
+
'utf8',
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
await fs.writeFile(
|
|
32
|
+
path.join(componentsDir, 'SceneTitle.tsx'),
|
|
33
|
+
`
|
|
34
|
+
@VideoComponent({
|
|
35
|
+
name: 'SceneTitle',
|
|
36
|
+
description: 'Renders the title block for scene hero content',
|
|
37
|
+
sourceFile: 'src/components/nested/SceneTitle.tsx',
|
|
38
|
+
aspectRatio: '9:16',
|
|
39
|
+
sceneType: 'intro',
|
|
40
|
+
tags: ['title', 'hero'],
|
|
41
|
+
propsTypeName: 'SceneTitleProps',
|
|
42
|
+
})
|
|
43
|
+
export const SceneTitle = () => null
|
|
44
|
+
`,
|
|
45
|
+
'utf8',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const discovered = await discoverComponentProjectComponents(projectRoot)
|
|
49
|
+
|
|
50
|
+
expect(discovered).toEqual([
|
|
51
|
+
{
|
|
52
|
+
name: 'SceneFrame',
|
|
53
|
+
description: 'Wraps the scene content in a consistent frame',
|
|
54
|
+
sourceFile: 'src/components/SceneFrame.tsx',
|
|
55
|
+
aspectRatio: '16:9',
|
|
56
|
+
sceneType: 'scene',
|
|
57
|
+
tags: ['layout', 'frame'],
|
|
58
|
+
propsTypeName: 'SceneFrameProps',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'SceneTitle',
|
|
62
|
+
description: 'Renders the title block for scene hero content',
|
|
63
|
+
sourceFile: 'src/components/nested/SceneTitle.tsx',
|
|
64
|
+
aspectRatio: '9:16',
|
|
65
|
+
sceneType: 'intro',
|
|
66
|
+
tags: ['title', 'hero'],
|
|
67
|
+
propsTypeName: 'SceneTitleProps',
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('discovers decorated components from installed video-project-core', async () => {
|
|
73
|
+
const projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'component-project-helper-package-'))
|
|
74
|
+
const videoProjectCoreComponentDir = path.join(
|
|
75
|
+
projectRoot,
|
|
76
|
+
'node_modules',
|
|
77
|
+
'@vibecuting',
|
|
78
|
+
'video-project-core',
|
|
79
|
+
'src',
|
|
80
|
+
'core',
|
|
81
|
+
'intro',
|
|
82
|
+
)
|
|
83
|
+
await fs.mkdir(videoProjectCoreComponentDir, { recursive: true })
|
|
84
|
+
|
|
85
|
+
await fs.writeFile(
|
|
86
|
+
path.join(videoProjectCoreComponentDir, 'DefaultIntroBumper.tsx'),
|
|
87
|
+
`
|
|
88
|
+
@VideoComponent({
|
|
89
|
+
name: 'DefaultIntroBumper',
|
|
90
|
+
description: 'Default remotion intro bumper',
|
|
91
|
+
sourceFile: 'node_modules/@vibecuting/video-project-core/src/core/intro/DefaultIntroBumper.tsx',
|
|
92
|
+
aspectRatio: '16:9',
|
|
93
|
+
sceneType: 'intro',
|
|
94
|
+
tags: ['intro', 'core'],
|
|
95
|
+
propsTypeName: 'DefaultIntroBumperProps',
|
|
96
|
+
})
|
|
97
|
+
export const DefaultIntroBumper = () => null
|
|
98
|
+
`,
|
|
99
|
+
'utf8',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const videoProjectHelperComponentDir = path.join(
|
|
103
|
+
projectRoot,
|
|
104
|
+
'node_modules',
|
|
105
|
+
'@vibecuting',
|
|
106
|
+
'video-project-helper',
|
|
107
|
+
'src',
|
|
108
|
+
'core',
|
|
109
|
+
'scene',
|
|
110
|
+
)
|
|
111
|
+
await fs.mkdir(videoProjectHelperComponentDir, { recursive: true })
|
|
112
|
+
|
|
113
|
+
await fs.writeFile(
|
|
114
|
+
path.join(videoProjectHelperComponentDir, 'DefaultSceneFrame.tsx'),
|
|
115
|
+
`
|
|
116
|
+
@VideoComponent({
|
|
117
|
+
name: 'DefaultSceneFrame',
|
|
118
|
+
description: 'Default helper scene frame',
|
|
119
|
+
sourceFile: 'node_modules/@vibecuting/video-project-helper/src/core/scene/DefaultSceneFrame.tsx',
|
|
120
|
+
aspectRatio: '16:9',
|
|
121
|
+
sceneType: 'scene',
|
|
122
|
+
tags: ['scene', 'helper'],
|
|
123
|
+
propsTypeName: 'DefaultSceneFrameProps',
|
|
124
|
+
})
|
|
125
|
+
export const DefaultSceneFrame = () => null
|
|
126
|
+
`,
|
|
127
|
+
'utf8',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const discovered = await discoverComponentProjectComponents(projectRoot)
|
|
131
|
+
|
|
132
|
+
expect(discovered).toEqual([
|
|
133
|
+
{
|
|
134
|
+
name: 'DefaultIntroBumper',
|
|
135
|
+
description: 'Default remotion intro bumper',
|
|
136
|
+
sourceFile: 'node_modules/@vibecuting/video-project-core/src/core/intro/DefaultIntroBumper.tsx',
|
|
137
|
+
aspectRatio: '16:9',
|
|
138
|
+
sceneType: 'intro',
|
|
139
|
+
tags: ['intro', 'core'],
|
|
140
|
+
propsTypeName: 'DefaultIntroBumperProps',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'DefaultSceneFrame',
|
|
144
|
+
description: 'Default helper scene frame',
|
|
145
|
+
sourceFile:
|
|
146
|
+
'node_modules/@vibecuting/video-project-helper/src/core/scene/DefaultSceneFrame.tsx',
|
|
147
|
+
aspectRatio: '16:9',
|
|
148
|
+
sceneType: 'scene',
|
|
149
|
+
tags: ['scene', 'helper'],
|
|
150
|
+
propsTypeName: 'DefaultSceneFrameProps',
|
|
151
|
+
},
|
|
152
|
+
])
|
|
153
|
+
})
|
package/src/discovery/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import type { Dirent } from 'node:fs'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
|
|
4
5
|
import {
|
|
@@ -8,6 +9,73 @@ import {
|
|
|
8
9
|
|
|
9
10
|
export type ComponentProjectComponentDiscovery = ComponentProjectComponentMetadata
|
|
10
11
|
|
|
12
|
+
async function collectComponentSourceFiles(directory: string): Promise<string[]> {
|
|
13
|
+
let entries: Dirent[] = []
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
entries = await fs.readdir(directory, { withFileTypes: true })
|
|
17
|
+
} catch {
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const files: string[] = []
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const entryPath = path.join(directory, entry.name)
|
|
25
|
+
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
files.push(...(await collectComponentSourceFiles(entryPath)))
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!entry.isFile()) {
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (entry.name.endsWith('.d.ts')) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
|
|
40
|
+
files.push(entryPath)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return files
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function collectNamespacedPackageSourceFiles(projectRoot: string): Promise<string[]> {
|
|
48
|
+
const namespaceRoot = path.join(projectRoot, 'node_modules', '@vibecuting')
|
|
49
|
+
let entries: Dirent[] = []
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
entries = await fs.readdir(namespaceRoot, { withFileTypes: true })
|
|
53
|
+
} catch {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const packageRoots = entries
|
|
58
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
59
|
+
.map((entry) => path.join(namespaceRoot, entry.name, 'src'))
|
|
60
|
+
|
|
61
|
+
const files = await Promise.all(packageRoots.map((directory) => collectComponentSourceFiles(directory)))
|
|
62
|
+
return [...new Set(files.flat())]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function collectDiscoverableSourceFiles(projectRoot: string): Promise<string[]> {
|
|
66
|
+
const sourceRoots = [
|
|
67
|
+
path.join(projectRoot, 'src', 'components'),
|
|
68
|
+
path.join(projectRoot, 'src', 'video', 'chapters'),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const localFiles = await Promise.all(
|
|
72
|
+
sourceRoots.map((directory) => collectComponentSourceFiles(directory)),
|
|
73
|
+
)
|
|
74
|
+
const packageFiles = await collectNamespacedPackageSourceFiles(projectRoot)
|
|
75
|
+
|
|
76
|
+
return [...new Set([...localFiles.flat(), ...packageFiles])]
|
|
77
|
+
}
|
|
78
|
+
|
|
11
79
|
function extractBalancedObjectLiteral(source: string, startIndex: number): string | undefined {
|
|
12
80
|
let depth = 0
|
|
13
81
|
let inString = false
|
|
@@ -68,6 +136,11 @@ function parseMetadataLiteral(literal: string): ComponentProjectComponentMetadat
|
|
|
68
136
|
}
|
|
69
137
|
|
|
70
138
|
function readMetadataFromSource(source: string): ComponentProjectComponentMetadata | undefined {
|
|
139
|
+
const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
|
|
140
|
+
if (decoratorMatch) {
|
|
141
|
+
return parseMetadataLiteral(decoratorMatch[1])
|
|
142
|
+
}
|
|
143
|
+
|
|
71
144
|
const candidates = [
|
|
72
145
|
/componentMetadata\s*=\s*defineComponentProjectComponentMetadata\s*\(/m,
|
|
73
146
|
/componentMetadata\s*=\s*VideoComponent\s*\(/m,
|
|
@@ -96,34 +169,16 @@ function readMetadataFromSource(source: string): ComponentProjectComponentMetada
|
|
|
96
169
|
}
|
|
97
170
|
}
|
|
98
171
|
|
|
99
|
-
const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
|
|
100
|
-
if (decoratorMatch) {
|
|
101
|
-
return parseMetadataLiteral(decoratorMatch[1])
|
|
102
|
-
}
|
|
103
|
-
|
|
104
172
|
return undefined
|
|
105
173
|
}
|
|
106
174
|
|
|
107
175
|
export async function discoverComponentProjectComponents(
|
|
108
176
|
projectRoot: string,
|
|
109
177
|
): Promise<ComponentProjectComponentDiscovery[]> {
|
|
110
|
-
const componentsDir = path.join(projectRoot, 'src', 'components')
|
|
111
|
-
let entries: string[] = []
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
entries = await fs.readdir(componentsDir)
|
|
115
|
-
} catch {
|
|
116
|
-
return []
|
|
117
|
-
}
|
|
118
|
-
|
|
119
178
|
const discovered: ComponentProjectComponentDiscovery[] = []
|
|
179
|
+
const sourceFiles = await collectDiscoverableSourceFiles(projectRoot)
|
|
120
180
|
|
|
121
|
-
for (const
|
|
122
|
-
if (!entry.endsWith('.tsx')) {
|
|
123
|
-
continue
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const absolutePath = path.join(componentsDir, entry)
|
|
181
|
+
for (const absolutePath of sourceFiles) {
|
|
127
182
|
const source = await fs.readFile(absolutePath, 'utf8')
|
|
128
183
|
const metadata = readMetadataFromSource(source)
|
|
129
184
|
if (!metadata) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const componentProjectSkillsScriptName = 'update-component-skills' as const
|
|
5
|
+
export const componentProjectSkillsScriptCommand =
|
|
6
|
+
'node ./node_modules/@vibecuting/component-project-helper/scripts/postinstall.mjs' as const
|
|
7
|
+
|
|
8
|
+
async function readPackageJson(projectRoot: string): Promise<Record<string, unknown>> {
|
|
9
|
+
const packageJsonPath = path.join(projectRoot, 'package.json')
|
|
10
|
+
return JSON.parse(await fs.readFile(packageJsonPath, 'utf8'))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function writePackageJson(
|
|
14
|
+
projectRoot: string,
|
|
15
|
+
packageJson: Record<string, unknown>,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const packageJsonPath = path.join(projectRoot, 'package.json')
|
|
18
|
+
await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function ensureComponentProjectSkillsScript(projectRoot: string): Promise<void> {
|
|
22
|
+
const packageJson = await readPackageJson(projectRoot)
|
|
23
|
+
const scripts = (packageJson.scripts as Record<string, string> | undefined) ?? {}
|
|
24
|
+
|
|
25
|
+
if (scripts[componentProjectSkillsScriptName] !== componentProjectSkillsScriptCommand) {
|
|
26
|
+
packageJson.scripts = {
|
|
27
|
+
...scripts,
|
|
28
|
+
[componentProjectSkillsScriptName]: componentProjectSkillsScriptCommand,
|
|
29
|
+
}
|
|
30
|
+
await writePackageJson(projectRoot, packageJson)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function removeComponentProjectSkillsScript(projectRoot: string): Promise<void> {
|
|
35
|
+
const packageJson = await readPackageJson(projectRoot)
|
|
36
|
+
const scripts = (packageJson.scripts as Record<string, string> | undefined) ?? {}
|
|
37
|
+
|
|
38
|
+
if (scripts[componentProjectSkillsScriptName] !== componentProjectSkillsScriptCommand) {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nextScripts = { ...scripts }
|
|
43
|
+
delete nextScripts[componentProjectSkillsScriptName]
|
|
44
|
+
packageJson.scripts = nextScripts
|
|
45
|
+
|
|
46
|
+
await writePackageJson(projectRoot, packageJson)
|
|
47
|
+
}
|
|
@@ -9,34 +9,53 @@ import { runComponentProjectPreuninstall } from './preuninstall'
|
|
|
9
9
|
|
|
10
10
|
async function createFixtureProjectRoot(): Promise<string> {
|
|
11
11
|
const projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'component-project-helper-'))
|
|
12
|
-
await fs.mkdir(path.join(projectRoot, 'src', 'components'), { recursive: true })
|
|
12
|
+
await fs.mkdir(path.join(projectRoot, 'src', 'components', 'nested'), { recursive: true })
|
|
13
|
+
await fs.writeFile(
|
|
14
|
+
path.join(projectRoot, 'package.json'),
|
|
15
|
+
JSON.stringify(
|
|
16
|
+
{
|
|
17
|
+
name: 'component-project-fixture',
|
|
18
|
+
private: true,
|
|
19
|
+
scripts: {
|
|
20
|
+
build: 'echo build',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
null,
|
|
24
|
+
2,
|
|
25
|
+
),
|
|
26
|
+
'utf8',
|
|
27
|
+
)
|
|
13
28
|
|
|
14
29
|
await fs.writeFile(
|
|
15
30
|
path.join(projectRoot, 'src', 'components', 'SceneFrame.tsx'),
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
`
|
|
32
|
+
@VideoComponent({
|
|
33
|
+
name: 'SceneFrame',
|
|
34
|
+
description: 'Wraps the scene content in a consistent frame',
|
|
35
|
+
sourceFile: 'src/components/SceneFrame.tsx',
|
|
36
|
+
aspectRatio: '16:9',
|
|
37
|
+
sceneType: 'scene',
|
|
38
|
+
tags: ['layout', 'frame'],
|
|
39
|
+
propsTypeName: 'SceneFrameProps',
|
|
40
|
+
})
|
|
41
|
+
export const SceneFrame = () => null
|
|
25
42
|
`,
|
|
26
43
|
'utf8',
|
|
27
44
|
)
|
|
28
45
|
|
|
29
46
|
await fs.writeFile(
|
|
30
|
-
path.join(projectRoot, 'src', 'components', 'SceneTitle.tsx'),
|
|
31
|
-
`
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
path.join(projectRoot, 'src', 'components', 'nested', 'SceneTitle.tsx'),
|
|
48
|
+
`
|
|
49
|
+
@VideoComponent({
|
|
50
|
+
name: 'SceneTitle',
|
|
51
|
+
description: 'Renders the title block for scene hero content',
|
|
52
|
+
sourceFile: 'src/components/nested/SceneTitle.tsx',
|
|
53
|
+
aspectRatio: '9:16',
|
|
54
|
+
sceneType: 'intro',
|
|
55
|
+
tags: ['title'],
|
|
56
|
+
propsTypeName: 'SceneTitleProps',
|
|
57
|
+
})
|
|
58
|
+
export const SceneTitle = () => null
|
|
40
59
|
`,
|
|
41
60
|
'utf8',
|
|
42
61
|
)
|
|
@@ -66,6 +85,17 @@ test('postinstall generates markdown and preuninstall removes it', async () => {
|
|
|
66
85
|
await expect(fs.stat(path.join(docsDir, 'SceneFrame.md'))).resolves.toBeDefined()
|
|
67
86
|
await expect(fs.stat(path.join(docsDir, 'SceneTitle.md'))).resolves.toBeDefined()
|
|
68
87
|
await expect(fs.stat(manifestPath)).resolves.toBeDefined()
|
|
88
|
+
await expect(await fs.readFile(path.join(docsDir, 'SceneTitle.md'), 'utf8')).toContain(
|
|
89
|
+
'src/components/nested/SceneTitle.tsx',
|
|
90
|
+
)
|
|
91
|
+
await expect(await fs.readFile(path.join(docsDir, 'SceneTitle.md'), 'utf8')).toContain(
|
|
92
|
+
'9:16',
|
|
93
|
+
)
|
|
94
|
+
await expect(
|
|
95
|
+
JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf8')).scripts[
|
|
96
|
+
'update-component-skills'
|
|
97
|
+
],
|
|
98
|
+
).toBe('node ./node_modules/@vibecuting/component-project-helper/scripts/postinstall.mjs')
|
|
69
99
|
|
|
70
100
|
await runComponentProjectPreuninstall(projectRoot)
|
|
71
101
|
|
|
@@ -76,4 +106,9 @@ test('postinstall generates markdown and preuninstall removes it', async () => {
|
|
|
76
106
|
code: 'ENOENT',
|
|
77
107
|
})
|
|
78
108
|
await expect(fs.stat(manifestPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
109
|
+
await expect(
|
|
110
|
+
JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf8')).scripts[
|
|
111
|
+
'update-component-skills'
|
|
112
|
+
],
|
|
113
|
+
).toBeUndefined()
|
|
79
114
|
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveComponentProjectGeneratedManifestPath,
|
|
11
11
|
resolveComponentProjectInstallRoot,
|
|
12
12
|
} from '../runtime'
|
|
13
|
+
import { ensureComponentProjectSkillsScript } from './package-json'
|
|
13
14
|
|
|
14
15
|
async function ensureDirectory(dir: string): Promise<void> {
|
|
15
16
|
await fs.mkdir(dir, { recursive: true })
|
|
@@ -51,6 +52,7 @@ export async function runComponentProjectPostinstall(
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
await removeStaleGeneratedFiles(manifestPath, files)
|
|
55
|
+
await ensureComponentProjectSkillsScript(projectRoot)
|
|
54
56
|
|
|
55
57
|
const manifest = ComponentProjectGeneratedManifestSchema.parse({
|
|
56
58
|
projectRoot,
|
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
|
|
5
5
|
import { ComponentProjectGeneratedManifestSchema } from '../schemas'
|
|
6
6
|
import { resolveComponentProjectGeneratedManifestPath } from '../runtime'
|
|
7
|
+
import { removeComponentProjectSkillsScript } from './package-json'
|
|
7
8
|
|
|
8
9
|
export async function runComponentProjectPreuninstall(
|
|
9
10
|
projectRoot = process.env.INIT_CWD ??
|
|
@@ -28,6 +29,8 @@ export async function runComponentProjectPreuninstall(
|
|
|
28
29
|
throw error
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
|
|
33
|
+
await removeComponentProjectSkillsScript(projectRoot)
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
async function main(): Promise<void> {
|
package/src/runtime/index.ts
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs'
|
|
2
3
|
import process from 'node:process'
|
|
3
4
|
|
|
5
|
+
function hasComponentProjectSourceDir(directory: string): boolean {
|
|
6
|
+
return fs.existsSync(path.join(directory, 'src', 'components'))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function findComponentProjectInstallRoot(startDirectory: string): string | undefined {
|
|
10
|
+
let currentDirectory = path.resolve(startDirectory)
|
|
11
|
+
|
|
12
|
+
for (;;) {
|
|
13
|
+
if (hasComponentProjectSourceDir(currentDirectory)) {
|
|
14
|
+
return currentDirectory
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parentDirectory = path.dirname(currentDirectory)
|
|
18
|
+
if (parentDirectory === currentDirectory) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
currentDirectory = parentDirectory
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
4
26
|
export function resolveComponentProjectInstallRoot(): string {
|
|
5
27
|
const candidates = [
|
|
28
|
+
process.cwd(),
|
|
6
29
|
process.env.INIT_CWD,
|
|
7
30
|
process.env.npm_config_local_prefix,
|
|
8
|
-
process.cwd(),
|
|
9
31
|
].filter((value): value is string => Boolean(value && value.trim()))
|
|
10
32
|
|
|
11
|
-
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
const resolved = findComponentProjectInstallRoot(candidate)
|
|
35
|
+
if (resolved) {
|
|
36
|
+
return resolved
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return path.resolve(process.cwd())
|
|
12
41
|
}
|
|
13
42
|
|
|
14
43
|
export function resolveComponentProjectGeneratedDocsDir(
|