import fs from 'node:fs/promises' import path from 'node:path' import ts from 'typescript' import { evaluateStaticExpression } from '../discovery/index.ts' import { resolveComponentProjectGeneratedDocsDir, resolveComponentProjectGeneratedManifestPath, } from '../runtime/index.ts' type VideoResourceMetaRecord = { packageName: string exportName: string metadata: { resourceKind: 'scene' | 'transition' | 'theme' name: string description: string sourceFile: string pluginKey: string tags: string[] aspectRatio?: '9:16' | '16:9' sceneType?: 'intro' | 'scene' | 'outro' motion: 'static' | 'animated' sceneFamily?: string themePreset?: string slots?: Array propsTypeName?: string rootLayout?: 'absolute-fill' transitionKind?: 'transition' | 'overlay' supportedSceneFamilies?: string[] cssVariables?: string[] } } type VideoResourceMetaFile = { videoResourceMeta?: { packageFingerprint?: string packages?: Array<{ packageName: string packageVersion: string }> resources: VideoResourceMetaRecord[] } } type GeneratedSkillDoc = { filePath: string contents: string } function stringifyFrontmatterList(items: readonly string[]): string { return items.map((item) => ` - ${item}`).join('\n') } function stringifySlots( slots: NonNullable, ): string { if (slots.length === 0) { return '[]' } return slots .map((slot) => { if (typeof slot === 'string') { return ` - ${slot}` } const lines = [ ` - name: ${slot.name}`, slot.description ? ` description: ${slot.description}` : undefined, slot.required !== undefined ? ` required: ${slot.required}` : undefined, slot.accepts ? ` accepts: ${slot.accepts}` : undefined, ].filter(Boolean) return lines.join('\n') }) .join('\n') } function sanitizeFileName(name: string): string { return name.replace(/[^a-zA-Z0-9._-]+/g, '-') } function toRelativePath(projectRoot: string, absolutePath: string): string { return path.relative(projectRoot, absolutePath) || '.' } function getMetaFilePath(projectRoot: string): string { return path.join(projectRoot, '.vibecuting', 'video-resource-meta.generated.ts') } async function readGeneratedVideoResourceMeta(projectRoot: string): Promise> { const metaFilePath = getMetaFilePath(projectRoot) const sourceText = await fs.readFile(metaFilePath, 'utf8') const sourceFile = ts.createSourceFile(metaFilePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement) || !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) { continue } for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'videoResourceMeta') { continue } if (!declaration.initializer) { throw new Error(`${metaFilePath}: missing videoResourceMeta initializer`) } const videoResourceMeta = evaluateStaticExpression(declaration.initializer, metaFilePath) as VideoResourceMetaFile['videoResourceMeta'] if (!videoResourceMeta) { throw new Error(`${metaFilePath}: missing videoResourceMeta export`) } return videoResourceMeta } } throw new Error(`${metaFilePath}: missing videoResourceMeta export`) } function renderSceneMetadata(record: VideoResourceMetaRecord): string { const { metadata } = record const slots = metadata.slots ? stringifySlots(metadata.slots) : '[]' return [ `- resourceKind: ${metadata.resourceKind}`, ` aspectRatio: ${metadata.aspectRatio ?? ''}`, ` sceneType: ${metadata.sceneType ?? ''}`, ` motion: ${metadata.motion}`, ` sceneFamily: ${metadata.sceneFamily ?? ''}`, ` themePreset: ${metadata.themePreset ?? ''}`, ` rootLayout: ${metadata.rootLayout ?? ''}`, ` propsTypeName: ${metadata.propsTypeName ?? ''}`, ` slots:`, slots, ].join('\n') } function renderTransitionMetadata(record: VideoResourceMetaRecord): string { const { metadata } = record return [ `- resourceKind: ${metadata.resourceKind}`, ` transitionKind: ${metadata.transitionKind ?? ''}`, ].join('\n') } function renderThemeMetadata(record: VideoResourceMetaRecord): string { const { metadata } = record const supportedSceneFamilies = metadata.supportedSceneFamilies ?? [] const cssVariables = metadata.cssVariables ?? [] return [ `- resourceKind: ${metadata.resourceKind}`, ` supportedSceneFamilies:`, supportedSceneFamilies.length > 0 ? stringifyFrontmatterList(supportedSceneFamilies) : ' []', ` cssVariables:`, cssVariables.length > 0 ? stringifyFrontmatterList(cssVariables) : ' []', ].join('\n') } function buildDocContents(record: VideoResourceMetaRecord): string { const { metadata, exportName } = record const tags = metadata.tags ?? [] const bodyMetadata = metadata.resourceKind === 'scene' ? renderSceneMetadata(record) : metadata.resourceKind === 'transition' ? renderTransitionMetadata(record) : renderThemeMetadata(record) return [ '---', `name: ${metadata.name}`, `resourceKind: ${metadata.resourceKind}`, `description: ${metadata.description}`, `sourceFile: ${metadata.sourceFile}`, `pluginKey: ${metadata.pluginKey}`, `tags:`, tags.length > 0 ? stringifyFrontmatterList(tags) : ' []', '---', '', `# ${exportName}`, '', metadata.description, '', '## 元数据', '', `- 资源类型: \`${metadata.resourceKind}\``, `- 源文件: \`${metadata.sourceFile}\``, `- 插件键: \`${metadata.pluginKey}\``, `- 标签: ${tags.length > 0 ? tags.map((tag) => `\`${tag}\``).join(', ') : '[]'}`, '', bodyMetadata, '', ].join('\n') } async function writeTextFile(filePath: string, contents: string): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }) const tempPath = `${filePath}.tmp` await fs.writeFile(tempPath, `${contents}\n`, 'utf8') await fs.rename(tempPath, filePath) } async function readOptionalFile(filePath: string): Promise { try { return await fs.readFile(filePath, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return undefined } throw error } } export async function updateComponentSkills( projectRoot: string, checkOnly = false, ): Promise { const videoResourceMeta = await readGeneratedVideoResourceMeta(projectRoot) const docsDir = resolveComponentProjectGeneratedDocsDir(projectRoot) const manifestPath = resolveComponentProjectGeneratedManifestPath(projectRoot) const resources = [...videoResourceMeta.resources].sort((left, right) => { return ( left.metadata.resourceKind.localeCompare(right.metadata.resourceKind) || left.metadata.pluginKey.localeCompare(right.metadata.pluginKey) || left.exportName.localeCompare(right.exportName) ) }) const generatedDocs: GeneratedSkillDoc[] = resources.map((record) => { const filePath = path.join(docsDir, `${sanitizeFileName(record.exportName)}.md`) return { filePath, contents: buildDocContents(record) } }) const manifestContents = JSON.stringify( { projectRoot: '.', docsDir: toRelativePath(projectRoot, docsDir), sourceMetaFile: toRelativePath(projectRoot, getMetaFilePath(projectRoot)), packageFingerprint: videoResourceMeta.packageFingerprint ?? null, files: generatedDocs.map((doc) => toRelativePath(projectRoot, doc.filePath)), resources: resources.map((resource) => ({ packageName: resource.packageName, exportName: resource.exportName, resourceKind: resource.metadata.resourceKind, pluginKey: resource.metadata.pluginKey, })), }, null, 2, ) if (checkOnly) { const currentManifest = await readOptionalFile(manifestPath) if (currentManifest?.trimEnd() !== `${manifestContents}\n`.trimEnd()) { throw new Error('Generated component skill manifest is stale. Run pnpm run update-component-skills') } for (const doc of generatedDocs) { const currentDoc = await readOptionalFile(doc.filePath) if (currentDoc?.trimEnd() !== doc.contents.trimEnd()) { throw new Error(`Generated component skill doc is stale: ${doc.filePath}`) } } return } await writeTextFile(manifestPath, manifestContents) await fs.mkdir(docsDir, { recursive: true }) const desiredFiles = new Set(generatedDocs.map((doc) => doc.filePath)) await Promise.all( generatedDocs.map((doc) => writeTextFile(doc.filePath, doc.contents)), ) const existingEntries = await fs.readdir(docsDir, { withFileTypes: true }) await Promise.all( existingEntries .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) .map(async (entry) => { const filePath = path.join(docsDir, entry.name) if (desiredFiles.has(filePath)) { return } await fs.unlink(filePath) }), ) } if (import.meta.url === `file://${process.argv[1]}`) { const checkOnly = process.argv.includes('--check') const cwd = process.cwd() updateComponentSkills(cwd, checkOnly).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error) console.error(message) process.exitCode = 1 }) }