import crypto from 'node:crypto' import fs from 'node:fs/promises' import type { Dirent } from 'node:fs' import path from 'node:path' import ts from 'typescript' import { type ScenePluginMetadata, type ThemePluginMetadata, type TransitionPluginMetadata, type VideoResourceDescriptor, type VideoResourceMetadata, defineScenePluginMetadata, ScenePluginMetadataSchema, } from '../resources/video-resource.ts' import { sceneResourceDescriptor, themeResourceDescriptor, transitionResourceDescriptor, videoResourceDescriptors, } from '../resources/resource-descriptors.ts' import { ComponentProjectComponentMetadataSchema } from '../schemas/index.ts' const SUPPORTED_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']) export type DiscoveredVideoResource = { packageName: string packageVersion: string packageRoot: string sourceFile: string exportName: string annotationName: string metadataFactoryName: string metadata: VideoResourceMetadata sourceDigest: string } type DescriptorMap = Record> const descriptorMap: DescriptorMap = { [sceneResourceDescriptor.annotationName]: sceneResourceDescriptor as VideoResourceDescriptor, [transitionResourceDescriptor.annotationName]: transitionResourceDescriptor as VideoResourceDescriptor, [themeResourceDescriptor.annotationName]: themeResourceDescriptor as VideoResourceDescriptor, } const metadataFactoryMap: DescriptorMap = { [sceneResourceDescriptor.metadataFactoryName]: sceneResourceDescriptor as VideoResourceDescriptor, ['defineComponentProjectComponentMetadata']: sceneResourceDescriptor as VideoResourceDescriptor, [transitionResourceDescriptor.metadataFactoryName]: transitionResourceDescriptor as VideoResourceDescriptor, [themeResourceDescriptor.metadataFactoryName]: themeResourceDescriptor as VideoResourceDescriptor, } const staticMetadataFactoryNames = new Set([ sceneResourceDescriptor.metadataFactoryName, transitionResourceDescriptor.metadataFactoryName, themeResourceDescriptor.metadataFactoryName, 'defineComponentProjectComponentMetadata', ]) function toScriptKind(filePath: string): ts.ScriptKind { if (filePath.endsWith('.tsx')) { return ts.ScriptKind.TSX } if (filePath.endsWith('.jsx')) { return ts.ScriptKind.JSX } if (filePath.endsWith('.mjs')) { return ts.ScriptKind.JS } if (filePath.endsWith('.cjs')) { return ts.ScriptKind.JS } return ts.ScriptKind.TS } function getIdentifierName(node: ts.Expression): string | undefined { if (ts.isIdentifier(node)) { return node.text } if (ts.isPropertyAccessExpression(node)) { return node.name.text } return undefined } function unwrapExpression(node: ts.Expression): ts.Expression { let current = node for (;;) { if (ts.isParenthesizedExpression(current)) { current = current.expression continue } if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { current = current.expression continue } if (ts.isSatisfiesExpression(current)) { current = current.expression continue } return current } } export function evaluateStaticExpression(node: ts.Expression, filePath: string): unknown { const expression = unwrapExpression(node) if (ts.isStringLiteralLike(expression)) { return expression.text } if (ts.isNoSubstitutionTemplateLiteral(expression)) { return expression.text } if (ts.isNumericLiteral(expression)) { return Number(expression.text) } if (expression.kind === ts.SyntaxKind.TrueKeyword) { return true } if (expression.kind === ts.SyntaxKind.FalseKeyword) { return false } if (expression.kind === ts.SyntaxKind.NullKeyword) { return null } if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.MinusToken) { const value = evaluateStaticExpression(expression.operand, filePath) if (typeof value === 'number') { return -value } } if (ts.isArrayLiteralExpression(expression)) { return expression.elements.map((element) => { if (ts.isSpreadElement(element)) { throw new Error(`${filePath}: spread elements are not supported in static metadata`) } return evaluateStaticExpression(element as ts.Expression, filePath) }) } if (ts.isObjectLiteralExpression(expression)) { const result: Record = {} for (const property of expression.properties) { if (ts.isSpreadAssignment(property)) { throw new Error(`${filePath}: spread assignments are not supported in static metadata`) } if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) { throw new Error(`${filePath}: unsupported object literal property in static metadata`) } const key = ts.isPropertyAssignment(property) ? property.name : property.name let propertyName: string | undefined if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) { propertyName = key.text } else if (ts.isNumericLiteral(key)) { propertyName = key.text } if (!propertyName) { throw new Error(`${filePath}: computed metadata keys are not supported`) } const value = ts.isPropertyAssignment(property) ? evaluateStaticExpression(property.initializer, filePath) : property.name.text result[propertyName] = value } return result } if (ts.isCallExpression(expression)) { const factoryName = getIdentifierName(expression.expression) if (factoryName && staticMetadataFactoryNames.has(factoryName)) { const firstArgument = expression.arguments[0] if (!firstArgument || !ts.isExpression(firstArgument)) { throw new Error(`${filePath}: static metadata factory ${factoryName} requires a single object literal argument`) } return evaluateStaticExpression(firstArgument, filePath) } } if (ts.isIdentifier(expression) && expression.text === 'undefined') { return undefined } throw new Error(`${filePath}: unsupported static metadata expression: ${expression.getText()}`) } function parseSourceFile(filePath: string, sourceText: string): ts.SourceFile { return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, toScriptKind(filePath)) } function isExported(node: ts.Node): boolean { if (!ts.canHaveModifiers(node)) { return false } return Boolean( ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword), ) } async function collectFiles(directory: string): Promise { let entries: Dirent[] = [] try { entries = await fs.readdir(directory, { withFileTypes: true }) } catch { return [] } const files: string[] = [] for (const entry of entries) { if (entry.name === 'node_modules' || entry.name === '.git') { continue } const absolutePath = path.join(directory, entry.name) if (entry.isDirectory()) { files.push(...(await collectFiles(absolutePath))) continue } if (!entry.isFile()) { continue } if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.map')) { continue } if (entry.name.includes('.test.') || entry.name.includes('.spec.')) { continue } if (SUPPORTED_EXTENSIONS.has(path.extname(entry.name))) { files.push(absolutePath) } } return files } async function resolveSourceRoot(packageRoot: string): Promise { const srcRoot = path.join(packageRoot, 'src') const distRoot = path.join(packageRoot, 'dist') try { const stat = await fs.stat(srcRoot) if (stat.isDirectory()) { return srcRoot } } catch { // ignore } try { const stat = await fs.stat(distRoot) if (stat.isDirectory()) { return distRoot } } catch { // ignore } return packageRoot } async function collectInstalledPackages(projectRoot: string): Promise { const namespaceRoot = path.join(projectRoot, 'node_modules', '@vibecuting') let entries: Dirent[] = [] try { entries = await fs.readdir(namespaceRoot, { withFileTypes: true }) } catch { return [] } return entries .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) .map((entry) => path.join(namespaceRoot, entry.name)) .sort((left, right) => left.localeCompare(right)) } function collectMetadataVariables(sourceFile: ts.SourceFile): Map { const result = new Map() sourceFile.forEachChild((node) => { if (!ts.isVariableStatement(node)) { return } for (const declaration of node.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { continue } const initializer = unwrapExpression(declaration.initializer) if (!ts.isCallExpression(initializer)) { continue } const factoryName = getIdentifierName(initializer.expression) if (!factoryName) { continue } if (!metadataFactoryMap[factoryName]) { continue } const metadataExpression = initializer.arguments[0] if (!metadataExpression || !ts.isExpression(metadataExpression)) { continue } result.set(declaration.name.text, metadataExpression) } }) return result } function normalizeSceneMetadata(value: unknown, filePath: string): ScenePluginMetadata { const modern = ScenePluginMetadataSchema.safeParse(value) if (modern.success) { return defineScenePluginMetadata(modern.data) } const legacy = ComponentProjectComponentMetadataSchema.safeParse(value) if (!legacy.success) { throw new Error(`${filePath}: invalid scene metadata`) } return defineScenePluginMetadata({ resourceKind: 'scene', name: legacy.data.name, description: legacy.data.description, sourceFile: legacy.data.sourceFile, pluginKey: legacy.data.name, tags: legacy.data.tags, aspectRatio: legacy.data.aspectRatio, sceneType: legacy.data.sceneType, motion: legacy.data.motion, sceneFamily: 'custom', rootLayout: 'absolute-fill', propsTypeName: legacy.data.propsTypeName, }) } function getExportNameFromDeclaration(node: ts.Node): string | undefined { if (ts.isVariableStatement(node) && isExported(node)) { const declaration = node.declarationList.declarations[0] if (declaration && ts.isIdentifier(declaration.name)) { return declaration.name.text } } if (ts.isFunctionDeclaration(node) && isExported(node) && node.name) { return node.name.text } if (ts.isClassDeclaration(node) && isExported(node) && node.name) { return node.name.text } return undefined } function extractAnnotationCall(node: ts.Expression): { annotationName: string metadataExpression: ts.Expression | undefined targetExpression: ts.Expression } | undefined { const outerCall = unwrapExpression(node) if (!ts.isCallExpression(outerCall)) { return undefined } const targetExpression = outerCall.arguments[0] if (!targetExpression || !ts.isExpression(targetExpression)) { return undefined } const innerCall = unwrapExpression(outerCall.expression) if (!ts.isCallExpression(innerCall)) { return undefined } const annotationName = getIdentifierName(innerCall.expression) if (!annotationName) { return undefined } const metadataExpression = innerCall.arguments[0] return { annotationName, metadataExpression: metadataExpression && ts.isExpression(metadataExpression) ? metadataExpression : undefined, targetExpression, } } function validateSceneTarget( metadata: ScenePluginMetadata, targetExpression: ts.Expression, filePath: string, ): void { const expression = unwrapExpression(targetExpression) if (!ts.isCallExpression(expression)) { throw new Error(`${filePath}: scene target must be a call expression`) } const factoryName = getIdentifierName(expression.expression) if (factoryName !== 'defineSceneComponent') { throw new Error(`${filePath}: scene target must use defineSceneComponent()`) } const definition = expression.arguments[0] if (!definition || !ts.isObjectLiteralExpression(definition)) { throw new Error(`${filePath}: defineSceneComponent requires a static object literal`) } const family = definition.properties.find((property) => { if (!ts.isPropertyAssignment(property)) { return false } const name = property.name return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === 'family' }) as ts.PropertyAssignment | undefined if (!family) { throw new Error(`${filePath}: defineSceneComponent is missing family`) } const familyValue = evaluateStaticExpression(family.initializer, filePath) if (familyValue !== metadata.sceneFamily) { throw new Error(`${filePath}: sceneFamily mismatch for ${metadata.pluginKey}`) } } function validateTransitionTarget( metadata: TransitionPluginMetadata, targetExpression: ts.Expression, filePath: string, ): void { const expression = unwrapExpression(targetExpression) if (!ts.isCallExpression(expression)) { throw new Error(`${filePath}: transition target must be a call expression`) } const factoryName = getIdentifierName(expression.expression) if (factoryName !== 'defineTransitionPlugin') { throw new Error(`${filePath}: transition target must use defineTransitionPlugin()`) } const definition = expression.arguments[0] if (!definition || !ts.isObjectLiteralExpression(definition)) { throw new Error(`${filePath}: defineTransitionPlugin requires a static object literal`) } const kindProperty = definition.properties.find((property) => { if (!ts.isPropertyAssignment(property)) { return false } const name = property.name return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === 'kind' }) as ts.PropertyAssignment | undefined if (!kindProperty) { throw new Error(`${filePath}: defineTransitionPlugin is missing kind`) } const kindValue = evaluateStaticExpression(kindProperty.initializer, filePath) if (kindValue !== metadata.transitionKind) { throw new Error(`${filePath}: transitionKind mismatch for ${metadata.pluginKey}`) } } function validateThemeTarget( metadata: ThemePluginMetadata, targetExpression: ts.Expression, filePath: string, ): void { const expression = unwrapExpression(targetExpression) if (!ts.isCallExpression(expression)) { throw new Error(`${filePath}: theme target must be a call expression`) } const factoryName = getIdentifierName(expression.expression) if (factoryName !== 'defineSceneTheme') { throw new Error(`${filePath}: theme target must use defineSceneTheme()`) } const definition = expression.arguments[0] if (!definition || !ts.isObjectLiteralExpression(definition)) { throw new Error(`${filePath}: defineSceneTheme requires a static object literal`) } const keyProperty = definition.properties.find((property) => { if (!ts.isPropertyAssignment(property)) { return false } const name = property.name return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === 'key' }) as ts.PropertyAssignment | undefined if (!keyProperty) { throw new Error(`${filePath}: defineSceneTheme is missing key`) } const keyValue = evaluateStaticExpression(keyProperty.initializer, filePath) if (keyValue !== metadata.pluginKey) { throw new Error(`${filePath}: theme key mismatch for ${metadata.pluginKey}`) } } function computeSourceDigest(sourceText: string): string { return crypto.createHash('sha256').update(sourceText).digest('hex') } async function discoverPackageResources( packageRoot: string, sourceRootOverride?: string, ): Promise { const sourceRoot = sourceRootOverride ?? (await resolveSourceRoot(packageRoot)) const files = await collectFiles(sourceRoot) const packageJsonPath = path.join(packageRoot, 'package.json') let packageJson: { name?: string; version?: string } = {} try { packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) } catch { packageJson = {} } const packageName = packageJson.name ?? path.basename(packageRoot) const packageVersion = packageJson.version ?? '0.0.0' const discovered: DiscoveredVideoResource[] = [] for (const absolutePath of files) { const sourceText = await fs.readFile(absolutePath, 'utf8') const sourceFile = parseSourceFile(absolutePath, sourceText) const metadataByVariableName = collectMetadataVariables(sourceFile) const sourceDigest = computeSourceDigest(sourceText) sourceFile.forEachChild((node) => { const exportName = getExportNameFromDeclaration(node) if (!exportName) { return } let expression: ts.Expression | undefined if (ts.isVariableStatement(node)) { expression = node.declarationList.declarations[0]?.initializer } else if (ts.isFunctionDeclaration(node)) { expression = node.body ? undefined : undefined } else if (ts.isClassDeclaration(node)) { expression = undefined } if (!expression) { return } const annotation = extractAnnotationCall(expression) if (!annotation) { return } const descriptor = descriptorMap[annotation.annotationName] if (!descriptor) { return } const metadataExpression = annotation.metadataExpression if (!metadataExpression) { throw new Error(`${absolutePath}: annotation ${annotation.annotationName} is missing metadata`) } const metadataValue = ts.isIdentifier(metadataExpression) ? metadataByVariableName.get(metadataExpression.text) : metadataExpression if (!metadataValue) { throw new Error(`${absolutePath}: unknown metadata reference ${metadataExpression.getText(sourceFile)}`) } const staticMetadata = evaluateStaticExpression(metadataValue, absolutePath) const parsedMetadata = annotation.annotationName === 'VideoComponent' ? normalizeSceneMetadata(staticMetadata, absolutePath) : descriptor.schema.parse(staticMetadata as VideoResourceMetadata) if (parsedMetadata.resourceKind === 'scene') { validateSceneTarget(parsedMetadata as ScenePluginMetadata, annotation.targetExpression, absolutePath) } else if (parsedMetadata.resourceKind === 'transition') { validateTransitionTarget( parsedMetadata as TransitionPluginMetadata, annotation.targetExpression, absolutePath, ) } else if (parsedMetadata.resourceKind === 'theme') { validateThemeTarget(parsedMetadata as ThemePluginMetadata, annotation.targetExpression, absolutePath) } discovered.push({ packageName, packageVersion, packageRoot, sourceFile: path.relative(packageRoot, absolutePath), exportName, annotationName: annotation.annotationName, metadataFactoryName: descriptor.metadataFactoryName, metadata: parsedMetadata, sourceDigest, }) }) } return discovered } async function collectLocalSourceRoots(projectRoot: string): Promise { const candidates = ['src/components', 'src/video/chapters'] const roots: string[] = [] for (const candidate of candidates) { const absolutePath = path.join(projectRoot, candidate) try { const stat = await fs.stat(absolutePath) if (stat.isDirectory()) { roots.push(absolutePath) } } catch { // ignore missing local source roots } } return roots } async function readInstalledPackageRoots(projectRoot: string): Promise { const namespaceRoot = path.join(projectRoot, 'node_modules', '@vibecuting') let entries: Dirent[] = [] try { entries = await fs.readdir(namespaceRoot, { withFileTypes: true }) } catch { return [] } return entries .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) .map((entry) => path.join(namespaceRoot, entry.name)) .sort((left, right) => left.localeCompare(right)) } function normalizeResourceKey(resource: DiscoveredVideoResource): string { return `${resource.packageName}:${resource.metadata.resourceKind}:${resource.metadata.pluginKey}` } export async function discoverVideoResources( projectRoot: string, descriptors: readonly VideoResourceDescriptor[] = videoResourceDescriptors, ): Promise { const descriptorNames = new Set(descriptors.map((descriptor) => descriptor.annotationName)) const packages = await readInstalledPackageRoots(projectRoot) const localSourceRoots = await collectLocalSourceRoots(projectRoot) const discovered: DiscoveredVideoResource[] = [] for (const packageRoot of packages) { const resources = await discoverPackageResources(packageRoot) for (const resource of resources) { if (!descriptorNames.has(resource.annotationName)) { continue } discovered.push(resource) } } for (const sourceRoot of localSourceRoots) { const resources = await discoverPackageResources(projectRoot, sourceRoot) for (const resource of resources) { if (!descriptorNames.has(resource.annotationName)) { continue } discovered.push(resource) } } const seen = new Set() const ordered = discovered.sort((left, right) => { const keyLeft = normalizeResourceKey(left) const keyRight = normalizeResourceKey(right) return keyLeft.localeCompare(keyRight) || left.sourceFile.localeCompare(right.sourceFile) }) for (const resource of ordered) { const key = normalizeResourceKey(resource) if (seen.has(key)) { throw new Error(`duplicate resource key detected: ${key}`) } seen.add(key) } return ordered } export async function discoverComponentProjectComponents( projectRoot: string, ): Promise> { const resources = await discoverVideoResources(projectRoot, [sceneResourceDescriptor]) return resources .filter((resource): resource is DiscoveredVideoResource & { metadata: ScenePluginMetadata } => { return resource.metadata.resourceKind === 'scene' }) .map((resource) => ({ ...(resource.metadata as ScenePluginMetadata), sourceFile: resource.sourceFile, })) }