@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.
- package/bin/component-project-helper.mjs +3 -3
- package/package.json +4 -3
- package/scripts/package-json.mjs +1 -1
- package/scripts/update-component-meta.mjs +60 -0
- package/src/decorators/index.ts +95 -17
- package/src/discovery/index.test.ts +86 -159
- package/src/discovery/index.ts +646 -102
- package/src/index.ts +49 -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/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,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
|
-
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
244
|
+
if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.map')) {
|
|
36
245
|
continue
|
|
37
246
|
}
|
|
38
247
|
|
|
39
|
-
if (entry.name.
|
|
248
|
+
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
|
|
40
249
|
continue
|
|
41
250
|
}
|
|
42
251
|
|
|
43
|
-
if (
|
|
44
|
-
files.push(
|
|
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
|
|
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
|
-
|
|
295
|
+
return entries
|
|
62
296
|
.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())]
|
|
297
|
+
.map((entry) => path.join(namespaceRoot, entry.name))
|
|
298
|
+
.sort((left, right) => left.localeCompare(right))
|
|
67
299
|
}
|
|
68
300
|
|
|
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)
|
|
301
|
+
function collectMetadataVariables(sourceFile: ts.SourceFile): Map<string, ts.Expression> {
|
|
302
|
+
const result = new Map<string, ts.Expression>()
|
|
79
303
|
|
|
80
|
-
|
|
81
|
-
|
|
304
|
+
sourceFile.forEachChild((node) => {
|
|
305
|
+
if (!ts.isVariableStatement(node)) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
82
308
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
314
|
+
const initializer = unwrapExpression(declaration.initializer)
|
|
315
|
+
if (!ts.isCallExpression(initializer)) {
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
91
318
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
escaped = false
|
|
319
|
+
const factoryName = getIdentifierName(initializer.expression)
|
|
320
|
+
if (!factoryName) {
|
|
95
321
|
continue
|
|
96
322
|
}
|
|
97
323
|
|
|
98
|
-
if (
|
|
99
|
-
escaped = true
|
|
324
|
+
if (!metadataFactoryMap[factoryName]) {
|
|
100
325
|
continue
|
|
101
326
|
}
|
|
102
327
|
|
|
103
|
-
|
|
104
|
-
|
|
328
|
+
const metadataExpression = initializer.arguments[0]
|
|
329
|
+
if (!metadataExpression || !ts.isExpression(metadataExpression)) {
|
|
330
|
+
continue
|
|
105
331
|
}
|
|
106
332
|
|
|
107
|
-
|
|
333
|
+
result.set(declaration.name.text, metadataExpression)
|
|
108
334
|
}
|
|
335
|
+
})
|
|
109
336
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
stringQuote = character
|
|
113
|
-
continue
|
|
114
|
-
}
|
|
337
|
+
return result
|
|
338
|
+
}
|
|
115
339
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
658
|
+
return roots
|
|
177
659
|
}
|
|
178
660
|
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
}
|