@vibecuting/component-project-helper 0.1.16 → 0.1.17

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