@vibecuting/component-project-helper 0.1.16 → 0.1.18

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.
@@ -1,15 +1,238 @@
1
+ import crypto from 'node:crypto'
1
2
  import fs from 'node:fs/promises'
2
3
  import type { Dirent } from 'node:fs'
3
4
  import path from 'node:path'
5
+ import ts from 'typescript'
4
6
 
5
7
  import {
6
- ComponentProjectComponentMetadataSchema,
7
- type ComponentProjectComponentMetadata,
8
- } from '../schemas'
8
+ type ScenePluginMetadata,
9
+ type ThemePluginMetadata,
10
+ type TransitionPluginMetadata,
11
+ type VideoResourceDescriptor,
12
+ type VideoResourceMetadata,
13
+ ScenePluginMetadataSchema,
14
+ } from '../resources/video-resource.ts'
15
+ import {
16
+ sceneResourceDescriptor,
17
+ themeResourceDescriptor,
18
+ transitionResourceDescriptor,
19
+ videoResourceDescriptors,
20
+ type VideoResourceDescriptorDefinition,
21
+ } from '../resources/resource-descriptors.ts'
22
+ import { ComponentProjectComponentMetadataSchema } from '../schemas/index.ts'
23
+
24
+ const SUPPORTED_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
25
+
26
+ export type DiscoveredVideoResource = {
27
+ packageName: string
28
+ packageVersion: string
29
+ packageRoot: string
30
+ sourceFile: string
31
+ exportName: string
32
+ annotationName: string
33
+ metadataFactoryName: string
34
+ metadata: VideoResourceMetadata
35
+ sourceDigest: string
36
+ }
37
+
38
+ type DescriptorMap = Record<string, VideoResourceDescriptor<VideoResourceMetadata>>
39
+
40
+ const descriptorMap: DescriptorMap = {
41
+ [sceneResourceDescriptor.annotationName]: sceneResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
42
+ [transitionResourceDescriptor.annotationName]:
43
+ transitionResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
44
+ [themeResourceDescriptor.annotationName]:
45
+ themeResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
46
+ }
47
+
48
+ const metadataFactoryMap: DescriptorMap = {
49
+ [sceneResourceDescriptor.metadataFactoryName]:
50
+ sceneResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
51
+ ['defineComponentProjectComponentMetadata']:
52
+ sceneResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
53
+ [transitionResourceDescriptor.metadataFactoryName]:
54
+ transitionResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
55
+ [themeResourceDescriptor.metadataFactoryName]:
56
+ themeResourceDescriptor as VideoResourceDescriptor<VideoResourceMetadata>,
57
+ }
58
+
59
+ const staticMetadataFactoryNames = new Set([
60
+ sceneResourceDescriptor.metadataFactoryName,
61
+ transitionResourceDescriptor.metadataFactoryName,
62
+ themeResourceDescriptor.metadataFactoryName,
63
+ 'defineComponentProjectComponentMetadata',
64
+ ])
65
+
66
+ function toScriptKind(filePath: string): ts.ScriptKind {
67
+ if (filePath.endsWith('.tsx')) {
68
+ return ts.ScriptKind.TSX
69
+ }
70
+
71
+ if (filePath.endsWith('.jsx')) {
72
+ return ts.ScriptKind.JSX
73
+ }
74
+
75
+ if (filePath.endsWith('.mjs')) {
76
+ return ts.ScriptKind.JS
77
+ }
78
+
79
+ if (filePath.endsWith('.cjs')) {
80
+ return ts.ScriptKind.JS
81
+ }
82
+
83
+ return ts.ScriptKind.TS
84
+ }
85
+
86
+ function getIdentifierName(node: ts.Expression): string | undefined {
87
+ if (ts.isIdentifier(node)) {
88
+ return node.text
89
+ }
90
+
91
+ if (ts.isPropertyAccessExpression(node)) {
92
+ return node.name.text
93
+ }
94
+
95
+ return undefined
96
+ }
97
+
98
+ function unwrapExpression(node: ts.Expression): ts.Expression {
99
+ let current = node
100
+
101
+ for (;;) {
102
+ if (ts.isParenthesizedExpression(current)) {
103
+ current = current.expression
104
+ continue
105
+ }
106
+
107
+ if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) {
108
+ current = current.expression
109
+ continue
110
+ }
111
+
112
+ if (ts.isSatisfiesExpression(current)) {
113
+ current = current.expression
114
+ continue
115
+ }
116
+
117
+ return current
118
+ }
119
+ }
120
+
121
+ export function evaluateStaticExpression(node: ts.Expression, filePath: string): unknown {
122
+ const expression = unwrapExpression(node)
123
+
124
+ if (ts.isStringLiteralLike(expression)) {
125
+ return expression.text
126
+ }
127
+
128
+ if (ts.isNoSubstitutionTemplateLiteral(expression)) {
129
+ return expression.text
130
+ }
131
+
132
+ if (ts.isNumericLiteral(expression)) {
133
+ return Number(expression.text)
134
+ }
135
+
136
+ if (expression.kind === ts.SyntaxKind.TrueKeyword) {
137
+ return true
138
+ }
139
+
140
+ if (expression.kind === ts.SyntaxKind.FalseKeyword) {
141
+ return false
142
+ }
143
+
144
+ if (expression.kind === ts.SyntaxKind.NullKeyword) {
145
+ return null
146
+ }
147
+
148
+ if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.MinusToken) {
149
+ const value = evaluateStaticExpression(expression.operand, filePath)
150
+ if (typeof value === 'number') {
151
+ return -value
152
+ }
153
+ }
154
+
155
+ if (ts.isArrayLiteralExpression(expression)) {
156
+ return expression.elements.map((element) => {
157
+ if (ts.isSpreadElement(element)) {
158
+ throw new Error(`${filePath}: spread elements are not supported in static metadata`)
159
+ }
160
+
161
+ return evaluateStaticExpression(element as ts.Expression, filePath)
162
+ })
163
+ }
164
+
165
+ if (ts.isObjectLiteralExpression(expression)) {
166
+ const result: Record<string, unknown> = {}
167
+
168
+ for (const property of expression.properties) {
169
+ if (ts.isSpreadAssignment(property)) {
170
+ throw new Error(`${filePath}: spread assignments are not supported in static metadata`)
171
+ }
172
+
173
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
174
+ throw new Error(`${filePath}: unsupported object literal property in static metadata`)
175
+ }
176
+
177
+ const key = ts.isPropertyAssignment(property)
178
+ ? property.name
179
+ : property.name
180
+
181
+ let propertyName: string | undefined
182
+
183
+ if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) {
184
+ propertyName = key.text
185
+ } else if (ts.isNumericLiteral(key)) {
186
+ propertyName = key.text
187
+ }
188
+
189
+ if (!propertyName) {
190
+ throw new Error(`${filePath}: computed metadata keys are not supported`)
191
+ }
192
+
193
+ const value = ts.isPropertyAssignment(property)
194
+ ? evaluateStaticExpression(property.initializer, filePath)
195
+ : property.name.text
196
+
197
+ result[propertyName] = value
198
+ }
199
+
200
+ return result
201
+ }
202
+
203
+ if (ts.isCallExpression(expression)) {
204
+ const factoryName = getIdentifierName(expression.expression)
205
+ if (factoryName && staticMetadataFactoryNames.has(factoryName)) {
206
+ const firstArgument = expression.arguments[0]
207
+ if (!firstArgument || !ts.isExpression(firstArgument)) {
208
+ throw new Error(`${filePath}: static metadata factory ${factoryName} requires a single object literal argument`)
209
+ }
210
+ return evaluateStaticExpression(firstArgument, filePath)
211
+ }
212
+ }
9
213
 
10
- export type ComponentProjectComponentDiscovery = ComponentProjectComponentMetadata
214
+ if (ts.isIdentifier(expression) && expression.text === 'undefined') {
215
+ return undefined
216
+ }
217
+
218
+ throw new Error(`${filePath}: unsupported static metadata expression: ${expression.getText()}`)
219
+ }
220
+
221
+ function parseSourceFile(filePath: string, sourceText: string): ts.SourceFile {
222
+ return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, toScriptKind(filePath))
223
+ }
224
+
225
+ function isExported(node: ts.Node): boolean {
226
+ if (!ts.canHaveModifiers(node)) {
227
+ return false
228
+ }
11
229
 
12
- async function collectComponentSourceFiles(directory: string): Promise<string[]> {
230
+ return Boolean(
231
+ ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword),
232
+ )
233
+ }
234
+
235
+ async function collectFiles(directory: string): Promise<string[]> {
13
236
  let entries: Dirent[] = []
14
237
 
15
238
  try {
@@ -21,10 +244,14 @@ async function collectComponentSourceFiles(directory: string): Promise<string[]>
21
244
  const files: string[] = []
22
245
 
23
246
  for (const entry of entries) {
24
- const entryPath = path.join(directory, entry.name)
247
+ if (entry.name === 'node_modules' || entry.name === '.git') {
248
+ continue
249
+ }
250
+
251
+ const absolutePath = path.join(directory, entry.name)
25
252
 
26
253
  if (entry.isDirectory()) {
27
- files.push(...(await collectComponentSourceFiles(entryPath)))
254
+ files.push(...(await collectFiles(absolutePath)))
28
255
  continue
29
256
  }
30
257
 
@@ -32,23 +259,48 @@ async function collectComponentSourceFiles(directory: string): Promise<string[]>
32
259
  continue
33
260
  }
34
261
 
35
- if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
262
+ if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.map')) {
36
263
  continue
37
264
  }
38
265
 
39
- if (entry.name.endsWith('.d.ts')) {
266
+ if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
40
267
  continue
41
268
  }
42
269
 
43
- if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
44
- files.push(entryPath)
270
+ if (SUPPORTED_EXTENSIONS.has(path.extname(entry.name))) {
271
+ files.push(absolutePath)
45
272
  }
46
273
  }
47
274
 
48
275
  return files
49
276
  }
50
277
 
51
- async function collectNamespacedPackageSourceFiles(projectRoot: string): Promise<string[]> {
278
+ async function resolveSourceRoot(packageRoot: string): Promise<string> {
279
+ const srcRoot = path.join(packageRoot, 'src')
280
+ const distRoot = path.join(packageRoot, 'dist')
281
+
282
+ try {
283
+ const stat = await fs.stat(srcRoot)
284
+ if (stat.isDirectory()) {
285
+ return srcRoot
286
+ }
287
+ } catch {
288
+ // ignore
289
+ }
290
+
291
+ try {
292
+ const stat = await fs.stat(distRoot)
293
+ if (stat.isDirectory()) {
294
+ return distRoot
295
+ }
296
+ } catch {
297
+ // ignore
298
+ }
299
+
300
+ return packageRoot
301
+ }
302
+
303
+ async function collectInstalledPackages(projectRoot: string): Promise<string[]> {
52
304
  const namespaceRoot = path.join(projectRoot, 'node_modules', '@vibecuting')
53
305
  let entries: Dirent[] = []
54
306
 
@@ -58,142 +310,452 @@ async function collectNamespacedPackageSourceFiles(projectRoot: string): Promise
58
310
  return []
59
311
  }
60
312
 
61
- const packageRoots = entries
313
+ return entries
62
314
  .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
63
- .map((entry) => path.join(namespaceRoot, entry.name, 'src'))
64
-
65
- const files = await Promise.all(packageRoots.map((directory) => collectComponentSourceFiles(directory)))
66
- return [...new Set(files.flat())]
315
+ .map((entry) => path.join(namespaceRoot, entry.name))
316
+ .sort((left, right) => left.localeCompare(right))
67
317
  }
68
318
 
69
- async function collectDiscoverableSourceFiles(projectRoot: string): Promise<string[]> {
70
- const sourceRoots = [
71
- path.join(projectRoot, 'src', 'components'),
72
- path.join(projectRoot, 'src', 'video', 'chapters'),
73
- ]
74
-
75
- const localFiles = await Promise.all(
76
- sourceRoots.map((directory) => collectComponentSourceFiles(directory)),
77
- )
78
- const packageFiles = await collectNamespacedPackageSourceFiles(projectRoot)
319
+ function collectMetadataVariables(sourceFile: ts.SourceFile): Map<string, ts.Expression> {
320
+ const result = new Map<string, ts.Expression>()
79
321
 
80
- return [...new Set([...localFiles.flat(), ...packageFiles])]
81
- }
322
+ sourceFile.forEachChild((node) => {
323
+ if (!ts.isVariableStatement(node)) {
324
+ return
325
+ }
82
326
 
83
- function extractBalancedObjectLiteral(source: string, startIndex: number): string | undefined {
84
- let depth = 0
85
- let inString = false
86
- let stringQuote = ''
87
- let escaped = false
327
+ for (const declaration of node.declarationList.declarations) {
328
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
329
+ continue
330
+ }
88
331
 
89
- for (let index = startIndex; index < source.length; index += 1) {
90
- const character = source[index]
332
+ const initializer = unwrapExpression(declaration.initializer)
333
+ if (!ts.isCallExpression(initializer)) {
334
+ continue
335
+ }
91
336
 
92
- if (inString) {
93
- if (escaped) {
94
- escaped = false
337
+ const factoryName = getIdentifierName(initializer.expression)
338
+ if (!factoryName) {
95
339
  continue
96
340
  }
97
341
 
98
- if (character === '\\') {
99
- escaped = true
342
+ if (!metadataFactoryMap[factoryName]) {
100
343
  continue
101
344
  }
102
345
 
103
- if (character === stringQuote) {
104
- inString = false
346
+ const metadataExpression = initializer.arguments[0]
347
+ if (!metadataExpression || !ts.isExpression(metadataExpression)) {
348
+ continue
105
349
  }
106
350
 
107
- continue
351
+ result.set(declaration.name.text, metadataExpression)
108
352
  }
353
+ })
109
354
 
110
- if (character === '"' || character === "'" || character === '`') {
111
- inString = true
112
- stringQuote = character
113
- continue
114
- }
355
+ return result
356
+ }
115
357
 
116
- if (character === '{') {
117
- depth += 1
118
- continue
119
- }
358
+ function normalizeSceneMetadata(value: unknown, filePath: string): ScenePluginMetadata {
359
+ const modern = ScenePluginMetadataSchema.safeParse(value)
360
+ if (modern.success) {
361
+ return modern.data
362
+ }
120
363
 
121
- if (character === '}') {
122
- depth -= 1
123
- if (depth === 0) {
124
- return source.slice(startIndex, index + 1)
125
- }
364
+ const legacy = ComponentProjectComponentMetadataSchema.safeParse(value)
365
+ if (!legacy.success) {
366
+ throw new Error(`${filePath}: invalid scene metadata`)
367
+ }
368
+
369
+ return ScenePluginMetadataSchema.parse({
370
+ resourceKind: 'scene',
371
+ name: legacy.data.name,
372
+ description: legacy.data.description,
373
+ sourceFile: legacy.data.sourceFile,
374
+ pluginKey: legacy.data.name,
375
+ tags: legacy.data.tags,
376
+ aspectRatio: legacy.data.aspectRatio,
377
+ sceneType: legacy.data.sceneType,
378
+ sceneFamily: 'custom',
379
+ rootLayout: 'absolute-fill',
380
+ propsTypeName: legacy.data.propsTypeName,
381
+ })
382
+ }
383
+
384
+ function getExportNameFromDeclaration(node: ts.Node): string | undefined {
385
+ if (ts.isVariableStatement(node) && isExported(node)) {
386
+ const declaration = node.declarationList.declarations[0]
387
+ if (declaration && ts.isIdentifier(declaration.name)) {
388
+ return declaration.name.text
126
389
  }
127
390
  }
128
391
 
392
+ if (ts.isFunctionDeclaration(node) && isExported(node) && node.name) {
393
+ return node.name.text
394
+ }
395
+
396
+ if (ts.isClassDeclaration(node) && isExported(node) && node.name) {
397
+ return node.name.text
398
+ }
399
+
129
400
  return undefined
130
401
  }
131
402
 
132
- function parseMetadataLiteral(literal: string): ComponentProjectComponentMetadata | undefined {
133
- try {
134
- const value = Function(`"use strict"; return (${literal});`)()
135
- const parsed = ComponentProjectComponentMetadataSchema.safeParse(value)
136
- return parsed.success ? parsed.data : undefined
137
- } catch {
403
+ function extractAnnotationCall(node: ts.Expression): {
404
+ annotationName: string
405
+ metadataExpression: ts.Expression | undefined
406
+ targetExpression: ts.Expression
407
+ } | undefined {
408
+ const outerCall = unwrapExpression(node)
409
+ if (!ts.isCallExpression(outerCall)) {
410
+ return undefined
411
+ }
412
+
413
+ const targetExpression = outerCall.arguments[0]
414
+ if (!targetExpression || !ts.isExpression(targetExpression)) {
415
+ return undefined
416
+ }
417
+
418
+ const innerCall = unwrapExpression(outerCall.expression)
419
+ if (!ts.isCallExpression(innerCall)) {
138
420
  return undefined
139
421
  }
422
+
423
+ const annotationName = getIdentifierName(innerCall.expression)
424
+ if (!annotationName) {
425
+ return undefined
426
+ }
427
+
428
+ const metadataExpression = innerCall.arguments[0]
429
+ return {
430
+ annotationName,
431
+ metadataExpression: metadataExpression && ts.isExpression(metadataExpression) ? metadataExpression : undefined,
432
+ targetExpression,
433
+ }
140
434
  }
141
435
 
142
- function readMetadataFromSource(source: string): ComponentProjectComponentMetadata | undefined {
143
- const decoratorMatch = /@VideoComponent\s*\((\{[\s\S]*?\})\)/m.exec(source)
144
- if (decoratorMatch) {
145
- return parseMetadataLiteral(decoratorMatch[1])
436
+ function validateSceneTarget(
437
+ metadata: ScenePluginMetadata,
438
+ targetExpression: ts.Expression,
439
+ filePath: string,
440
+ ): void {
441
+ const expression = unwrapExpression(targetExpression)
442
+ if (!ts.isCallExpression(expression)) {
443
+ throw new Error(`${filePath}: scene target must be a call expression`)
146
444
  }
147
445
 
148
- const candidates = [
149
- /componentMetadata\s*=\s*defineComponentProjectComponentMetadata\s*\(/m,
150
- /componentMetadata\s*=\s*VideoComponent\s*\(/m,
151
- /componentMetadata\s*=\s*/m,
152
- ]
446
+ const factoryName = getIdentifierName(expression.expression)
447
+ if (factoryName !== 'defineSceneComponent') {
448
+ throw new Error(`${filePath}: scene target must use defineSceneComponent()`)
449
+ }
153
450
 
154
- for (const pattern of candidates) {
155
- const match = pattern.exec(source)
156
- if (!match) {
157
- continue
451
+ const definition = expression.arguments[0]
452
+ if (!definition || !ts.isObjectLiteralExpression(definition)) {
453
+ throw new Error(`${filePath}: defineSceneComponent requires a static object literal`)
454
+ }
455
+
456
+ const family = definition.properties.find((property) => {
457
+ if (!ts.isPropertyAssignment(property)) {
458
+ return false
158
459
  }
159
460
 
160
- const startIndex = source.indexOf('{', match.index)
161
- if (startIndex < 0) {
162
- continue
461
+ const name = property.name
462
+ return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === 'family'
463
+ }) as ts.PropertyAssignment | undefined
464
+
465
+ if (!family) {
466
+ throw new Error(`${filePath}: defineSceneComponent is missing family`)
467
+ }
468
+
469
+ const familyValue = evaluateStaticExpression(family.initializer, filePath)
470
+ if (familyValue !== metadata.sceneFamily) {
471
+ throw new Error(`${filePath}: sceneFamily mismatch for ${metadata.pluginKey}`)
472
+ }
473
+ }
474
+
475
+ function validateTransitionTarget(
476
+ metadata: TransitionPluginMetadata,
477
+ targetExpression: ts.Expression,
478
+ filePath: string,
479
+ ): void {
480
+ const expression = unwrapExpression(targetExpression)
481
+ if (!ts.isCallExpression(expression)) {
482
+ throw new Error(`${filePath}: transition target must be a call expression`)
483
+ }
484
+
485
+ const factoryName = getIdentifierName(expression.expression)
486
+ if (factoryName !== 'defineTransitionPlugin') {
487
+ throw new Error(`${filePath}: transition target must use defineTransitionPlugin()`)
488
+ }
489
+
490
+ const definition = expression.arguments[0]
491
+ if (!definition || !ts.isObjectLiteralExpression(definition)) {
492
+ throw new Error(`${filePath}: defineTransitionPlugin requires a static object literal`)
493
+ }
494
+
495
+ const kindProperty = definition.properties.find((property) => {
496
+ if (!ts.isPropertyAssignment(property)) {
497
+ return false
163
498
  }
164
499
 
165
- const literal = extractBalancedObjectLiteral(source, startIndex)
166
- if (!literal) {
167
- continue
500
+ const name = property.name
501
+ return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === 'kind'
502
+ }) as ts.PropertyAssignment | undefined
503
+
504
+ if (!kindProperty) {
505
+ throw new Error(`${filePath}: defineTransitionPlugin is missing kind`)
506
+ }
507
+
508
+ const kindValue = evaluateStaticExpression(kindProperty.initializer, filePath)
509
+ if (kindValue !== metadata.transitionKind) {
510
+ throw new Error(`${filePath}: transitionKind mismatch for ${metadata.pluginKey}`)
511
+ }
512
+ }
513
+
514
+ function validateThemeTarget(
515
+ metadata: ThemePluginMetadata,
516
+ targetExpression: ts.Expression,
517
+ filePath: string,
518
+ ): void {
519
+ const expression = unwrapExpression(targetExpression)
520
+ if (!ts.isCallExpression(expression)) {
521
+ throw new Error(`${filePath}: theme target must be a call expression`)
522
+ }
523
+
524
+ const factoryName = getIdentifierName(expression.expression)
525
+ if (factoryName !== 'defineSceneTheme') {
526
+ throw new Error(`${filePath}: theme target must use defineSceneTheme()`)
527
+ }
528
+
529
+ const definition = expression.arguments[0]
530
+ if (!definition || !ts.isObjectLiteralExpression(definition)) {
531
+ throw new Error(`${filePath}: defineSceneTheme requires a static object literal`)
532
+ }
533
+
534
+ const keyProperty = definition.properties.find((property) => {
535
+ if (!ts.isPropertyAssignment(property)) {
536
+ return false
168
537
  }
169
538
 
170
- const parsed = parseMetadataLiteral(literal)
171
- if (parsed) {
172
- return parsed
539
+ const name = property.name
540
+ return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === 'key'
541
+ }) as ts.PropertyAssignment | undefined
542
+
543
+ if (!keyProperty) {
544
+ throw new Error(`${filePath}: defineSceneTheme is missing key`)
545
+ }
546
+
547
+ const keyValue = evaluateStaticExpression(keyProperty.initializer, filePath)
548
+ if (keyValue !== metadata.pluginKey) {
549
+ throw new Error(`${filePath}: theme key mismatch for ${metadata.pluginKey}`)
550
+ }
551
+ }
552
+
553
+ function computeSourceDigest(sourceText: string): string {
554
+ return crypto.createHash('sha256').update(sourceText).digest('hex')
555
+ }
556
+
557
+ async function discoverPackageResources(
558
+ packageRoot: string,
559
+ sourceRootOverride?: string,
560
+ ): Promise<DiscoveredVideoResource[]> {
561
+ const sourceRoot = sourceRootOverride ?? (await resolveSourceRoot(packageRoot))
562
+ const files = await collectFiles(sourceRoot)
563
+ const packageJsonPath = path.join(packageRoot, 'package.json')
564
+ let packageJson: { name?: string; version?: string } = {}
565
+
566
+ try {
567
+ packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'))
568
+ } catch {
569
+ packageJson = {}
570
+ }
571
+
572
+ const packageName = packageJson.name ?? path.basename(packageRoot)
573
+ const packageVersion = packageJson.version ?? '0.0.0'
574
+ const discovered: DiscoveredVideoResource[] = []
575
+
576
+ for (const absolutePath of files) {
577
+ const sourceText = await fs.readFile(absolutePath, 'utf8')
578
+ const sourceFile = parseSourceFile(absolutePath, sourceText)
579
+ const metadataByVariableName = collectMetadataVariables(sourceFile)
580
+ const sourceDigest = computeSourceDigest(sourceText)
581
+
582
+ sourceFile.forEachChild((node) => {
583
+ const exportName = getExportNameFromDeclaration(node)
584
+ if (!exportName) {
585
+ return
586
+ }
587
+
588
+ let expression: ts.Expression | undefined
589
+
590
+ if (ts.isVariableStatement(node)) {
591
+ expression = node.declarationList.declarations[0]?.initializer
592
+ } else if (ts.isFunctionDeclaration(node)) {
593
+ expression = node.body ? undefined : undefined
594
+ } else if (ts.isClassDeclaration(node)) {
595
+ expression = undefined
596
+ }
597
+
598
+ if (!expression) {
599
+ return
600
+ }
601
+
602
+ const annotation = extractAnnotationCall(expression)
603
+ if (!annotation) {
604
+ return
605
+ }
606
+
607
+ const descriptor = descriptorMap[annotation.annotationName]
608
+ if (!descriptor) {
609
+ return
610
+ }
611
+
612
+ const metadataExpression = annotation.metadataExpression
613
+ if (!metadataExpression) {
614
+ throw new Error(`${absolutePath}: annotation ${annotation.annotationName} is missing metadata`)
615
+ }
616
+
617
+ const metadataValue = ts.isIdentifier(metadataExpression)
618
+ ? metadataByVariableName.get(metadataExpression.text)
619
+ : metadataExpression
620
+
621
+ if (!metadataValue) {
622
+ throw new Error(`${absolutePath}: unknown metadata reference ${metadataExpression.getText(sourceFile)}`)
623
+ }
624
+
625
+ const staticMetadata = evaluateStaticExpression(metadataValue, absolutePath)
626
+ const parsedMetadata =
627
+ annotation.annotationName === 'VideoComponent'
628
+ ? normalizeSceneMetadata(staticMetadata, absolutePath)
629
+ : descriptor.schema.parse(staticMetadata as VideoResourceMetadata)
630
+
631
+ if (parsedMetadata.resourceKind === 'scene') {
632
+ validateSceneTarget(parsedMetadata as ScenePluginMetadata, annotation.targetExpression, absolutePath)
633
+ } else if (parsedMetadata.resourceKind === 'transition') {
634
+ validateTransitionTarget(
635
+ parsedMetadata as TransitionPluginMetadata,
636
+ annotation.targetExpression,
637
+ absolutePath,
638
+ )
639
+ } else if (parsedMetadata.resourceKind === 'theme') {
640
+ validateThemeTarget(parsedMetadata as ThemePluginMetadata, annotation.targetExpression, absolutePath)
641
+ }
642
+
643
+ discovered.push({
644
+ packageName,
645
+ packageVersion,
646
+ packageRoot,
647
+ sourceFile: path.relative(packageRoot, absolutePath),
648
+ exportName,
649
+ annotationName: annotation.annotationName,
650
+ metadataFactoryName: descriptor.metadataFactoryName,
651
+ metadata: parsedMetadata,
652
+ sourceDigest,
653
+ })
654
+ })
655
+ }
656
+
657
+ return discovered
658
+ }
659
+
660
+ async function collectLocalSourceRoots(projectRoot: string): Promise<string[]> {
661
+ const candidates = ['src/components', 'src/video/chapters']
662
+ const roots: string[] = []
663
+
664
+ for (const candidate of candidates) {
665
+ const absolutePath = path.join(projectRoot, candidate)
666
+ try {
667
+ const stat = await fs.stat(absolutePath)
668
+ if (stat.isDirectory()) {
669
+ roots.push(absolutePath)
670
+ }
671
+ } catch {
672
+ // ignore missing local source roots
173
673
  }
174
674
  }
175
675
 
176
- return undefined
676
+ return roots
177
677
  }
178
678
 
179
- export async function discoverComponentProjectComponents(
679
+ async function readInstalledPackageRoots(projectRoot: string): Promise<string[]> {
680
+ const namespaceRoot = path.join(projectRoot, 'node_modules', '@vibecuting')
681
+ let entries: Dirent[] = []
682
+
683
+ try {
684
+ entries = await fs.readdir(namespaceRoot, { withFileTypes: true })
685
+ } catch {
686
+ return []
687
+ }
688
+
689
+ return entries
690
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
691
+ .map((entry) => path.join(namespaceRoot, entry.name))
692
+ .sort((left, right) => left.localeCompare(right))
693
+ }
694
+
695
+ function normalizeResourceKey(resource: DiscoveredVideoResource): string {
696
+ return `${resource.packageName}:${resource.metadata.resourceKind}:${resource.metadata.pluginKey}`
697
+ }
698
+
699
+ export async function discoverVideoResources(
180
700
  projectRoot: string,
181
- ): Promise<ComponentProjectComponentDiscovery[]> {
182
- const discovered: ComponentProjectComponentDiscovery[] = []
183
- const sourceFiles = await collectDiscoverableSourceFiles(projectRoot)
184
-
185
- for (const absolutePath of sourceFiles) {
186
- const source = await fs.readFile(absolutePath, 'utf8')
187
- const metadata = readMetadataFromSource(source)
188
- if (!metadata) {
189
- continue
701
+ descriptors: readonly VideoResourceDescriptorDefinition[] = videoResourceDescriptors,
702
+ ): Promise<DiscoveredVideoResource[]> {
703
+ const descriptorNames = new Set(descriptors.map((descriptor) => descriptor.annotationName))
704
+ const packages = await readInstalledPackageRoots(projectRoot)
705
+ const localSourceRoots = await collectLocalSourceRoots(projectRoot)
706
+ const discovered: DiscoveredVideoResource[] = []
707
+
708
+ for (const packageRoot of packages) {
709
+ const resources = await discoverPackageResources(packageRoot)
710
+ for (const resource of resources) {
711
+ if (!descriptorNames.has(resource.annotationName)) {
712
+ continue
713
+ }
714
+
715
+ discovered.push(resource)
190
716
  }
717
+ }
191
718
 
192
- discovered.push({
193
- ...metadata,
194
- sourceFile: path.relative(projectRoot, absolutePath),
195
- })
719
+ for (const sourceRoot of localSourceRoots) {
720
+ const resources = await discoverPackageResources(projectRoot, sourceRoot)
721
+ for (const resource of resources) {
722
+ if (!descriptorNames.has(resource.annotationName)) {
723
+ continue
724
+ }
725
+
726
+ discovered.push(resource)
727
+ }
196
728
  }
197
729
 
198
- return discovered.sort((left, right) => left.name.localeCompare(right.name))
730
+ const seen = new Set<string>()
731
+ const ordered = discovered.sort((left, right) => {
732
+ const keyLeft = normalizeResourceKey(left)
733
+ const keyRight = normalizeResourceKey(right)
734
+ return keyLeft.localeCompare(keyRight) || left.sourceFile.localeCompare(right.sourceFile)
735
+ })
736
+
737
+ for (const resource of ordered) {
738
+ const key = normalizeResourceKey(resource)
739
+ if (seen.has(key)) {
740
+ throw new Error(`duplicate resource key detected: ${key}`)
741
+ }
742
+
743
+ seen.add(key)
744
+ }
745
+
746
+ return ordered
747
+ }
748
+
749
+ export async function discoverComponentProjectComponents(
750
+ projectRoot: string,
751
+ ): Promise<Array<ScenePluginMetadata & { sourceFile: string }>> {
752
+ const resources = await discoverVideoResources(projectRoot, [sceneResourceDescriptor])
753
+ return resources
754
+ .filter((resource): resource is DiscoveredVideoResource & { metadata: ScenePluginMetadata } => {
755
+ return resource.metadata.resourceKind === 'scene'
756
+ })
757
+ .map((resource) => ({
758
+ ...(resource.metadata as ScenePluginMetadata),
759
+ sourceFile: resource.sourceFile,
760
+ }))
199
761
  }