@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.
- package/README.md +4 -6
- package/bin/component-project-helper.mjs +3 -3
- package/package.json +9 -6
- package/scripts/package-json.mjs +1 -1
- package/scripts/update-component-meta.mjs +60 -0
- package/scripts/update-component-skills.mjs +63 -0
- package/src/decorators/index.ts +95 -17
- package/src/discovery/index.test.ts +86 -159
- package/src/discovery/index.ts +664 -102
- package/src/index.ts +52 -10
- package/src/meta/generate-video-resource-meta.ts +198 -0
- package/src/meta/update-component-meta.test.ts +64 -0
- package/src/meta/update-component-meta.ts +47 -0
- package/src/meta/update-component-skills.test.ts +66 -0
- package/src/meta/update-component-skills.ts +306 -0
- package/src/resources/resource-descriptors.ts +47 -0
- package/src/resources/video-resource-descriptors.json +17 -0
- package/src/resources/video-resource.ts +143 -0
- package/src/schemas/index.ts +24 -2
- package/scripts/postinstall.mjs +0 -112
- package/scripts/preuninstall.mjs +0 -45
- package/scripts/render-markdown.mjs +0 -36
- package/src/lifecycle/package-json.ts +0 -63
- package/src/lifecycle/postinstall.test.ts +0 -114
- package/src/lifecycle/postinstall.ts +0 -127
- package/src/lifecycle/preuninstall.ts +0 -54
- package/src/markdown/index.test.ts +0 -20
- package/src/markdown/index.ts +0 -43
package/src/discovery/index.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
type
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
262
|
+
if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.map')) {
|
|
36
263
|
continue
|
|
37
264
|
}
|
|
38
265
|
|
|
39
|
-
if (entry.name.
|
|
266
|
+
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
|
|
40
267
|
continue
|
|
41
268
|
}
|
|
42
269
|
|
|
43
|
-
if (
|
|
44
|
-
files.push(
|
|
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
|
|
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
|
-
|
|
313
|
+
return entries
|
|
62
314
|
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
63
|
-
.map((entry) => path.join(namespaceRoot, entry.name
|
|
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
|
-
|
|
70
|
-
const
|
|
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
|
-
|
|
81
|
-
|
|
322
|
+
sourceFile.forEachChild((node) => {
|
|
323
|
+
if (!ts.isVariableStatement(node)) {
|
|
324
|
+
return
|
|
325
|
+
}
|
|
82
326
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
332
|
+
const initializer = unwrapExpression(declaration.initializer)
|
|
333
|
+
if (!ts.isCallExpression(initializer)) {
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
91
336
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
escaped = false
|
|
337
|
+
const factoryName = getIdentifierName(initializer.expression)
|
|
338
|
+
if (!factoryName) {
|
|
95
339
|
continue
|
|
96
340
|
}
|
|
97
341
|
|
|
98
|
-
if (
|
|
99
|
-
escaped = true
|
|
342
|
+
if (!metadataFactoryMap[factoryName]) {
|
|
100
343
|
continue
|
|
101
344
|
}
|
|
102
345
|
|
|
103
|
-
|
|
104
|
-
|
|
346
|
+
const metadataExpression = initializer.arguments[0]
|
|
347
|
+
if (!metadataExpression || !ts.isExpression(metadataExpression)) {
|
|
348
|
+
continue
|
|
105
349
|
}
|
|
106
350
|
|
|
107
|
-
|
|
351
|
+
result.set(declaration.name.text, metadataExpression)
|
|
108
352
|
}
|
|
353
|
+
})
|
|
109
354
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
stringQuote = character
|
|
113
|
-
continue
|
|
114
|
-
}
|
|
355
|
+
return result
|
|
356
|
+
}
|
|
115
357
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
676
|
+
return roots
|
|
177
677
|
}
|
|
178
678
|
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
}
|