import crypto from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' import type { DiscoveredVideoResource } from '../discovery/index.ts' type ResourceEntry = { packageName: string packageVersion: string } type GeneratedResourceRecord = { packageName: string exportName: string metadata: DiscoveredVideoResource['metadata'] } type GeneratedModuleInput = { projectRoot: string resources: DiscoveredVideoResource[] } function groupResourcesByPackage(resources: DiscoveredVideoResource[]): Map { const result = new Map() for (const resource of resources) { const items = result.get(resource.packageName) ?? [] items.push(resource) result.set(resource.packageName, items) } for (const [packageName, items] of result.entries()) { result.set( packageName, items.sort((left, right) => left.exportName.localeCompare(right.exportName)), ) } return result } function stableJson(value: unknown): string { return JSON.stringify( value, (_key, nestedValue) => { if (nestedValue && typeof nestedValue === 'object' && !Array.isArray(nestedValue)) { return Object.fromEntries( Object.entries(nestedValue as Record).sort(([left], [right]) => left.localeCompare(right), ), ) } return nestedValue }, 2, ) } function createFingerprint(resources: DiscoveredVideoResource[]): string { const hash = crypto.createHash('sha256') hash.update( stableJson( resources.map((resource) => ({ packageName: resource.packageName, packageVersion: resource.packageVersion, exportName: resource.exportName, annotationName: resource.annotationName, metadata: resource.metadata, sourceDigest: resource.sourceDigest, })), ), ) return `sha256:${hash.digest('hex')}` } function buildImportSpecifier(projectRoot: string, resource: DiscoveredVideoResource): string { if (resource.packageRoot !== projectRoot) { return resource.packageName } const relativePath = path .relative(path.join(projectRoot, '.vibecuting'), path.join(projectRoot, resource.sourceFile)) .replaceAll(path.sep, '/') .replace(/\.(tsx?|jsx?|mjs|cjs)$/, '') return relativePath.startsWith('.') ? relativePath : `./${relativePath}` } function buildImports(projectRoot: string, resources: DiscoveredVideoResource[]): string { const grouped = new Map() for (const resource of resources) { const specifier = buildImportSpecifier(projectRoot, resource) const items = grouped.get(specifier) ?? [] items.push(resource) grouped.set(specifier, items) } return Array.from(grouped.entries()) .map(([specifier, packageResources]) => { const names = packageResources.map((resource) => resource.exportName).join(', ') return `import { ${names} } from '${specifier}'` }) .join('\n') } function buildRegistryArray( name: string, kind: DiscoveredVideoResource['metadata']['resourceKind'], resources: DiscoveredVideoResource[], ): string { const imports = resources .filter((resource) => resource.metadata.resourceKind === kind) .map((resource) => resource.exportName) const factoryName = kind === 'scene' ? 'createScenePluginRegistry' : kind === 'transition' ? 'createTransitionPluginRegistry' : 'createSceneThemeRegistry' return `export const ${name} = ${factoryName}([${imports.join(', ')}])` } function buildMetadataRecords(resources: DiscoveredVideoResource[]): GeneratedResourceRecord[] { return resources.map((resource) => ({ packageName: resource.packageName, exportName: resource.exportName, metadata: resource.metadata, })) } export async function generateVideoResourceMeta({ projectRoot, resources, }: GeneratedModuleInput): Promise<{ filePath: string; contents: string }> { const sortedResources = [...resources].sort((left, right) => { return ( left.packageName.localeCompare(right.packageName) || left.metadata.resourceKind.localeCompare(right.metadata.resourceKind) || left.metadata.pluginKey.localeCompare(right.metadata.pluginKey) || left.exportName.localeCompare(right.exportName) ) }) const filePath = path.join(projectRoot, '.vibecuting', 'video-resource-meta.generated.ts') const fingerprint = createFingerprint(sortedResources) const packageEntries: ResourceEntry[] = [] const seenPackages = new Set() for (const resource of sortedResources) { if (seenPackages.has(resource.packageName)) { continue } seenPackages.add(resource.packageName) packageEntries.push({ packageName: resource.packageName, packageVersion: resource.packageVersion, }) } const contents = [ '// Generated by update-component-meta. Do not edit.', '', buildImports(projectRoot, sortedResources), "import { createScenePluginRegistry, createSceneThemeRegistry, createTransitionPluginRegistry } from '@vibecuting/video-project-core'", '', `export const videoResourceMeta = ${stableJson({ schemaVersion: 1, packageFingerprint: fingerprint, packages: packageEntries, resources: buildMetadataRecords(sortedResources), })} as const`, '', buildRegistryArray('installedScenePluginRegistry', 'scene', sortedResources), buildRegistryArray('installedTransitionPluginRegistry', 'transition', sortedResources), buildRegistryArray('installedSceneThemeRegistry', 'theme', sortedResources), '', ] .filter(Boolean) .join('\n') return { filePath, contents } } export async function writeVideoResourceMeta( input: GeneratedModuleInput, ): Promise<{ filePath: string; contents: string }> { const generated = await generateVideoResourceMeta(input) await fs.mkdir(path.dirname(generated.filePath), { recursive: true }) const tempPath = `${generated.filePath}.tmp` await fs.writeFile(tempPath, `${generated.contents}\n`, 'utf8') await fs.rename(tempPath, generated.filePath) return generated }