@vibecuting/component-project-helper 0.1.1

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 ADDED
@@ -0,0 +1,27 @@
1
+ # @vibecuting/component-project-helper
2
+
3
+ 组件工程辅助包。
4
+
5
+ 职责:
6
+
7
+ - 作为可发布的 npm 包承载组件工程辅助逻辑
8
+ - 安装时扫描 `src/components/**/*.tsx`,把组件元数据生成到 `.agents/skills/project-allow-component/components/`
9
+ - 通过 `postinstall` / `preuninstall` 维护生成文件和清理清单
10
+ - 提供 `VideoComponent` 装饰器和 `defineComponentProjectComponentMetadata()` 注解定义,供后续需要元数据注解的组件复用
11
+ - `postinstall` / `preuninstall` 的实际执行入口在 `scripts/` 下,便于 npm 安装时直接运行
12
+ - 作为主工程之外的独立 helper 包,避免把辅助逻辑重新塞回主站代码里
13
+
14
+ 目录结构:
15
+
16
+ - `src/decorators`:注解定义和元数据读写
17
+ - `src/schemas`:zod schema
18
+ - `src/discovery`:组件发现与解析
19
+ - `src/markdown`:Markdown 渲染
20
+ - `src/runtime`:安装根与输出路径解析
21
+ - `src/lifecycle`:postinstall / preuninstall 生命周期逻辑
22
+
23
+ 常用命令:
24
+
25
+ - `pnpm typecheck`
26
+ - `pnpm test`
27
+ - `node ./bin/component-project-helper.mjs postinstall`
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const binDir = path.dirname(fileURLToPath(import.meta.url))
8
+ const scriptName = process.argv[2] === 'preuninstall' ? 'preuninstall.mjs' : 'postinstall.mjs'
9
+ const scriptPath = path.resolve(binDir, '../scripts', scriptName)
10
+
11
+ const result = spawnSync(process.execPath, [scriptPath], {
12
+ stdio: 'inherit',
13
+ env: process.env,
14
+ })
15
+
16
+ process.exit(result.status ?? 1)
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@vibecuting/component-project-helper",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "scripts",
13
+ "src",
14
+ "README.md",
15
+ "package.json"
16
+ ],
17
+ "publishConfig": {
18
+ "registry": "https://registry.npmjs.org",
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "postinstall": "node ./scripts/postinstall.mjs",
23
+ "preuninstall": "node ./scripts/preuninstall.mjs",
24
+ "typecheck": "node ../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/tsc -p tsconfig.json --noEmit",
25
+ "test": "node ../../../node_modules/vitest/vitest.mjs run"
26
+ },
27
+ "dependencies": {
28
+ "reflect-metadata": "^0.2.2",
29
+ "typescript": "^5.9.3",
30
+ "zod": "4.1.12"
31
+ },
32
+ "devDependencies": {
33
+ "vitest": "^3.2.4"
34
+ }
35
+ }
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { ComponentProjectComponentMetadataSchema } from './schemas.mjs'
5
+
6
+ function extractBalancedObjectLiteral(source, startIndex) {
7
+ let depth = 0
8
+ let inString = false
9
+ let stringQuote = ''
10
+ let escaped = false
11
+
12
+ for (let index = startIndex; index < source.length; index += 1) {
13
+ const character = source[index]
14
+
15
+ if (inString) {
16
+ if (escaped) {
17
+ escaped = false
18
+ continue
19
+ }
20
+
21
+ if (character === '\\') {
22
+ escaped = true
23
+ continue
24
+ }
25
+
26
+ if (character === stringQuote) {
27
+ inString = false
28
+ }
29
+
30
+ continue
31
+ }
32
+
33
+ if (character === '"' || character === "'" || character === '`') {
34
+ inString = true
35
+ stringQuote = character
36
+ continue
37
+ }
38
+
39
+ if (character === '{') {
40
+ depth += 1
41
+ continue
42
+ }
43
+
44
+ if (character === '}') {
45
+ depth -= 1
46
+ if (depth === 0) {
47
+ return source.slice(startIndex, index + 1)
48
+ }
49
+ }
50
+ }
51
+
52
+ return undefined
53
+ }
54
+
55
+ function parseMetadataLiteral(literal) {
56
+ try {
57
+ const value = Function(`"use strict"; return (${literal});`)()
58
+ const parsed = ComponentProjectComponentMetadataSchema.safeParse(value)
59
+ return parsed.success ? parsed.data : undefined
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ function readMetadataFromSource(source) {
66
+ const patterns = [
67
+ /componentMetadata\s*=\s*defineComponentProjectComponentMetadata\s*\(/m,
68
+ /componentMetadata\s*=\s*VideoComponent\s*\(/m,
69
+ /componentMetadata\s*=\s*/m,
70
+ ]
71
+
72
+ for (const pattern of patterns) {
73
+ const match = pattern.exec(source)
74
+ if (!match) {
75
+ continue
76
+ }
77
+
78
+ const startIndex = source.indexOf('{', match.index)
79
+ if (startIndex < 0) {
80
+ continue
81
+ }
82
+
83
+ const literal = extractBalancedObjectLiteral(source, startIndex)
84
+ if (!literal) {
85
+ continue
86
+ }
87
+
88
+ const parsed = parseMetadataLiteral(literal)
89
+ if (parsed) {
90
+ return parsed
91
+ }
92
+ }
93
+
94
+ const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
95
+ if (decoratorMatch) {
96
+ return parseMetadataLiteral(decoratorMatch[1])
97
+ }
98
+
99
+ return undefined
100
+ }
101
+
102
+ 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
+ const discovered = []
113
+
114
+ for (const entry of entries) {
115
+ if (!entry.endsWith('.tsx')) {
116
+ continue
117
+ }
118
+
119
+ const absolutePath = path.join(componentsDir, entry)
120
+ const source = await fs.readFile(absolutePath, 'utf8')
121
+ const metadata = readMetadataFromSource(source)
122
+ if (!metadata) {
123
+ continue
124
+ }
125
+
126
+ discovered.push({
127
+ ...metadata,
128
+ sourceFile: path.relative(projectRoot, absolutePath),
129
+ })
130
+ }
131
+
132
+ return discovered.sort((left, right) => left.name.localeCompare(right.name))
133
+ }
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { discoverComponentProjectComponents } from './discover-components.mjs'
5
+ import {
6
+ resolveComponentProjectGeneratedDocsDir,
7
+ resolveComponentProjectGeneratedManifestPath,
8
+ resolveComponentProjectInstallRoot,
9
+ } from './runtime-root.mjs'
10
+ import { renderComponentProjectMarkdown } from './render-markdown.mjs'
11
+ import { ComponentProjectGeneratedManifestSchema } from './schemas.mjs'
12
+
13
+ async function ensureDirectory(dir) {
14
+ await fs.mkdir(dir, { recursive: true })
15
+ }
16
+
17
+ async function removeStaleGeneratedFiles(manifestPath, nextFiles) {
18
+ try {
19
+ const previous = ComponentProjectGeneratedManifestSchema.parse(
20
+ JSON.parse(await fs.readFile(manifestPath, 'utf8')),
21
+ )
22
+ const staleFiles = previous.files.filter((filePath) => !nextFiles.includes(filePath))
23
+ await Promise.all(staleFiles.map((filePath) => fs.rm(filePath, { force: true })))
24
+ } catch (error) {
25
+ if (error?.code !== 'ENOENT') {
26
+ throw error
27
+ }
28
+ }
29
+ }
30
+
31
+ export async function runComponentProjectPostinstall(projectRoot = resolveComponentProjectInstallRoot()) {
32
+ const docsDir = resolveComponentProjectGeneratedDocsDir(projectRoot)
33
+ const manifestPath = resolveComponentProjectGeneratedManifestPath(projectRoot)
34
+ const components = await discoverComponentProjectComponents(projectRoot)
35
+
36
+ await ensureDirectory(docsDir)
37
+
38
+ const files = []
39
+ for (const component of components) {
40
+ const filePath = path.join(docsDir, `${component.name}.md`)
41
+ await fs.writeFile(filePath, renderComponentProjectMarkdown(component), 'utf8')
42
+ files.push(filePath)
43
+ }
44
+
45
+ await removeStaleGeneratedFiles(manifestPath, files)
46
+
47
+ const manifest = ComponentProjectGeneratedManifestSchema.parse({
48
+ projectRoot,
49
+ generatedAt: new Date().toISOString(),
50
+ docsDir,
51
+ files,
52
+ })
53
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
54
+ }
55
+
56
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)
57
+
58
+ if (isDirectRun) {
59
+ await runComponentProjectPostinstall()
60
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { resolveComponentProjectGeneratedManifestPath } from './runtime-root.mjs'
5
+ import { ComponentProjectGeneratedManifestSchema } from './schemas.mjs'
6
+
7
+ export async function runComponentProjectPreuninstall(
8
+ projectRoot = process.env.INIT_CWD ?? process.env.npm_config_local_prefix ?? process.cwd(),
9
+ ) {
10
+ const manifestPath = resolveComponentProjectGeneratedManifestPath(projectRoot)
11
+
12
+ try {
13
+ const manifest = ComponentProjectGeneratedManifestSchema.parse(
14
+ JSON.parse(await fs.readFile(manifestPath, 'utf8')),
15
+ )
16
+
17
+ for (const filePath of manifest.files) {
18
+ await fs.rm(filePath, { force: true })
19
+ }
20
+
21
+ await fs.rm(manifestPath, { force: true })
22
+ await fs.rm(manifest.docsDir, { recursive: true, force: true })
23
+ } catch (error) {
24
+ if (error?.code !== 'ENOENT') {
25
+ throw error
26
+ }
27
+ }
28
+ }
29
+
30
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)
31
+
32
+ if (isDirectRun) {
33
+ await runComponentProjectPreuninstall()
34
+ }
@@ -0,0 +1,32 @@
1
+ import { ComponentProjectComponentMetadataSchema } from './schemas.mjs'
2
+
3
+ function escapeInline(value) {
4
+ return value.replaceAll('`', '\\`')
5
+ }
6
+
7
+ export function renderComponentProjectMarkdown(input) {
8
+ const metadata = ComponentProjectComponentMetadataSchema.parse(input)
9
+ const tags = metadata.tags.length > 0 ? metadata.tags : ['component']
10
+
11
+ return [
12
+ '---',
13
+ `name: ${metadata.name}`,
14
+ `description: ${metadata.description}`,
15
+ `sourceFile: ${metadata.sourceFile}`,
16
+ `propsTypeName: ${metadata.propsTypeName ?? ''}`,
17
+ 'tags:',
18
+ ...tags.map((tag) => ` - ${tag}`),
19
+ '---',
20
+ '',
21
+ `# ${metadata.name}`,
22
+ '',
23
+ metadata.description,
24
+ '',
25
+ '## Metadata',
26
+ '',
27
+ `- Source file: \`${escapeInline(metadata.sourceFile)}\``,
28
+ `- Props type: \`${escapeInline(metadata.propsTypeName ?? 'unknown')}\``,
29
+ `- Tags: ${tags.map((tag) => `\`${escapeInline(tag)}\``).join(', ')}`,
30
+ '',
31
+ ].join('\n')
32
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'node:path'
2
+ import process from 'node:process'
3
+
4
+ export function resolveComponentProjectInstallRoot() {
5
+ const candidates = [
6
+ process.env.INIT_CWD,
7
+ process.env.npm_config_local_prefix,
8
+ process.cwd(),
9
+ ].filter((value) => Boolean(value && String(value).trim()))
10
+
11
+ return path.resolve(candidates[0] ?? process.cwd())
12
+ }
13
+
14
+ export function resolveComponentProjectGeneratedDocsDir(projectRoot = resolveComponentProjectInstallRoot()) {
15
+ return path.join(
16
+ projectRoot,
17
+ '.agents',
18
+ 'skills',
19
+ 'project-allow-component',
20
+ 'components',
21
+ )
22
+ }
23
+
24
+ export function resolveComponentProjectGeneratedManifestPath(
25
+ projectRoot = resolveComponentProjectInstallRoot(),
26
+ ) {
27
+ return path.join(
28
+ projectRoot,
29
+ '.agents',
30
+ 'skills',
31
+ 'project-allow-component',
32
+ 'component-project-helper.manifest.json',
33
+ )
34
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod'
2
+
3
+ export const ComponentProjectComponentMetadataSchema = z.object({
4
+ name: z.string().min(1),
5
+ description: z.string().min(1),
6
+ sourceFile: z.string().min(1),
7
+ tags: z.array(z.string().min(1)).default([]),
8
+ propsTypeName: z.string().min(1).optional(),
9
+ })
10
+
11
+ export const ComponentProjectGeneratedManifestSchema = z.object({
12
+ projectRoot: z.string().min(1),
13
+ generatedAt: z.string().min(1),
14
+ docsDir: z.string().min(1),
15
+ files: z.array(z.string().min(1)),
16
+ })
@@ -0,0 +1,45 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import {
4
+ VideoComponent,
5
+ defineComponentProjectComponentMetadata,
6
+ getComponentProjectComponentMetadata,
7
+ } from './index'
8
+
9
+ test('attaches normalized metadata to decorated function components', () => {
10
+ const metadata = defineComponentProjectComponentMetadata({
11
+ name: 'SceneFrame',
12
+ description: 'Wraps the scene content in a consistent frame',
13
+ sourceFile: 'src/components/SceneFrame.tsx',
14
+ aspectRatio: '16:9',
15
+ sceneType: 'scene',
16
+ tags: ['layout', 'frame'],
17
+ propsTypeName: 'SceneFrameProps',
18
+ })
19
+
20
+ const SceneFrame = () => null
21
+
22
+ const decorated = VideoComponent(metadata)(SceneFrame)
23
+
24
+ expect(decorated).toBe(SceneFrame)
25
+ expect(getComponentProjectComponentMetadata(SceneFrame)).toEqual(metadata)
26
+ })
27
+
28
+ test('attaches normalized metadata to decorated function components', () => {
29
+ const metadata = defineComponentProjectComponentMetadata({
30
+ name: 'SceneTitle',
31
+ description: 'Renders the title block for scene hero content',
32
+ sourceFile: 'src/components/SceneTitle.tsx',
33
+ aspectRatio: '16:9',
34
+ sceneType: 'intro',
35
+ tags: ['title', 'hero'],
36
+ propsTypeName: 'SceneTitleProps',
37
+ })
38
+
39
+ const SceneTitle = () => null
40
+
41
+ const decorated = VideoComponent(metadata)(SceneTitle)
42
+
43
+ expect(decorated).toBe(SceneTitle)
44
+ expect(getComponentProjectComponentMetadata(SceneTitle)).toEqual(metadata)
45
+ })
@@ -0,0 +1,40 @@
1
+ import 'reflect-metadata'
2
+
3
+ import type { ComponentType } from 'react'
4
+
5
+ import {
6
+ ComponentProjectComponentMetadataSchema,
7
+ type ComponentProjectComponentMetadata,
8
+ } from '../schemas'
9
+
10
+ export const COMPONENT_PROJECT_METADATA_KEY = Symbol.for(
11
+ '@vibecuting/component-project-helper/component-metadata',
12
+ )
13
+
14
+ export function defineComponentProjectComponentMetadata(
15
+ metadata: ComponentProjectComponentMetadata,
16
+ ): ComponentProjectComponentMetadata {
17
+ return ComponentProjectComponentMetadataSchema.parse(metadata)
18
+ }
19
+
20
+ export function VideoComponent(metadata: ComponentProjectComponentMetadata) {
21
+ const normalized = defineComponentProjectComponentMetadata(metadata)
22
+
23
+ return function componentProjectDecorator<T extends ComponentType<any>>(target: T): T {
24
+ Reflect.defineMetadata(COMPONENT_PROJECT_METADATA_KEY, normalized, target)
25
+ return target
26
+ }
27
+ }
28
+
29
+ export function getComponentProjectComponentMetadata(
30
+ target: unknown,
31
+ ): ComponentProjectComponentMetadata | undefined {
32
+ if (!target || (typeof target !== 'object' && typeof target !== 'function')) {
33
+ return undefined
34
+ }
35
+
36
+ return Reflect.getMetadata(
37
+ COMPONENT_PROJECT_METADATA_KEY,
38
+ target,
39
+ ) as ComponentProjectComponentMetadata | undefined
40
+ }
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ ComponentProjectComponentMetadataSchema,
6
+ type ComponentProjectComponentMetadata,
7
+ } from '../schemas'
8
+
9
+ export type ComponentProjectComponentDiscovery = ComponentProjectComponentMetadata
10
+
11
+ function extractBalancedObjectLiteral(source: string, startIndex: number): string | undefined {
12
+ let depth = 0
13
+ let inString = false
14
+ let stringQuote = ''
15
+ let escaped = false
16
+
17
+ for (let index = startIndex; index < source.length; index += 1) {
18
+ const character = source[index]
19
+
20
+ if (inString) {
21
+ if (escaped) {
22
+ escaped = false
23
+ continue
24
+ }
25
+
26
+ if (character === '\\') {
27
+ escaped = true
28
+ continue
29
+ }
30
+
31
+ if (character === stringQuote) {
32
+ inString = false
33
+ }
34
+
35
+ continue
36
+ }
37
+
38
+ if (character === '"' || character === "'" || character === '`') {
39
+ inString = true
40
+ stringQuote = character
41
+ continue
42
+ }
43
+
44
+ if (character === '{') {
45
+ depth += 1
46
+ continue
47
+ }
48
+
49
+ if (character === '}') {
50
+ depth -= 1
51
+ if (depth === 0) {
52
+ return source.slice(startIndex, index + 1)
53
+ }
54
+ }
55
+ }
56
+
57
+ return undefined
58
+ }
59
+
60
+ function parseMetadataLiteral(literal: string): ComponentProjectComponentMetadata | undefined {
61
+ try {
62
+ const value = Function(`"use strict"; return (${literal});`)()
63
+ const parsed = ComponentProjectComponentMetadataSchema.safeParse(value)
64
+ return parsed.success ? parsed.data : undefined
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ function readMetadataFromSource(source: string): ComponentProjectComponentMetadata | undefined {
71
+ const candidates = [
72
+ /componentMetadata\s*=\s*defineComponentProjectComponentMetadata\s*\(/m,
73
+ /componentMetadata\s*=\s*VideoComponent\s*\(/m,
74
+ /componentMetadata\s*=\s*/m,
75
+ ]
76
+
77
+ for (const pattern of candidates) {
78
+ const match = pattern.exec(source)
79
+ if (!match) {
80
+ continue
81
+ }
82
+
83
+ const startIndex = source.indexOf('{', match.index)
84
+ if (startIndex < 0) {
85
+ continue
86
+ }
87
+
88
+ const literal = extractBalancedObjectLiteral(source, startIndex)
89
+ if (!literal) {
90
+ continue
91
+ }
92
+
93
+ const parsed = parseMetadataLiteral(literal)
94
+ if (parsed) {
95
+ return parsed
96
+ }
97
+ }
98
+
99
+ const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
100
+ if (decoratorMatch) {
101
+ return parseMetadataLiteral(decoratorMatch[1])
102
+ }
103
+
104
+ return undefined
105
+ }
106
+
107
+ export async function discoverComponentProjectComponents(
108
+ projectRoot: string,
109
+ ): 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
+ const discovered: ComponentProjectComponentDiscovery[] = []
120
+
121
+ for (const entry of entries) {
122
+ if (!entry.endsWith('.tsx')) {
123
+ continue
124
+ }
125
+
126
+ const absolutePath = path.join(componentsDir, entry)
127
+ const source = await fs.readFile(absolutePath, 'utf8')
128
+ const metadata = readMetadataFromSource(source)
129
+ if (!metadata) {
130
+ continue
131
+ }
132
+
133
+ discovered.push({
134
+ ...metadata,
135
+ sourceFile: path.relative(projectRoot, absolutePath),
136
+ })
137
+ }
138
+
139
+ return discovered.sort((left, right) => left.name.localeCompare(right.name))
140
+ }
@@ -0,0 +1,7 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import { componentProjectHelperPackageName } from './index'
4
+
5
+ test('exports the package name', () => {
6
+ expect(componentProjectHelperPackageName).toBe('@vibecuting/component-project-helper')
7
+ })
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export {
2
+ VideoComponent,
3
+ defineComponentProjectComponentMetadata,
4
+ getComponentProjectComponentMetadata,
5
+ } from './decorators'
6
+ export {
7
+ ComponentProjectComponentMetadataSchema,
8
+ ComponentProjectGeneratedManifestSchema,
9
+ } from './schemas'
10
+ export {
11
+ discoverComponentProjectComponents,
12
+ type ComponentProjectComponentDiscovery,
13
+ } from './discovery'
14
+ export {
15
+ renderComponentProjectMarkdown,
16
+ type ComponentProjectMarkdownDocument,
17
+ } from './markdown'
18
+ export {
19
+ resolveComponentProjectGeneratedDocsDir,
20
+ resolveComponentProjectGeneratedManifestPath,
21
+ resolveComponentProjectInstallRoot,
22
+ } from './runtime'
23
+ export { runComponentProjectPostinstall } from './lifecycle/postinstall'
24
+ export { runComponentProjectPreuninstall } from './lifecycle/preuninstall'
25
+
26
+ export const componentProjectHelperPackageName = '@vibecuting/component-project-helper' as const
@@ -0,0 +1,79 @@
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 { runComponentProjectPostinstall } from './postinstall'
8
+ import { runComponentProjectPreuninstall } from './preuninstall'
9
+
10
+ async function createFixtureProjectRoot(): Promise<string> {
11
+ const projectRoot = await fs.mkdtemp(path.join(tmpdir(), 'component-project-helper-'))
12
+ await fs.mkdir(path.join(projectRoot, 'src', 'components'), { recursive: true })
13
+
14
+ await fs.writeFile(
15
+ path.join(projectRoot, 'src', 'components', 'SceneFrame.tsx'),
16
+ `export const componentMetadata = {
17
+ name: 'SceneFrame',
18
+ description: 'Wraps the scene content in a consistent frame',
19
+ sourceFile: 'src/components/SceneFrame.tsx',
20
+ aspectRatio: '16:9',
21
+ sceneType: 'scene',
22
+ tags: ['layout', 'frame'],
23
+ propsTypeName: 'SceneFrameProps',
24
+ } as const
25
+ `,
26
+ 'utf8',
27
+ )
28
+
29
+ await fs.writeFile(
30
+ path.join(projectRoot, 'src', 'components', 'SceneTitle.tsx'),
31
+ `export const componentMetadata = {
32
+ name: 'SceneTitle',
33
+ description: 'Renders the title block for scene hero content',
34
+ sourceFile: 'src/components/SceneTitle.tsx',
35
+ aspectRatio: '16:9',
36
+ sceneType: 'intro',
37
+ tags: ['title'],
38
+ propsTypeName: 'SceneTitleProps',
39
+ } as const
40
+ `,
41
+ 'utf8',
42
+ )
43
+
44
+ return projectRoot
45
+ }
46
+
47
+ test('postinstall generates markdown and preuninstall removes it', async () => {
48
+ const projectRoot = await createFixtureProjectRoot()
49
+ const docsDir = path.join(
50
+ projectRoot,
51
+ '.agents',
52
+ 'skills',
53
+ 'project-allow-component',
54
+ 'components',
55
+ )
56
+ const manifestPath = path.join(
57
+ projectRoot,
58
+ '.agents',
59
+ 'skills',
60
+ 'project-allow-component',
61
+ 'component-project-helper.manifest.json',
62
+ )
63
+
64
+ await runComponentProjectPostinstall(projectRoot)
65
+
66
+ await expect(fs.stat(path.join(docsDir, 'SceneFrame.md'))).resolves.toBeDefined()
67
+ await expect(fs.stat(path.join(docsDir, 'SceneTitle.md'))).resolves.toBeDefined()
68
+ await expect(fs.stat(manifestPath)).resolves.toBeDefined()
69
+
70
+ await runComponentProjectPreuninstall(projectRoot)
71
+
72
+ await expect(fs.stat(path.join(docsDir, 'SceneFrame.md'))).rejects.toMatchObject({
73
+ code: 'ENOENT',
74
+ })
75
+ await expect(fs.stat(path.join(docsDir, 'SceneTitle.md'))).rejects.toMatchObject({
76
+ code: 'ENOENT',
77
+ })
78
+ await expect(fs.stat(manifestPath)).rejects.toMatchObject({ code: 'ENOENT' })
79
+ })
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { ComponentProjectGeneratedManifestSchema } from '../schemas'
6
+ import { discoverComponentProjectComponents } from '../discovery'
7
+ import { renderComponentProjectMarkdown } from '../markdown'
8
+ import {
9
+ resolveComponentProjectGeneratedDocsDir,
10
+ resolveComponentProjectGeneratedManifestPath,
11
+ resolveComponentProjectInstallRoot,
12
+ } from '../runtime'
13
+
14
+ async function ensureDirectory(dir: string): Promise<void> {
15
+ await fs.mkdir(dir, { recursive: true })
16
+ }
17
+
18
+ async function removeStaleGeneratedFiles(
19
+ manifestPath: string,
20
+ nextFiles: string[],
21
+ ): Promise<void> {
22
+ try {
23
+ const previous = ComponentProjectGeneratedManifestSchema.parse(
24
+ JSON.parse(await fs.readFile(manifestPath, 'utf8')),
25
+ )
26
+ const staleFiles = previous.files.filter((filePath) => !nextFiles.includes(filePath))
27
+ await Promise.all(staleFiles.map((filePath) => fs.rm(filePath, { force: true })))
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
30
+ throw error
31
+ }
32
+ }
33
+ }
34
+
35
+ export async function runComponentProjectPostinstall(
36
+ projectRoot = resolveComponentProjectInstallRoot(),
37
+ ): Promise<void> {
38
+ const docsDir = resolveComponentProjectGeneratedDocsDir(projectRoot)
39
+ const manifestPath = resolveComponentProjectGeneratedManifestPath(projectRoot)
40
+ const components = await discoverComponentProjectComponents(projectRoot)
41
+
42
+ await ensureDirectory(docsDir)
43
+
44
+ const files: string[] = []
45
+
46
+ for (const component of components) {
47
+ const filePath = path.join(docsDir, `${component.name}.md`)
48
+ const markdown = renderComponentProjectMarkdown(component)
49
+ await fs.writeFile(filePath, markdown, 'utf8')
50
+ files.push(filePath)
51
+ }
52
+
53
+ await removeStaleGeneratedFiles(manifestPath, files)
54
+
55
+ const manifest = ComponentProjectGeneratedManifestSchema.parse({
56
+ projectRoot,
57
+ generatedAt: new Date().toISOString(),
58
+ docsDir,
59
+ files,
60
+ })
61
+
62
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
63
+ }
64
+
65
+ async function main(): Promise<void> {
66
+ await runComponentProjectPostinstall()
67
+ }
68
+
69
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
70
+ await main()
71
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { ComponentProjectGeneratedManifestSchema } from '../schemas'
6
+ import { resolveComponentProjectGeneratedManifestPath } from '../runtime'
7
+
8
+ export async function runComponentProjectPreuninstall(
9
+ projectRoot = process.env.INIT_CWD ??
10
+ process.env.npm_config_local_prefix ??
11
+ process.cwd(),
12
+ ): Promise<void> {
13
+ const manifestPath = resolveComponentProjectGeneratedManifestPath(projectRoot)
14
+
15
+ try {
16
+ const manifest = ComponentProjectGeneratedManifestSchema.parse(
17
+ JSON.parse(await fs.readFile(manifestPath, 'utf8')),
18
+ )
19
+
20
+ for (const filePath of manifest.files) {
21
+ await fs.rm(filePath, { force: true })
22
+ }
23
+
24
+ await fs.rm(manifestPath, { force: true })
25
+ await fs.rm(manifest.docsDir, { recursive: true, force: true })
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
28
+ throw error
29
+ }
30
+ }
31
+ }
32
+
33
+ async function main(): Promise<void> {
34
+ await runComponentProjectPreuninstall()
35
+ }
36
+
37
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
38
+ await main()
39
+ }
@@ -0,0 +1,20 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import { renderComponentProjectMarkdown } from './index'
4
+
5
+ test('renders markdown with metadata fields', () => {
6
+ const markdown = renderComponentProjectMarkdown({
7
+ name: 'SceneFrame',
8
+ description: 'Wraps the scene content in a consistent frame',
9
+ sourceFile: 'src/components/SceneFrame.tsx',
10
+ aspectRatio: '16:9',
11
+ sceneType: 'scene',
12
+ tags: ['layout', 'frame'],
13
+ propsTypeName: 'SceneFrameProps',
14
+ })
15
+
16
+ expect(markdown).toContain('# SceneFrame')
17
+ expect(markdown).toContain('SceneFrameProps')
18
+ expect(markdown).toContain('src/components/SceneFrame.tsx')
19
+ expect(markdown).toContain('16:9')
20
+ })
@@ -0,0 +1,43 @@
1
+ import {
2
+ ComponentProjectComponentMetadataSchema,
3
+ type ComponentProjectComponentMetadata,
4
+ } from '../schemas'
5
+
6
+ export type ComponentProjectMarkdownDocument = ComponentProjectComponentMetadata
7
+
8
+ function escapeInline(value: string): string {
9
+ return value.replaceAll('`', '\\`')
10
+ }
11
+
12
+ export function renderComponentProjectMarkdown(
13
+ input: ComponentProjectMarkdownDocument,
14
+ ): string {
15
+ const metadata = ComponentProjectComponentMetadataSchema.parse(input)
16
+ const tags = metadata.tags.length > 0 ? metadata.tags : ['component']
17
+
18
+ return [
19
+ '---',
20
+ `name: ${metadata.name}`,
21
+ `description: ${metadata.description}`,
22
+ `sourceFile: ${metadata.sourceFile}`,
23
+ `aspectRatio: ${metadata.aspectRatio}`,
24
+ `sceneType: ${metadata.sceneType ?? ''}`,
25
+ `propsTypeName: ${metadata.propsTypeName ?? ''}`,
26
+ 'tags:',
27
+ ...tags.map((tag) => ` - ${tag}`),
28
+ '---',
29
+ '',
30
+ `# ${metadata.name}`,
31
+ '',
32
+ metadata.description,
33
+ '',
34
+ '## Metadata',
35
+ '',
36
+ `- Source file: \`${escapeInline(metadata.sourceFile)}\``,
37
+ `- Aspect ratio: \`${escapeInline(metadata.aspectRatio)}\``,
38
+ `- Scene type: \`${escapeInline(metadata.sceneType ?? 'unknown')}\``,
39
+ `- Props type: \`${escapeInline(metadata.propsTypeName ?? 'unknown')}\``,
40
+ `- Tags: ${tags.map((tag) => `\`${escapeInline(tag)}\``).join(', ')}`,
41
+ '',
42
+ ].join('\n')
43
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'node:path'
2
+ import process from 'node:process'
3
+
4
+ export function resolveComponentProjectInstallRoot(): string {
5
+ const candidates = [
6
+ process.env.INIT_CWD,
7
+ process.env.npm_config_local_prefix,
8
+ process.cwd(),
9
+ ].filter((value): value is string => Boolean(value && value.trim()))
10
+
11
+ return path.resolve(candidates[0] ?? process.cwd())
12
+ }
13
+
14
+ export function resolveComponentProjectGeneratedDocsDir(
15
+ projectRoot = resolveComponentProjectInstallRoot(),
16
+ ): string {
17
+ return path.join(
18
+ projectRoot,
19
+ '.agents',
20
+ 'skills',
21
+ 'project-allow-component',
22
+ 'components',
23
+ )
24
+ }
25
+
26
+ export function resolveComponentProjectGeneratedManifestPath(
27
+ projectRoot = resolveComponentProjectInstallRoot(),
28
+ ): string {
29
+ return path.join(
30
+ projectRoot,
31
+ '.agents',
32
+ 'skills',
33
+ 'project-allow-component',
34
+ 'component-project-helper.manifest.json',
35
+ )
36
+ }
@@ -0,0 +1,17 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import { ComponentProjectComponentMetadataSchema } from './index'
4
+
5
+ test('validates component metadata', () => {
6
+ const metadata = ComponentProjectComponentMetadataSchema.parse({
7
+ name: 'SceneFrame',
8
+ description: 'Wraps the scene content in a consistent frame',
9
+ sourceFile: 'src/components/SceneFrame.tsx',
10
+ aspectRatio: '16:9',
11
+ sceneType: 'scene',
12
+ tags: ['layout', 'frame'],
13
+ propsTypeName: 'SceneFrameProps',
14
+ })
15
+
16
+ expect(metadata.tags).toEqual(['layout', 'frame'])
17
+ })
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod'
2
+
3
+ export const ComponentProjectComponentMetadataSchema = z.object({
4
+ name: z.string().min(1),
5
+ description: z.string().min(1),
6
+ sourceFile: z.string().min(1),
7
+ aspectRatio: z.enum(['9:16', '16:9']),
8
+ sceneType: z.enum(['intro', 'scene', 'outro']).optional(),
9
+ tags: z.array(z.string().min(1)).default([]),
10
+ propsTypeName: z.string().min(1).optional(),
11
+ })
12
+
13
+ export const ComponentProjectGeneratedManifestSchema = z.object({
14
+ projectRoot: z.string().min(1),
15
+ generatedAt: z.string().min(1),
16
+ docsDir: z.string().min(1),
17
+ files: z.array(z.string().min(1)),
18
+ })
19
+
20
+ export type ComponentProjectComponentMetadata = z.infer<
21
+ typeof ComponentProjectComponentMetadataSchema
22
+ >
23
+ export type ComponentProjectGeneratedManifest = z.infer<
24
+ typeof ComponentProjectGeneratedManifestSchema
25
+ >