@astralsight/astroforge-rsbuild-plugin 0.0.1
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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/index.d.mts +229 -0
- package/dist/index.mjs +1538 -0
- package/package.json +46 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { unsupportedCapabilities } from "@astralsight/astroforge-core/platform";
|
|
5
|
+
import { parse } from "@babel/parser";
|
|
6
|
+
//#region src/assets.ts
|
|
7
|
+
function collectAssets(root, document) {
|
|
8
|
+
const assets = /* @__PURE__ */ new Map();
|
|
9
|
+
addAsset(root, document.manifest.icon, assets);
|
|
10
|
+
for (const page of Object.values(document.pages)) collectPageAssets(root, page, assets);
|
|
11
|
+
for (const component of Object.values(document.components)) collectComponentAssets(root, component, assets);
|
|
12
|
+
return [...assets.values()];
|
|
13
|
+
}
|
|
14
|
+
function collectPageAssets(root, page, assets) {
|
|
15
|
+
collectNodeAssets(root, page.template, assets);
|
|
16
|
+
collectStyleAssets(root, page.style, assets);
|
|
17
|
+
}
|
|
18
|
+
function collectComponentAssets(root, component, assets) {
|
|
19
|
+
collectNodeAssets(root, component.template, assets);
|
|
20
|
+
collectStyleAssets(root, component.style, assets);
|
|
21
|
+
}
|
|
22
|
+
function collectStyleAssets(root, style, assets) {
|
|
23
|
+
for (const rule of style.rules) for (const value of Object.values(rule.declarations)) for (const path of extractCssUrls(value)) addAsset(root, path, assets);
|
|
24
|
+
}
|
|
25
|
+
function extractCssUrls(value) {
|
|
26
|
+
const urls = [];
|
|
27
|
+
const pattern = /url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")\s]+))\s*\)/g;
|
|
28
|
+
let match;
|
|
29
|
+
while (match = pattern.exec(value)) urls.push(match[1] ?? match[2] ?? match[3]);
|
|
30
|
+
return urls;
|
|
31
|
+
}
|
|
32
|
+
function collectNodeAssets(root, nodes, assets) {
|
|
33
|
+
for (const node of nodes) switch (node.kind) {
|
|
34
|
+
case "element":
|
|
35
|
+
if (node.value.tag === "image") {
|
|
36
|
+
const src = node.value.attrs.src;
|
|
37
|
+
if (src?.kind === "static" && typeof src.value === "string") addAsset(root, src.value, assets);
|
|
38
|
+
}
|
|
39
|
+
collectNodeAssets(root, node.value.children, assets);
|
|
40
|
+
break;
|
|
41
|
+
case "conditional":
|
|
42
|
+
for (const branch of node.value.branches) collectNodeAssets(root, branch.body, assets);
|
|
43
|
+
break;
|
|
44
|
+
case "list":
|
|
45
|
+
collectNodeAssets(root, node.value.body, assets);
|
|
46
|
+
break;
|
|
47
|
+
case "fragment":
|
|
48
|
+
collectNodeAssets(root, node.value, assets);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function addAsset(root, path, assets) {
|
|
53
|
+
if (!path.startsWith("/") || assets.has(path)) return;
|
|
54
|
+
const sourcePath = resolve(root, "src", path.slice(1));
|
|
55
|
+
if (!existsSync(sourcePath)) return;
|
|
56
|
+
const bytes = readFileSync(sourcePath);
|
|
57
|
+
assets.set(path, {
|
|
58
|
+
path,
|
|
59
|
+
source_path: sourcePath,
|
|
60
|
+
digest: createHash("sha1").update(bytes).digest("hex")
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/capabilities.ts
|
|
65
|
+
function validatePlatformCapabilities(document, platform) {
|
|
66
|
+
const apiLevel = document.manifest.min_platform_version;
|
|
67
|
+
const declared = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const feature of document.manifest.features) declared.set(`feature:${feature.name}`, {
|
|
69
|
+
kind: "feature",
|
|
70
|
+
name: feature.name
|
|
71
|
+
});
|
|
72
|
+
for (const tag of collectRenderableTags(document)) declared.set(`component:${tag}`, {
|
|
73
|
+
kind: "component",
|
|
74
|
+
name: tag
|
|
75
|
+
});
|
|
76
|
+
const issues = unsupportedCapabilities(declared.values(), platform, apiLevel);
|
|
77
|
+
if (issues.length === 0) return;
|
|
78
|
+
throw new Error(platformCapabilityMessage(issues));
|
|
79
|
+
}
|
|
80
|
+
function collectRenderableTags(document) {
|
|
81
|
+
const out = /* @__PURE__ */ new Set();
|
|
82
|
+
for (const page of Object.values(document.pages)) collectTagsFromNodes(page.template, out);
|
|
83
|
+
for (const component of Object.values(document.components)) collectTagsFromNodes(component.template, out);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function collectTagsFromNodes(nodes, out) {
|
|
87
|
+
for (const node of nodes) switch (node.kind) {
|
|
88
|
+
case "element":
|
|
89
|
+
if (!node.value.is_component) out.add(node.value.tag);
|
|
90
|
+
collectTagsFromNodes(node.value.children, out);
|
|
91
|
+
break;
|
|
92
|
+
case "conditional":
|
|
93
|
+
for (const branch of node.value.branches) collectTagsFromNodes(branch.body, out);
|
|
94
|
+
break;
|
|
95
|
+
case "list":
|
|
96
|
+
collectTagsFromNodes(node.value.body, out);
|
|
97
|
+
break;
|
|
98
|
+
case "fragment":
|
|
99
|
+
collectTagsFromNodes(node.value, out);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function platformCapabilityMessage(issues) {
|
|
104
|
+
return [
|
|
105
|
+
"AstroForge 平台能力校验失败。",
|
|
106
|
+
"请移除这些使用,或为目标平台增加后端支持 / 条件编译分支:",
|
|
107
|
+
...issues.map((issue) => {
|
|
108
|
+
const label = issue.kind === "feature" ? "接口" : "组件";
|
|
109
|
+
if (issue.reason === "api-level") return `- ${label} ${issue.name}: 当前 minPlatformVersion 低于 ${issue.required}`;
|
|
110
|
+
return `- ${label} ${issue.name}: 当前目标平台 ${issue.platform} 未声明支持`;
|
|
111
|
+
})
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/config.ts
|
|
116
|
+
function readAstroForgeConfig(root, configFile = "astroforge.config.ts") {
|
|
117
|
+
const path = resolve(root, configFile);
|
|
118
|
+
return parseAstroForgeConfig(readFileSync(path, "utf8"), path);
|
|
119
|
+
}
|
|
120
|
+
function parseAstroForgeConfig(source, filename = "astroforge.config.ts") {
|
|
121
|
+
const ast = parse(source, {
|
|
122
|
+
sourceType: "module",
|
|
123
|
+
plugins: ["typescript"]
|
|
124
|
+
});
|
|
125
|
+
for (const statement of ast.program.body) {
|
|
126
|
+
if (statement.type !== "ExportDefaultDeclaration") continue;
|
|
127
|
+
const declaration = unwrapTsExpression(statement.declaration);
|
|
128
|
+
if (declaration.type !== "ObjectExpression") throw new Error(`${filename}: default export 必须是对象字面量`);
|
|
129
|
+
const config = valueFromExpression(declaration);
|
|
130
|
+
assertProjectConfig(config, filename);
|
|
131
|
+
return config;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`${filename}: 未找到 default export`);
|
|
134
|
+
}
|
|
135
|
+
function assertProjectConfig(value, filename) {
|
|
136
|
+
if (!isRecord(value) || !isRecord(value.manifest)) throw new Error(`${filename}: 缺少 manifest 对象`);
|
|
137
|
+
const manifest = value.manifest;
|
|
138
|
+
assertString(manifest.package, filename, "manifest.package");
|
|
139
|
+
assertString(manifest.name, filename, "manifest.name");
|
|
140
|
+
assertString(manifest.versionName, filename, "manifest.versionName");
|
|
141
|
+
assertNumber(manifest.versionCode, filename, "manifest.versionCode");
|
|
142
|
+
assertNumber(manifest.minPlatformVersion, filename, "manifest.minPlatformVersion");
|
|
143
|
+
assertString(manifest.icon, filename, "manifest.icon");
|
|
144
|
+
if (!Array.isArray(manifest.deviceTypeList)) throw new Error(`${filename}: manifest.deviceTypeList 必须是字符串数组`);
|
|
145
|
+
for (const value of manifest.deviceTypeList) if (typeof value !== "string") throw new Error(`${filename}: manifest.deviceTypeList 必须是字符串数组`);
|
|
146
|
+
}
|
|
147
|
+
function valueFromExpression(node) {
|
|
148
|
+
const expression = unwrapTsExpression(node);
|
|
149
|
+
switch (expression.type) {
|
|
150
|
+
case "StringLiteral": return expression.value;
|
|
151
|
+
case "NumericLiteral": return expression.value;
|
|
152
|
+
case "BooleanLiteral": return expression.value;
|
|
153
|
+
case "NullLiteral": return null;
|
|
154
|
+
case "UnaryExpression": {
|
|
155
|
+
const argument = unwrapTsExpression(expression.argument);
|
|
156
|
+
if (expression.operator === "-" && argument.type === "NumericLiteral") return -argument.value;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "ArrayExpression": return expression.elements.map((element) => {
|
|
160
|
+
if (!element) return null;
|
|
161
|
+
if (element.type === "SpreadElement") throw new Error("配置数组暂不支持展开语法");
|
|
162
|
+
return valueFromExpression(element);
|
|
163
|
+
});
|
|
164
|
+
case "ObjectExpression": {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const property of expression.properties) {
|
|
167
|
+
if (property.type === "SpreadElement") throw new Error("配置对象暂不支持展开语法");
|
|
168
|
+
if (property.type !== "ObjectProperty") throw new Error("配置对象暂不支持访问器或方法");
|
|
169
|
+
const key = propertyKey(property.key);
|
|
170
|
+
out[key] = valueFromExpression(property.value);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`配置项仅支持可序列化字面量,收到 ${expression.type}`);
|
|
176
|
+
}
|
|
177
|
+
function propertyKey(node) {
|
|
178
|
+
switch (node.type) {
|
|
179
|
+
case "Identifier": return node.name;
|
|
180
|
+
case "StringLiteral":
|
|
181
|
+
case "NumericLiteral": return String(node.value);
|
|
182
|
+
default: throw new Error(`不支持的配置键类型:${node.type}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function unwrapTsExpression(node) {
|
|
186
|
+
let current = node;
|
|
187
|
+
while (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression" || current.type === "ParenthesizedExpression") current = current.expression;
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
function assertString(value, filename, key) {
|
|
191
|
+
if (typeof value !== "string") throw new Error(`${filename}: ${key} 必须是字符串`);
|
|
192
|
+
}
|
|
193
|
+
function assertNumber(value, filename, key) {
|
|
194
|
+
if (typeof value !== "number") throw new Error(`${filename}: ${key} 必须是数字`);
|
|
195
|
+
}
|
|
196
|
+
function isRecord(value) {
|
|
197
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/ir.ts
|
|
201
|
+
function createEmptyScript() {
|
|
202
|
+
return {
|
|
203
|
+
props: {},
|
|
204
|
+
private_data: {},
|
|
205
|
+
methods: {},
|
|
206
|
+
lifecycle: {}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function createEmptyStyleTable() {
|
|
210
|
+
return { rules: [] };
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/style.ts
|
|
214
|
+
function parseStyleTable(source) {
|
|
215
|
+
const rules = [];
|
|
216
|
+
const css = stripComments(source);
|
|
217
|
+
const pattern = /([^{}]+)\{([^{}]*)\}/g;
|
|
218
|
+
let match;
|
|
219
|
+
while (match = pattern.exec(css)) {
|
|
220
|
+
const selectorSource = match[1].trim();
|
|
221
|
+
const body = match[2].trim();
|
|
222
|
+
if (!selectorSource || !body) continue;
|
|
223
|
+
rules.push({
|
|
224
|
+
selectors: selectorSource.split(",").map((selector) => parseSelector(selector.trim())),
|
|
225
|
+
declarations: parseDeclarations(body)
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return { rules };
|
|
229
|
+
}
|
|
230
|
+
function stripComments(source) {
|
|
231
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
232
|
+
}
|
|
233
|
+
function parseSelector(selector) {
|
|
234
|
+
if (selector === "@font-face") return {
|
|
235
|
+
kind: "font_face",
|
|
236
|
+
name: "font-face"
|
|
237
|
+
};
|
|
238
|
+
if (selector.startsWith("@keyframes ")) return {
|
|
239
|
+
kind: "keyframes",
|
|
240
|
+
name: selector.slice(11).trim()
|
|
241
|
+
};
|
|
242
|
+
if (selector.startsWith(".")) return {
|
|
243
|
+
kind: "class",
|
|
244
|
+
name: selector.slice(1)
|
|
245
|
+
};
|
|
246
|
+
if (selector.startsWith("#")) return {
|
|
247
|
+
kind: "id",
|
|
248
|
+
name: selector.slice(1)
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
kind: selectorKindForTag(selector),
|
|
252
|
+
name: selector
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function selectorKindForTag(_selector) {
|
|
256
|
+
return "tag";
|
|
257
|
+
}
|
|
258
|
+
function parseDeclarations(body) {
|
|
259
|
+
const declarations = {};
|
|
260
|
+
for (const item of body.split(";")) {
|
|
261
|
+
const declaration = item.trim();
|
|
262
|
+
if (!declaration) continue;
|
|
263
|
+
const colon = declaration.indexOf(":");
|
|
264
|
+
if (colon === -1) continue;
|
|
265
|
+
const key = declaration.slice(0, colon).trim();
|
|
266
|
+
const value = declaration.slice(colon + 1).trim();
|
|
267
|
+
if (key && value) declarations[key] = value;
|
|
268
|
+
}
|
|
269
|
+
return declarations;
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/tsx.ts
|
|
273
|
+
const BUILTIN_COMPONENTS = new Map([
|
|
274
|
+
["A", "a"],
|
|
275
|
+
["Barcode", "barcode"],
|
|
276
|
+
["Canvas", "canvas"],
|
|
277
|
+
["Chart", "chart"],
|
|
278
|
+
["View", "div"],
|
|
279
|
+
["Div", "div"],
|
|
280
|
+
["Text", "text"],
|
|
281
|
+
["Image", "image"],
|
|
282
|
+
["ImageAnimator", "image-animator"],
|
|
283
|
+
["Input", "input"],
|
|
284
|
+
["Label", "label"],
|
|
285
|
+
["List", "list"],
|
|
286
|
+
["ListItem", "list-item"],
|
|
287
|
+
["Marquee", "marquee"],
|
|
288
|
+
["Media", "media"],
|
|
289
|
+
["Option", "option"],
|
|
290
|
+
["Picker", "picker"],
|
|
291
|
+
["Popup", "popup"],
|
|
292
|
+
["Progress", "progress"],
|
|
293
|
+
["Prompt", "prompt"],
|
|
294
|
+
["QR", "qr"],
|
|
295
|
+
["Qr", "qr"],
|
|
296
|
+
["Rating", "rating"],
|
|
297
|
+
["Refresh", "refresh"],
|
|
298
|
+
["RefreshFooter", "refresh-footer"],
|
|
299
|
+
["RefreshHeader", "refresh-header"],
|
|
300
|
+
["RichText", "richtext"],
|
|
301
|
+
["Screen", "screen"],
|
|
302
|
+
["Scroll", "scroll"],
|
|
303
|
+
["Select", "select"],
|
|
304
|
+
["Slider", "slider"],
|
|
305
|
+
["Span", "span"],
|
|
306
|
+
["Stack", "stack"],
|
|
307
|
+
["Swiper", "swiper"],
|
|
308
|
+
["Switch", "switch"],
|
|
309
|
+
["TabContent", "tab-content"],
|
|
310
|
+
["TabBar", "tabbar"],
|
|
311
|
+
["Tabs", "tabs"],
|
|
312
|
+
["Textarea", "textarea"],
|
|
313
|
+
["Video", "video"]
|
|
314
|
+
]);
|
|
315
|
+
function extractPageFromTsx(source, options) {
|
|
316
|
+
return extractPageModuleFromTsx(source, options).page;
|
|
317
|
+
}
|
|
318
|
+
function extractPageModuleFromTsx(source, options) {
|
|
319
|
+
const ast = parse(source, {
|
|
320
|
+
sourceType: "module",
|
|
321
|
+
plugins: ["typescript", "jsx"]
|
|
322
|
+
});
|
|
323
|
+
const bindings = collectAstroForgeImports(ast.program.body);
|
|
324
|
+
const pageFunction = findDefaultPageFunction(ast.program.body, options.filename);
|
|
325
|
+
const script = extractScript(source, ast.program.body, pageFunction.node, options.filename);
|
|
326
|
+
const template = templateFromRenderExpression(pageFunction.renderExpression, bindings, options.filename);
|
|
327
|
+
const components = extractLocalComponents(source, ast.program.body, pageFunction.node, bindings, options.filename);
|
|
328
|
+
const componentImports = collectComponentImports(ast.program.body, bindings, Object.keys(components));
|
|
329
|
+
const imports = importsFromTemplate(template, components);
|
|
330
|
+
return {
|
|
331
|
+
page: {
|
|
332
|
+
route: options.route,
|
|
333
|
+
imports,
|
|
334
|
+
template,
|
|
335
|
+
script,
|
|
336
|
+
style: extractStyleTable(source, ast.program.body, options.filename, options.loadStyle)
|
|
337
|
+
},
|
|
338
|
+
components,
|
|
339
|
+
componentImports
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function extractComponentFromTsx(source, options) {
|
|
343
|
+
const ast = parse(source, {
|
|
344
|
+
sourceType: "module",
|
|
345
|
+
plugins: ["typescript", "jsx"]
|
|
346
|
+
});
|
|
347
|
+
const bindings = collectAstroForgeImports(ast.program.body);
|
|
348
|
+
const located = locateComponentFunction(ast.program.body, options.exportName, options.filename);
|
|
349
|
+
const template = templateFromRenderExpression(located.renderExpression, bindings, options.filename);
|
|
350
|
+
const componentImports = collectComponentImports(ast.program.body, bindings, []);
|
|
351
|
+
return {
|
|
352
|
+
component: {
|
|
353
|
+
name: kebabCase(located.localName),
|
|
354
|
+
template,
|
|
355
|
+
script: {
|
|
356
|
+
...createEmptyScript(),
|
|
357
|
+
props: extractComponentProps(located.node, ast.program.body, options.filename)
|
|
358
|
+
},
|
|
359
|
+
style: extractStyleTable(source, ast.program.body, options.filename, options.loadStyle)
|
|
360
|
+
},
|
|
361
|
+
componentImports
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function locateComponentFunction(body, exportName, filename) {
|
|
365
|
+
if (!exportName) {
|
|
366
|
+
const page = findDefaultPageFunction(body, filename);
|
|
367
|
+
return {
|
|
368
|
+
localName: page.node.id?.name ?? defaultExportName(body) ?? "Component",
|
|
369
|
+
node: page.node,
|
|
370
|
+
renderExpression: page.renderExpression
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
for (const statement of body) {
|
|
374
|
+
if (statement.type !== "ExportNamedDeclaration" || !statement.declaration) continue;
|
|
375
|
+
if (statement.declaration.type === "FunctionDeclaration" && statement.declaration.id?.name === exportName) return {
|
|
376
|
+
localName: exportName,
|
|
377
|
+
node: statement.declaration,
|
|
378
|
+
renderExpression: renderExpressionFromFunction(statement.declaration, filename)
|
|
379
|
+
};
|
|
380
|
+
if (statement.declaration.type === "VariableDeclaration") {
|
|
381
|
+
for (const declarator of statement.declaration.declarations) if (declarator.id.type === "Identifier" && declarator.id.name === exportName && declarator.init && isFunctionLike(unwrapExpression(declarator.init))) return {
|
|
382
|
+
localName: exportName,
|
|
383
|
+
node: unwrapExpression(declarator.init),
|
|
384
|
+
renderExpression: renderExpressionFromFunction(unwrapExpression(declarator.init), filename)
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const statement of body) if (statement.type === "ExportNamedDeclaration" && !statement.declaration && statement.specifiers.some((s) => s.exported?.name === exportName)) {
|
|
389
|
+
const local = findTopLevelBinding(body, exportName);
|
|
390
|
+
if (local && isFunctionLike(local)) return {
|
|
391
|
+
localName: exportName,
|
|
392
|
+
node: local,
|
|
393
|
+
renderExpression: renderExpressionFromFunction(local, filename)
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`${filename ?? "TSX"}: 未找到名为 ${exportName} 的命名导出函数`);
|
|
397
|
+
}
|
|
398
|
+
function defaultExportName(body) {
|
|
399
|
+
for (const statement of body) {
|
|
400
|
+
if (statement.type !== "ExportDefaultDeclaration") continue;
|
|
401
|
+
const declaration = unwrapExpression(statement.declaration);
|
|
402
|
+
if (declaration.type === "FunctionDeclaration" && declaration.id?.name) return declaration.id.name;
|
|
403
|
+
if (declaration.type === "Identifier") return declaration.name;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function collectComponentImports(body, builtinBindings, localComponentNames) {
|
|
407
|
+
const localTagSet = new Set(localComponentNames);
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const statement of body) {
|
|
410
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
411
|
+
const source = statement.source.value;
|
|
412
|
+
if (typeof source !== "string") continue;
|
|
413
|
+
if (!source.startsWith("./") && !source.startsWith("../")) continue;
|
|
414
|
+
for (const specifier of statement.specifiers) {
|
|
415
|
+
const localName = specifier.type === "ImportDefaultSpecifier" ? specifier.local.name : specifier.type === "ImportSpecifier" ? specifier.local.name : void 0;
|
|
416
|
+
if (!localName || !isPascalCase(localName)) continue;
|
|
417
|
+
if (builtinBindings.has(localName)) continue;
|
|
418
|
+
const tag = kebabCase(localName);
|
|
419
|
+
if (localTagSet.has(tag)) continue;
|
|
420
|
+
const exportName = specifier.type === "ImportSpecifier" ? specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value : void 0;
|
|
421
|
+
out.push({
|
|
422
|
+
localName,
|
|
423
|
+
tag,
|
|
424
|
+
from: source,
|
|
425
|
+
exportName
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
function extractAppFromTsx(source, filename) {
|
|
432
|
+
const ast = parse(source, {
|
|
433
|
+
sourceType: "module",
|
|
434
|
+
plugins: ["typescript", "jsx"]
|
|
435
|
+
});
|
|
436
|
+
const context = createScriptContext(source, filename);
|
|
437
|
+
const lifecycle = {};
|
|
438
|
+
for (const statement of ast.program.body) {
|
|
439
|
+
if (statement.type !== "ExportDefaultDeclaration") continue;
|
|
440
|
+
const declaration = unwrapExpression(statement.declaration);
|
|
441
|
+
if (declaration.type !== "ObjectExpression") throw new Error(`${filename ?? "app.tsx"}: app default export 必须是对象字面量`);
|
|
442
|
+
collectLifecycleObject(lifecycle, context, declaration, "body");
|
|
443
|
+
return { lifecycle };
|
|
444
|
+
}
|
|
445
|
+
return { lifecycle };
|
|
446
|
+
}
|
|
447
|
+
function collectAstroForgeImports(body) {
|
|
448
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
449
|
+
for (const statement of body) {
|
|
450
|
+
if (statement.type !== "ImportDeclaration" || !["@astralsight/astroforge-core", "@astralsight/astroforge-core/components"].includes(statement.source.value)) continue;
|
|
451
|
+
for (const specifier of statement.specifiers) {
|
|
452
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
453
|
+
const imported = specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
|
|
454
|
+
const tag = BUILTIN_COMPONENTS.get(imported);
|
|
455
|
+
if (tag) bindings.set(specifier.local.name, tag);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return bindings;
|
|
459
|
+
}
|
|
460
|
+
function extractLocalComponents(source, body, pageFunction, bindings, filename) {
|
|
461
|
+
const components = {};
|
|
462
|
+
for (const statement of body) {
|
|
463
|
+
const candidate = componentCandidate(statement);
|
|
464
|
+
if (!candidate || candidate.node === pageFunction || !isPascalCase(candidate.name)) continue;
|
|
465
|
+
const fn = unwrapExpression(candidate.node);
|
|
466
|
+
if (!isFunctionLike(fn)) continue;
|
|
467
|
+
const name = kebabCase(candidate.name);
|
|
468
|
+
components[name] = {
|
|
469
|
+
name,
|
|
470
|
+
template: templateFromRenderExpression(renderExpressionFromFunction(fn, filename), bindings, filename),
|
|
471
|
+
script: {
|
|
472
|
+
...createEmptyScript(),
|
|
473
|
+
props: extractComponentProps(fn, body, filename)
|
|
474
|
+
},
|
|
475
|
+
style: createEmptyStyleTable()
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return components;
|
|
479
|
+
}
|
|
480
|
+
function componentCandidate(statement) {
|
|
481
|
+
if (statement.type === "FunctionDeclaration" && statement.id?.name) return {
|
|
482
|
+
name: statement.id.name,
|
|
483
|
+
node: statement
|
|
484
|
+
};
|
|
485
|
+
if (statement.type !== "VariableDeclaration") return;
|
|
486
|
+
for (const declarator of statement.declarations) if (declarator.id.type === "Identifier" && declarator.init) return {
|
|
487
|
+
name: declarator.id.name,
|
|
488
|
+
node: unwrapExpression(declarator.init)
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function extractComponentProps(fn, moduleBody, filename) {
|
|
492
|
+
const first = fn.params?.[0];
|
|
493
|
+
if (!first) return {};
|
|
494
|
+
const node = unwrapExpression(first);
|
|
495
|
+
const typeNode = propTypeNode(node);
|
|
496
|
+
const props = typeNode ? propsFromTypeNode(typeNode, moduleBody, filename) : {};
|
|
497
|
+
if (node.type === "ObjectPattern") for (const property of node.properties) {
|
|
498
|
+
if (property.type !== "ObjectProperty") continue;
|
|
499
|
+
if (property.computed) continue;
|
|
500
|
+
const key = objectPropertyKey(property.key, filename);
|
|
501
|
+
const value = unwrapExpression(property.value);
|
|
502
|
+
if (value.type === "AssignmentPattern") {
|
|
503
|
+
const fallback = staticAttrLiteral(value.right);
|
|
504
|
+
if (fallback !== void 0) props[key] = {
|
|
505
|
+
type: props[key]?.type ?? propTypeFromDefault(fallback),
|
|
506
|
+
default: fallback
|
|
507
|
+
};
|
|
508
|
+
} else if (!props[key]) props[key] = { type: "Object" };
|
|
509
|
+
}
|
|
510
|
+
return props;
|
|
511
|
+
}
|
|
512
|
+
function propTypeNode(param) {
|
|
513
|
+
if (param.type === "Identifier" || param.type === "ObjectPattern") return param.typeAnnotation?.typeAnnotation;
|
|
514
|
+
if (param.type === "AssignmentPattern") return propTypeNode(unwrapExpression(param.left));
|
|
515
|
+
}
|
|
516
|
+
function propsFromTypeNode(typeNode, moduleBody, filename) {
|
|
517
|
+
const node = unwrapExpression(typeNode);
|
|
518
|
+
if (node.type === "TSTypeLiteral") return propsFromTypeLiteral(node, moduleBody, filename);
|
|
519
|
+
if (node.type === "TSTypeReference" && node.typeName.type === "Identifier") {
|
|
520
|
+
const resolved = findTypeBinding(moduleBody, node.typeName.name);
|
|
521
|
+
if (!resolved) return {};
|
|
522
|
+
return propsFromTypeNode(resolved, moduleBody, filename);
|
|
523
|
+
}
|
|
524
|
+
if (node.type === "TSInterfaceDeclaration") return propsFromTypeLiteral(node.body, moduleBody, filename);
|
|
525
|
+
if (node.type === "TSTypeAliasDeclaration") return propsFromTypeNode(node.typeAnnotation, moduleBody, filename);
|
|
526
|
+
return {};
|
|
527
|
+
}
|
|
528
|
+
function propsFromTypeLiteral(node, moduleBody, filename) {
|
|
529
|
+
const out = {};
|
|
530
|
+
for (const member of node.members ?? node.body ?? []) {
|
|
531
|
+
if (member.type !== "TSPropertySignature") continue;
|
|
532
|
+
const key = objectPropertyKey(member.key, filename);
|
|
533
|
+
const valueType = member.typeAnnotation?.typeAnnotation;
|
|
534
|
+
out[key] = { type: valueType ? propRuntimeType(valueType, moduleBody, filename) : "Object" };
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
function findTypeBinding(moduleBody, name) {
|
|
539
|
+
for (const statement of moduleBody) {
|
|
540
|
+
const node = statement.type === "ExportNamedDeclaration" && statement.declaration ? statement.declaration : statement;
|
|
541
|
+
if ((node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") && node.id?.name === name) return node;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function propRuntimeType(typeNode, moduleBody, filename) {
|
|
545
|
+
const node = unwrapExpression(typeNode);
|
|
546
|
+
switch (node.type) {
|
|
547
|
+
case "TSStringKeyword": return "String";
|
|
548
|
+
case "TSNumberKeyword": return "Number";
|
|
549
|
+
case "TSBooleanKeyword": return "Boolean";
|
|
550
|
+
case "TSArrayType":
|
|
551
|
+
case "TSTupleType": return "Array";
|
|
552
|
+
case "TSFunctionType": return "Function";
|
|
553
|
+
case "TSTypeLiteral": return "Object";
|
|
554
|
+
case "TSTypeReference": {
|
|
555
|
+
if (node.typeName.type !== "Identifier") return "Object";
|
|
556
|
+
const name = node.typeName.name;
|
|
557
|
+
if (["Array", "ReadonlyArray"].includes(name)) return "Array";
|
|
558
|
+
if (["Function"].includes(name)) return "Function";
|
|
559
|
+
const resolved = findTypeBinding(moduleBody, name);
|
|
560
|
+
if (!resolved) return "Object";
|
|
561
|
+
return propRuntimeType(resolved, moduleBody, filename);
|
|
562
|
+
}
|
|
563
|
+
case "TSInterfaceDeclaration": return "Object";
|
|
564
|
+
case "TSTypeAliasDeclaration": return propRuntimeType(node.typeAnnotation, moduleBody, filename);
|
|
565
|
+
case "TSUnionType": return propRuntimeTypeFromUnion(node.types, moduleBody, filename);
|
|
566
|
+
default: return "Object";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function propRuntimeTypeFromUnion(types, moduleBody, filename) {
|
|
570
|
+
const concrete = types.filter((item) => ![
|
|
571
|
+
"TSNullKeyword",
|
|
572
|
+
"TSUndefinedKeyword",
|
|
573
|
+
"TSVoidKeyword"
|
|
574
|
+
].includes(unwrapExpression(item).type));
|
|
575
|
+
const mapped = new Set(concrete.map((item) => propRuntimeType(item, moduleBody, filename)));
|
|
576
|
+
return mapped.size === 1 ? [...mapped][0] : "Object";
|
|
577
|
+
}
|
|
578
|
+
function propTypeFromDefault(value) {
|
|
579
|
+
if (typeof value === "string") return "String";
|
|
580
|
+
if (typeof value === "number") return "Number";
|
|
581
|
+
if (typeof value === "boolean") return "Boolean";
|
|
582
|
+
if (Array.isArray(value)) return "Array";
|
|
583
|
+
return "Object";
|
|
584
|
+
}
|
|
585
|
+
function importsFromTemplate(template, components) {
|
|
586
|
+
const imports = {};
|
|
587
|
+
collectTemplateImports(template, components, imports);
|
|
588
|
+
return imports;
|
|
589
|
+
}
|
|
590
|
+
function collectTemplateImports(nodes, components, imports) {
|
|
591
|
+
for (const node of nodes) switch (node.kind) {
|
|
592
|
+
case "element":
|
|
593
|
+
if (node.value.is_component && components[node.value.tag]) imports[node.value.tag] = node.value.tag;
|
|
594
|
+
collectTemplateImports(node.value.children, components, imports);
|
|
595
|
+
break;
|
|
596
|
+
case "conditional":
|
|
597
|
+
for (const branch of node.value.branches) collectTemplateImports(branch.body, components, imports);
|
|
598
|
+
break;
|
|
599
|
+
case "list":
|
|
600
|
+
collectTemplateImports(node.value.body, components, imports);
|
|
601
|
+
break;
|
|
602
|
+
case "fragment":
|
|
603
|
+
collectTemplateImports(node.value, components, imports);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function extractStyleTable(source, body, filename, loadStyle) {
|
|
608
|
+
const chunks = [];
|
|
609
|
+
for (const statement of body) {
|
|
610
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
611
|
+
const specifier = statement.source.value;
|
|
612
|
+
if (typeof specifier !== "string" || !specifier.endsWith(".css")) continue;
|
|
613
|
+
const css = loadStyle?.(specifier, filename);
|
|
614
|
+
if (css === void 0) throw new Error(`${filename ?? "TSX"}: CSS import ${specifier} 无法解析`);
|
|
615
|
+
chunks.push(css);
|
|
616
|
+
}
|
|
617
|
+
for (const statement of body) {
|
|
618
|
+
if (statement.type !== "ExportNamedDeclaration" || statement.declaration?.type !== "VariableDeclaration") continue;
|
|
619
|
+
for (const declarator of statement.declaration.declarations) {
|
|
620
|
+
if (declarator.id.type !== "Identifier" || !["style", "styles"].includes(declarator.id.name) || !declarator.init) continue;
|
|
621
|
+
const init = unwrapExpression(declarator.init);
|
|
622
|
+
if (init.type === "StringLiteral") {
|
|
623
|
+
chunks.push(init.value);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (init.type === "TemplateLiteral" && init.expressions.length === 0) {
|
|
627
|
+
chunks.push(init.quasis.map((quasi) => quasi.value.cooked).join(""));
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
throw new Error(`${filename ?? "TSX"}: styles 必须是静态字符串`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (chunks.length === 0) return createEmptyStyleTable();
|
|
634
|
+
return parseStyleTable(chunks.join("\n"));
|
|
635
|
+
}
|
|
636
|
+
function findDefaultPageFunction(body, filename) {
|
|
637
|
+
for (const statement of body) {
|
|
638
|
+
if (statement.type !== "ExportDefaultDeclaration") continue;
|
|
639
|
+
const declaration = unwrapExpression(statement.declaration);
|
|
640
|
+
if (isFunctionLike(declaration)) return {
|
|
641
|
+
node: declaration,
|
|
642
|
+
renderExpression: renderExpressionFromFunction(declaration, filename)
|
|
643
|
+
};
|
|
644
|
+
if (declaration.type === "Identifier") {
|
|
645
|
+
const binding = findTopLevelBinding(body, declaration.name);
|
|
646
|
+
if (binding && isFunctionLike(binding)) return {
|
|
647
|
+
node: binding,
|
|
648
|
+
renderExpression: renderExpressionFromFunction(binding, filename)
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
throw new Error(`${filename ?? "TSX"}: default export 必须是返回 JSX 的函数`);
|
|
652
|
+
}
|
|
653
|
+
throw new Error(`${filename ?? "TSX"}: 未找到 default export`);
|
|
654
|
+
}
|
|
655
|
+
function extractScript(source, moduleBody, pageFunction, filename) {
|
|
656
|
+
const script = createEmptyScript();
|
|
657
|
+
const body = unwrapExpression(pageFunction.body);
|
|
658
|
+
if (body.type !== "BlockStatement") return script;
|
|
659
|
+
const context = createScriptContext(source, filename);
|
|
660
|
+
for (const statement of body.body) collectUseStateDeclaration(script, context, statement);
|
|
661
|
+
for (const statement of body.body) collectMethod(script, context, statement);
|
|
662
|
+
collectPageLifecycle(script, context, moduleBody);
|
|
663
|
+
collectUseEffectCalls(script, context, body.body);
|
|
664
|
+
return script;
|
|
665
|
+
}
|
|
666
|
+
function createScriptContext(source, filename) {
|
|
667
|
+
return {
|
|
668
|
+
source,
|
|
669
|
+
filename,
|
|
670
|
+
stateVars: /* @__PURE__ */ new Set(),
|
|
671
|
+
stateSetters: /* @__PURE__ */ new Map()
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
function collectUseStateDeclaration(script, context, statement) {
|
|
675
|
+
if (statement.type !== "VariableDeclaration") return;
|
|
676
|
+
for (const declarator of statement.declarations) {
|
|
677
|
+
if (declarator.id.type !== "ArrayPattern" || declarator.id.elements.length < 2 || !declarator.init || !isUseStateCall(declarator.init)) continue;
|
|
678
|
+
const stateId = declarator.id.elements[0];
|
|
679
|
+
const setterId = declarator.id.elements[1];
|
|
680
|
+
if (stateId?.type !== "Identifier" || setterId?.type !== "Identifier") throw new Error(`${context.filename ?? "TSX"}: useState 解构必须形如 const [value, setValue] = useState(...)`);
|
|
681
|
+
const initial = declarator.init.arguments[0];
|
|
682
|
+
script.private_data[stateId.name] = initial ? staticJsonValue(initial, context.filename) : null;
|
|
683
|
+
context.stateVars.add(stateId.name);
|
|
684
|
+
context.stateSetters.set(setterId.name, stateId.name);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function collectMethod(script, context, statement) {
|
|
688
|
+
if (statement.type === "FunctionDeclaration") {
|
|
689
|
+
if (!statement.id?.name) return;
|
|
690
|
+
script.methods[statement.id.name] = lowerFunctionLike(context, statement, statement.id.name);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (statement.type !== "VariableDeclaration") return;
|
|
694
|
+
for (const declarator of statement.declarations) {
|
|
695
|
+
if (declarator.id.type !== "Identifier" || !declarator.init || !isFunctionLike(unwrapExpression(declarator.init))) continue;
|
|
696
|
+
script.methods[declarator.id.name] = lowerFunctionLike(context, unwrapExpression(declarator.init), declarator.id.name);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function collectUseEffectCalls(script, context, statements) {
|
|
700
|
+
for (const statement of statements) {
|
|
701
|
+
if (statement.type !== "ExpressionStatement" || !isUseEffectCall(statement.expression)) continue;
|
|
702
|
+
const call = unwrapExpression(statement.expression);
|
|
703
|
+
assertSupportedEffectDeps(call, context.filename);
|
|
704
|
+
const effect = effectBodies(context, call.arguments[0]);
|
|
705
|
+
if (effect.body) appendLifecycleBody(script.lifecycle, "onReady", effect.body);
|
|
706
|
+
if (effect.cleanup) appendLifecycleBody(script.lifecycle, "onDestroy", effect.cleanup);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function isUseEffectCall(node) {
|
|
710
|
+
const expression = unwrapExpression(node);
|
|
711
|
+
return expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "useEffect";
|
|
712
|
+
}
|
|
713
|
+
function assertSupportedEffectDeps(call, filename) {
|
|
714
|
+
const deps = call.arguments[1];
|
|
715
|
+
if (!deps) return;
|
|
716
|
+
const node = unwrapExpression(deps);
|
|
717
|
+
if (node.type === "ArrayExpression" && node.elements.length === 0) return;
|
|
718
|
+
throw new Error(`${filename ?? "TSX"}: useEffect 静态展开仅支持省略依赖或空依赖数组`);
|
|
719
|
+
}
|
|
720
|
+
function effectBodies(context, callback) {
|
|
721
|
+
const fn = unwrapExpression(callback);
|
|
722
|
+
if (!isFunctionLike(fn)) throw new Error(`${context.filename ?? "TSX"}: useEffect 参数必须是函数`);
|
|
723
|
+
const body = unwrapExpression(fn.body);
|
|
724
|
+
if (body.type !== "BlockStatement") return { body: `${lowerExpression(context, body)};` };
|
|
725
|
+
const statements = [];
|
|
726
|
+
let cleanup;
|
|
727
|
+
for (const statement of body.body) {
|
|
728
|
+
if (statement.type === "ReturnStatement") {
|
|
729
|
+
if (!statement.argument) continue;
|
|
730
|
+
const returned = unwrapExpression(statement.argument);
|
|
731
|
+
if (!isFunctionLike(returned)) throw new Error(`${context.filename ?? "TSX"}: useEffect cleanup 必须返回函数`);
|
|
732
|
+
cleanup = lowerFunctionBodySource(context, returned.body);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const lowered = lowerStatement(context, statement);
|
|
736
|
+
if (lowered) statements.push(lowered);
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
body: statements.join("\n"),
|
|
740
|
+
cleanup
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function collectPageLifecycle(script, context, moduleBody) {
|
|
744
|
+
for (const statement of moduleBody) {
|
|
745
|
+
if (statement.type !== "ExportNamedDeclaration" || statement.declaration?.type !== "VariableDeclaration") continue;
|
|
746
|
+
for (const declarator of statement.declaration.declarations) {
|
|
747
|
+
if (declarator.id.type !== "Identifier" || declarator.id.name !== "lifecycle" || !declarator.init) continue;
|
|
748
|
+
const init = unwrapExpression(declarator.init);
|
|
749
|
+
if (init.type !== "ObjectExpression") throw new Error(`${context.filename ?? "TSX"}: lifecycle 必须是对象字面量`);
|
|
750
|
+
collectLifecycleObject(script.lifecycle, context, init, "function");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function collectLifecycleObject(out, context, object, mode) {
|
|
755
|
+
for (const property of object.properties) {
|
|
756
|
+
if (property.type === "SpreadElement") throw new Error(`${context.filename ?? "TSX"}: lifecycle 暂不支持展开语法`);
|
|
757
|
+
const key = lifecycleKey(property.key, context.filename);
|
|
758
|
+
const fn = lifecycleFunctionNode(property, context.filename);
|
|
759
|
+
out[key] = mode === "function" ? lowerFunctionLike(context, fn, key) : lowerFunctionBodySource(context, fn.body);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function appendLifecycleBody(lifecycle, name, body) {
|
|
763
|
+
const existing = lifecycle[name];
|
|
764
|
+
lifecycle[name] = `function ${name}() {\n${indentJsBody([existing ? functionBodyFromSource(existing) : "", body].map((item) => item.trim()).filter(Boolean).join("\n"))}\n}`;
|
|
765
|
+
}
|
|
766
|
+
function functionBodyFromSource(source) {
|
|
767
|
+
const match = source.match(/^function\s+[A-Za-z_$][\w$]*\([^)]*\)\s*\{\n?([\s\S]*)\n?\}$/);
|
|
768
|
+
if (!match) return source;
|
|
769
|
+
return match[1].split("\n").map((line) => line.startsWith(" ") ? line.slice(2) : line).join("\n").trim();
|
|
770
|
+
}
|
|
771
|
+
function indentJsBody(source) {
|
|
772
|
+
return source.split("\n").map((line) => line.trim() ? ` ${line}` : "").join("\n");
|
|
773
|
+
}
|
|
774
|
+
function lifecycleFunctionNode(property, filename) {
|
|
775
|
+
if (property.type === "ObjectMethod") return property;
|
|
776
|
+
if (property.type !== "ObjectProperty") throw new Error(`${filename ?? "TSX"}: lifecycle 仅支持方法或函数属性`);
|
|
777
|
+
const value = unwrapExpression(property.value);
|
|
778
|
+
if (!isFunctionLike(value)) throw new Error(`${filename ?? "TSX"}: lifecycle 属性值必须是函数`);
|
|
779
|
+
return value;
|
|
780
|
+
}
|
|
781
|
+
function lifecycleKey(node, filename) {
|
|
782
|
+
switch (node.type) {
|
|
783
|
+
case "Identifier": return node.name;
|
|
784
|
+
case "StringLiteral": return node.value;
|
|
785
|
+
default: throw new Error(`${filename ?? "TSX"}: lifecycle key 必须是标识符或字符串`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function isUseStateCall(node) {
|
|
789
|
+
const expression = unwrapExpression(node);
|
|
790
|
+
return expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "useState";
|
|
791
|
+
}
|
|
792
|
+
function lowerFunctionLike(context, node, methodName) {
|
|
793
|
+
return `function ${methodName}(${node.params.map((param) => lowerParameter(context, param)).join(", ")}) ${lowerFunctionBody(context, node.body)}`;
|
|
794
|
+
}
|
|
795
|
+
function lowerParameter(context, param) {
|
|
796
|
+
const node = unwrapExpression(param);
|
|
797
|
+
switch (node.type) {
|
|
798
|
+
case "Identifier": return node.name;
|
|
799
|
+
case "RestElement": return `...${lowerParameter(context, node.argument)}`;
|
|
800
|
+
case "AssignmentPattern": return `${lowerParameter(context, node.left)} = ${sourceForNode(context.source, node.right)}`;
|
|
801
|
+
default: throw new Error(`不支持的方法参数形态:${node.type}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function lowerFunctionBody(context, body) {
|
|
805
|
+
const block = unwrapExpression(body);
|
|
806
|
+
if (block.type !== "BlockStatement") return `{ return ${lowerExpression(context, block)}; }`;
|
|
807
|
+
const statements = block.body.map((statement) => lowerStatement(context, statement)).filter(Boolean);
|
|
808
|
+
if (statements.length === 0) return "{}";
|
|
809
|
+
return `{\n${statements.map((statement) => ` ${statement}`).join("\n")}\n}`;
|
|
810
|
+
}
|
|
811
|
+
function lowerFunctionBodySource(context, body) {
|
|
812
|
+
const block = unwrapExpression(body);
|
|
813
|
+
if (block.type !== "BlockStatement") return `return ${lowerExpression(context, block)};`;
|
|
814
|
+
return block.body.map((statement) => lowerStatement(context, statement)).filter(Boolean).join("\n");
|
|
815
|
+
}
|
|
816
|
+
function lowerStatement(context, statement) {
|
|
817
|
+
switch (statement.type) {
|
|
818
|
+
case "ExpressionStatement": return `${lowerExpression(context, statement.expression)};`;
|
|
819
|
+
case "ReturnStatement": return statement.argument ? `return ${lowerExpression(context, statement.argument)};` : "return;";
|
|
820
|
+
case "VariableDeclaration": return lowerVariableDeclaration(context, statement);
|
|
821
|
+
default: return sourceForNode(context.source, statement);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function lowerVariableDeclaration(context, statement) {
|
|
825
|
+
const declarations = statement.declarations.map((declarator) => {
|
|
826
|
+
const id = sourceForNode(context.source, declarator.id);
|
|
827
|
+
if (!declarator.init) return id;
|
|
828
|
+
return `${id} = ${lowerExpression(context, declarator.init)}`;
|
|
829
|
+
});
|
|
830
|
+
return `${statement.kind} ${declarations.join(", ")};`;
|
|
831
|
+
}
|
|
832
|
+
function lowerExpression(context, expression, aliases = /* @__PURE__ */ new Map()) {
|
|
833
|
+
const node = unwrapExpression(expression);
|
|
834
|
+
switch (node.type) {
|
|
835
|
+
case "Identifier": {
|
|
836
|
+
const alias = aliases.get(node.name);
|
|
837
|
+
if (alias) return `this.${alias}`;
|
|
838
|
+
if (context.stateVars.has(node.name)) return `this.${node.name}`;
|
|
839
|
+
return node.name;
|
|
840
|
+
}
|
|
841
|
+
case "StringLiteral": return JSON.stringify(node.value);
|
|
842
|
+
case "NumericLiteral": return String(node.value);
|
|
843
|
+
case "BooleanLiteral": return String(node.value);
|
|
844
|
+
case "NullLiteral": return "null";
|
|
845
|
+
case "BinaryExpression":
|
|
846
|
+
case "LogicalExpression": return `${lowerExpression(context, node.left, aliases)} ${node.operator} ${lowerExpression(context, node.right, aliases)}`;
|
|
847
|
+
case "UnaryExpression": return `${node.operator}${lowerExpression(context, node.argument, aliases)}`;
|
|
848
|
+
case "AssignmentExpression": return `${lowerExpression(context, node.left, aliases)} ${node.operator} ${lowerExpression(context, node.right, aliases)}`;
|
|
849
|
+
case "UpdateExpression": {
|
|
850
|
+
const argument = lowerExpression(context, node.argument, aliases);
|
|
851
|
+
return node.prefix ? `${node.operator}${argument}` : `${argument}${node.operator}`;
|
|
852
|
+
}
|
|
853
|
+
case "MemberExpression": return lowerMemberExpression(context, node, aliases);
|
|
854
|
+
case "CallExpression": {
|
|
855
|
+
const setterTarget = node.callee.type === "Identifier" ? context.stateSetters.get(node.callee.name) : void 0;
|
|
856
|
+
if (setterTarget) return lowerStateSetterCall(context, setterTarget, node);
|
|
857
|
+
return `${lowerExpression(context, node.callee, aliases)}(${node.arguments.map((arg) => {
|
|
858
|
+
if (arg.type === "SpreadElement") return `...${lowerExpression(context, arg.argument, aliases)}`;
|
|
859
|
+
return lowerExpression(context, arg, aliases);
|
|
860
|
+
}).join(", ")})`;
|
|
861
|
+
}
|
|
862
|
+
case "ArrowFunctionExpression": return sourceForNode(context.source, node);
|
|
863
|
+
default: return sourceForNode(context.source, node);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function lowerMemberExpression(context, node, aliases) {
|
|
867
|
+
const object = lowerExpression(context, node.object, aliases);
|
|
868
|
+
if (node.computed) return `${object}[${lowerExpression(context, node.property, aliases)}]`;
|
|
869
|
+
return `${object}.${sourceForNode(context.source, node.property)}`;
|
|
870
|
+
}
|
|
871
|
+
function lowerStateSetterCall(context, stateName, node) {
|
|
872
|
+
const next = node.arguments[0];
|
|
873
|
+
if (!next) return `this.${stateName} = null`;
|
|
874
|
+
const expression = unwrapExpression(next);
|
|
875
|
+
if (expression.type === "ArrowFunctionExpression") {
|
|
876
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
877
|
+
const firstParam = expression.params[0];
|
|
878
|
+
if (firstParam?.type === "Identifier") aliases.set(firstParam.name, stateName);
|
|
879
|
+
if (expression.body.type === "BlockStatement") throw new Error(`${context.filename ?? "TSX"}: setState updater 暂不支持 block body`);
|
|
880
|
+
return `this.${stateName} = ${lowerExpression(context, expression.body, aliases)}`;
|
|
881
|
+
}
|
|
882
|
+
return `this.${stateName} = ${lowerExpression(context, expression)}`;
|
|
883
|
+
}
|
|
884
|
+
function findTopLevelBinding(body, name) {
|
|
885
|
+
for (const statement of body) {
|
|
886
|
+
if (statement.type === "FunctionDeclaration" && statement.id?.name === name) return statement;
|
|
887
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
888
|
+
for (const declarator of statement.declarations) if (declarator.id.type === "Identifier" && declarator.id.name === name) return declarator.init ? unwrapExpression(declarator.init) : void 0;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function renderExpressionFromFunction(node, filename) {
|
|
892
|
+
const body = unwrapExpression(node.body);
|
|
893
|
+
if (isJsxNode(body)) return body;
|
|
894
|
+
if (body.type !== "BlockStatement") throw new Error(`${filename ?? "TSX"}: 页面函数必须返回 JSX`);
|
|
895
|
+
for (const statement of body.body) {
|
|
896
|
+
if (statement.type !== "ReturnStatement") continue;
|
|
897
|
+
if (!statement.argument) throw new Error(`${filename ?? "TSX"}: 页面函数返回值不能为空`);
|
|
898
|
+
return unwrapExpression(statement.argument);
|
|
899
|
+
}
|
|
900
|
+
throw new Error(`${filename ?? "TSX"}: 页面函数缺少 return 语句`);
|
|
901
|
+
}
|
|
902
|
+
function templateFromRenderExpression(expression, bindings, filename) {
|
|
903
|
+
const node = nodeFromJsx(expression, bindings, filename);
|
|
904
|
+
if (node.kind === "fragment") return node.value;
|
|
905
|
+
return [node];
|
|
906
|
+
}
|
|
907
|
+
function nodeFromJsx(node, bindings, filename) {
|
|
908
|
+
switch (node.type) {
|
|
909
|
+
case "JSXElement": return {
|
|
910
|
+
kind: "element",
|
|
911
|
+
value: elementFromJsx(node, bindings, filename)
|
|
912
|
+
};
|
|
913
|
+
case "JSXFragment": return {
|
|
914
|
+
kind: "fragment",
|
|
915
|
+
value: childrenFromJsx(node.children, bindings, filename)
|
|
916
|
+
};
|
|
917
|
+
default: throw new Error(`${filename ?? "TSX"}: 不支持的 JSX 根节点 ${node.type}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
function elementFromJsx(node, bindings, filename) {
|
|
921
|
+
const name = jsxElementName(node.openingElement.name, filename);
|
|
922
|
+
const tag = bindings.get(name) ?? name;
|
|
923
|
+
const isComponent = !bindings.has(name) && /^[A-Z]/.test(name);
|
|
924
|
+
const attrs = {};
|
|
925
|
+
const events = {};
|
|
926
|
+
for (const attr of node.openingElement.attributes) {
|
|
927
|
+
if (attr.type === "JSXSpreadAttribute") throw new Error(`${filename ?? "TSX"}: 暂不支持 JSX spread 属性`);
|
|
928
|
+
const attrName = jsxAttributeName(attr.name, filename);
|
|
929
|
+
if (attrName === "key") continue;
|
|
930
|
+
if (isEventAttribute(attrName)) {
|
|
931
|
+
events[eventNameFromAttribute(attrName)] = bindingFromAttribute(attr.value, true, filename);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
const normalizedName = normalizeAttributeName(attrName, isComponent);
|
|
935
|
+
attrs[normalizedName] = attrFromValue(normalizedName, attr.value, filename);
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
tag: isComponent ? kebabCase(tag) : tag,
|
|
939
|
+
is_component: isComponent,
|
|
940
|
+
attrs,
|
|
941
|
+
events,
|
|
942
|
+
children: childrenFromJsx(node.children, bindings, filename)
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function childrenFromJsx(children, bindings, filename) {
|
|
946
|
+
const out = [];
|
|
947
|
+
for (const child of children) {
|
|
948
|
+
if (child.type === "JSXText") {
|
|
949
|
+
const text = normalizeJsxText(child.value);
|
|
950
|
+
if (text.length > 0) out.push({
|
|
951
|
+
kind: "text",
|
|
952
|
+
value: text
|
|
953
|
+
});
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
if (child.type === "JSXElement" || child.type === "JSXFragment") {
|
|
957
|
+
const node = nodeFromJsx(child, bindings, filename);
|
|
958
|
+
if (node.kind === "fragment") out.push(...node.value);
|
|
959
|
+
else out.push(node);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (child.type === "JSXExpressionContainer") {
|
|
963
|
+
const expression = unwrapExpression(child.expression);
|
|
964
|
+
if (expression.type === "JSXEmptyExpression") continue;
|
|
965
|
+
out.push(nodeFromExpression(expression, bindings, filename));
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
throw new Error(`${filename ?? "TSX"}: 不支持的 JSX 子节点 ${child.type}`);
|
|
969
|
+
}
|
|
970
|
+
return out;
|
|
971
|
+
}
|
|
972
|
+
function nodeFromExpression(expression, bindings, filename) {
|
|
973
|
+
const conditional = conditionalFromExpression(expression, bindings, filename);
|
|
974
|
+
if (conditional) return conditional;
|
|
975
|
+
const list = listFromExpression(expression, bindings, filename);
|
|
976
|
+
if (list) return list;
|
|
977
|
+
const value = literalValue(expression);
|
|
978
|
+
if (value !== void 0) return {
|
|
979
|
+
kind: "text",
|
|
980
|
+
value: String(value)
|
|
981
|
+
};
|
|
982
|
+
return {
|
|
983
|
+
kind: "expression",
|
|
984
|
+
value: bindingFromExpression(expression, false, filename)
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function conditionalFromExpression(expression, bindings, filename) {
|
|
988
|
+
const node = unwrapExpression(expression);
|
|
989
|
+
if (node.type === "ConditionalExpression") return {
|
|
990
|
+
kind: "conditional",
|
|
991
|
+
value: { branches: [{
|
|
992
|
+
guard: bindingFromExpression(node.test, false, filename),
|
|
993
|
+
body: nodesFromBranchExpression(node.consequent, bindings, filename)
|
|
994
|
+
}, ...alternateBranches(node.alternate, bindings, filename)] }
|
|
995
|
+
};
|
|
996
|
+
if (node.type === "LogicalExpression" && node.operator === "&&") return {
|
|
997
|
+
kind: "conditional",
|
|
998
|
+
value: { branches: [{
|
|
999
|
+
guard: bindingFromExpression(node.left, false, filename),
|
|
1000
|
+
body: nodesFromBranchExpression(node.right, bindings, filename)
|
|
1001
|
+
}] }
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function alternateBranches(expression, bindings, filename) {
|
|
1005
|
+
const node = unwrapExpression(expression);
|
|
1006
|
+
if (node.type === "ConditionalExpression") return [{
|
|
1007
|
+
guard: bindingFromExpression(node.test, false, filename),
|
|
1008
|
+
body: nodesFromBranchExpression(node.consequent, bindings, filename)
|
|
1009
|
+
}, ...alternateBranches(node.alternate, bindings, filename)];
|
|
1010
|
+
return [{
|
|
1011
|
+
guard: null,
|
|
1012
|
+
body: nodesFromBranchExpression(node, bindings, filename)
|
|
1013
|
+
}];
|
|
1014
|
+
}
|
|
1015
|
+
function nodesFromBranchExpression(expression, bindings, filename) {
|
|
1016
|
+
const node = unwrapExpression(expression);
|
|
1017
|
+
if (node.type === "NullLiteral") return [];
|
|
1018
|
+
if (node.type === "BooleanLiteral" && node.value === false) return [];
|
|
1019
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") {
|
|
1020
|
+
const result = nodeFromJsx(node, bindings, filename);
|
|
1021
|
+
return result.kind === "fragment" ? result.value : [result];
|
|
1022
|
+
}
|
|
1023
|
+
return [nodeFromExpression(node, bindings, filename)];
|
|
1024
|
+
}
|
|
1025
|
+
function listFromExpression(expression, bindings, filename) {
|
|
1026
|
+
const node = unwrapExpression(expression);
|
|
1027
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.type !== "Identifier" || node.callee.property.name !== "map") return;
|
|
1028
|
+
const callback = unwrapExpression(node.arguments[0]);
|
|
1029
|
+
if (!callback || !isFunctionLike(callback)) throw new Error(`${filename ?? "TSX"}: list render 的 map 参数必须是函数`);
|
|
1030
|
+
const itemParam = callback.params[0];
|
|
1031
|
+
if (itemParam?.type !== "Identifier") throw new Error(`${filename ?? "TSX"}: list render 必须声明 item 参数`);
|
|
1032
|
+
const indexParam = callback.params[1];
|
|
1033
|
+
if (indexParam && indexParam.type !== "Identifier") throw new Error(`${filename ?? "TSX"}: list render 的 index 参数必须是标识符`);
|
|
1034
|
+
const bodyExpression = renderExpressionFromMapCallback(callback, filename);
|
|
1035
|
+
return {
|
|
1036
|
+
kind: "list",
|
|
1037
|
+
value: {
|
|
1038
|
+
source: bindingFromExpression(node.callee.object, false, filename),
|
|
1039
|
+
item_var: itemParam.name,
|
|
1040
|
+
index_var: indexParam?.name,
|
|
1041
|
+
key: keyBindingFromJsx(bodyExpression, filename),
|
|
1042
|
+
body: nodesFromBranchExpression(bodyExpression, bindings, filename)
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
function renderExpressionFromMapCallback(callback, filename) {
|
|
1047
|
+
const body = unwrapExpression(callback.body);
|
|
1048
|
+
if (body.type !== "BlockStatement") return body;
|
|
1049
|
+
for (const statement of body.body) {
|
|
1050
|
+
if (statement.type !== "ReturnStatement") continue;
|
|
1051
|
+
if (!statement.argument) throw new Error(`${filename ?? "TSX"}: list render 的 return 不能为空`);
|
|
1052
|
+
return unwrapExpression(statement.argument);
|
|
1053
|
+
}
|
|
1054
|
+
throw new Error(`${filename ?? "TSX"}: list render 函数缺少 return 语句`);
|
|
1055
|
+
}
|
|
1056
|
+
function keyBindingFromJsx(expression, filename) {
|
|
1057
|
+
const node = unwrapExpression(expression);
|
|
1058
|
+
if (node.type !== "JSXElement") return;
|
|
1059
|
+
const keyAttr = node.openingElement.attributes.find((attr) => attr.type === "JSXAttribute" && attr.name.type === "JSXIdentifier" && attr.name.name === "key");
|
|
1060
|
+
if (!keyAttr) return;
|
|
1061
|
+
return bindingFromAttribute(keyAttr.value, false, filename);
|
|
1062
|
+
}
|
|
1063
|
+
function attrFromValue(name, value, filename) {
|
|
1064
|
+
if (!value) return {
|
|
1065
|
+
kind: "static",
|
|
1066
|
+
value: true
|
|
1067
|
+
};
|
|
1068
|
+
if (value.type === "StringLiteral") return {
|
|
1069
|
+
kind: "static",
|
|
1070
|
+
value: value.value
|
|
1071
|
+
};
|
|
1072
|
+
if (value.type !== "JSXExpressionContainer") throw new Error(`${filename ?? "TSX"}: 不支持的属性值 ${value.type}`);
|
|
1073
|
+
const expression = unwrapExpression(value.expression);
|
|
1074
|
+
const literal = literalValue(expression);
|
|
1075
|
+
if (literal !== void 0) return {
|
|
1076
|
+
kind: "static",
|
|
1077
|
+
value: literal
|
|
1078
|
+
};
|
|
1079
|
+
const staticObject = staticAttrLiteral(expression);
|
|
1080
|
+
if (staticObject !== void 0) return {
|
|
1081
|
+
kind: "static",
|
|
1082
|
+
value: staticObject
|
|
1083
|
+
};
|
|
1084
|
+
if (name === "style") {
|
|
1085
|
+
const styleObject = styleObjectAttr(expression, filename);
|
|
1086
|
+
if (styleObject) return {
|
|
1087
|
+
kind: "style_object",
|
|
1088
|
+
value: styleObject
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
return {
|
|
1092
|
+
kind: "dynamic",
|
|
1093
|
+
value: bindingFromExpression(expression, false, filename)
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function styleObjectAttr(expression, filename) {
|
|
1097
|
+
const node = unwrapExpression(expression);
|
|
1098
|
+
if (node.type !== "ObjectExpression") return void 0;
|
|
1099
|
+
const slots = [];
|
|
1100
|
+
for (const property of node.properties) {
|
|
1101
|
+
if (property.type !== "ObjectProperty" || property.computed) throw new Error(`${filename ?? "TSX"}: style 对象暂不支持展开、方法或计算键`);
|
|
1102
|
+
const key = objectPropertyKey(property.key, filename);
|
|
1103
|
+
const value = staticAttrLiteral(property.value);
|
|
1104
|
+
if (value !== void 0) {
|
|
1105
|
+
slots.push({
|
|
1106
|
+
name: key,
|
|
1107
|
+
value: {
|
|
1108
|
+
kind: "static",
|
|
1109
|
+
value
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
slots.push({
|
|
1115
|
+
name: key,
|
|
1116
|
+
value: {
|
|
1117
|
+
kind: "dynamic",
|
|
1118
|
+
value: bindingFromExpression(property.value, false, filename)
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
return slots;
|
|
1123
|
+
}
|
|
1124
|
+
function staticAttrLiteral(expression) {
|
|
1125
|
+
const node = unwrapExpression(expression);
|
|
1126
|
+
const literal = literalValue(node);
|
|
1127
|
+
if (literal !== void 0) return literal;
|
|
1128
|
+
if (node.type === "UnaryExpression" && node.operator === "-" && unwrapExpression(node.argument).type === "NumericLiteral") return -unwrapExpression(node.argument).value;
|
|
1129
|
+
if (node.type === "ArrayExpression") {
|
|
1130
|
+
const out = [];
|
|
1131
|
+
for (const element of node.elements) {
|
|
1132
|
+
if (!element) {
|
|
1133
|
+
out.push(null);
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (element.type === "SpreadElement") return void 0;
|
|
1137
|
+
const item = staticAttrLiteral(element);
|
|
1138
|
+
if (item === void 0) return void 0;
|
|
1139
|
+
out.push(item);
|
|
1140
|
+
}
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
if (node.type === "ObjectExpression") {
|
|
1144
|
+
const out = {};
|
|
1145
|
+
for (const property of node.properties) {
|
|
1146
|
+
if (property.type !== "ObjectProperty") return void 0;
|
|
1147
|
+
if (property.computed) return void 0;
|
|
1148
|
+
let key;
|
|
1149
|
+
if (property.key.type === "Identifier") key = property.key.name;
|
|
1150
|
+
else if (property.key.type === "StringLiteral" || property.key.type === "NumericLiteral") key = String(property.key.value);
|
|
1151
|
+
else return;
|
|
1152
|
+
const v = staticAttrLiteral(property.value);
|
|
1153
|
+
if (v === void 0) return void 0;
|
|
1154
|
+
out[key] = v;
|
|
1155
|
+
}
|
|
1156
|
+
return out;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
function bindingFromAttribute(value, callable, filename) {
|
|
1160
|
+
if (!value || value.type !== "JSXExpressionContainer") throw new Error(`${filename ?? "TSX"}: 事件属性必须使用表达式绑定`);
|
|
1161
|
+
return bindingFromExpression(unwrapExpression(value.expression), callable, filename);
|
|
1162
|
+
}
|
|
1163
|
+
function bindingFromExpression(expression, callable, filename) {
|
|
1164
|
+
const path = bindingPath(expression);
|
|
1165
|
+
if (!path) throw new Error(`${filename ?? "TSX"}: 当前阶段仅支持标识符或成员访问绑定`);
|
|
1166
|
+
return {
|
|
1167
|
+
path,
|
|
1168
|
+
is_callable: callable
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
function bindingPath(expression) {
|
|
1172
|
+
const node = unwrapExpression(expression);
|
|
1173
|
+
switch (node.type) {
|
|
1174
|
+
case "Identifier": return node.name;
|
|
1175
|
+
case "MemberExpression": {
|
|
1176
|
+
const object = bindingPath(node.object);
|
|
1177
|
+
if (!object || node.computed) return;
|
|
1178
|
+
const property = node.property.type === "Identifier" ? node.property.name : void 0;
|
|
1179
|
+
return property ? `${object}.${property}` : void 0;
|
|
1180
|
+
}
|
|
1181
|
+
case "OptionalMemberExpression": {
|
|
1182
|
+
const object = bindingPath(node.object);
|
|
1183
|
+
if (!object || node.computed) return;
|
|
1184
|
+
const property = node.property.type === "Identifier" ? node.property.name : void 0;
|
|
1185
|
+
return property ? `${object}.${property}` : void 0;
|
|
1186
|
+
}
|
|
1187
|
+
default: return;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function literalValue(expression) {
|
|
1191
|
+
switch (expression.type) {
|
|
1192
|
+
case "StringLiteral": return expression.value;
|
|
1193
|
+
case "NumericLiteral": return expression.value;
|
|
1194
|
+
case "BooleanLiteral": return expression.value;
|
|
1195
|
+
case "NullLiteral": return null;
|
|
1196
|
+
default: return;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function staticJsonValue(expression, filename) {
|
|
1200
|
+
const node = unwrapExpression(expression);
|
|
1201
|
+
const literal = literalValue(node);
|
|
1202
|
+
if (literal !== void 0) return literal;
|
|
1203
|
+
if (node.type === "ArrayExpression") return node.elements.map((element) => {
|
|
1204
|
+
if (!element) return null;
|
|
1205
|
+
if (element.type === "SpreadElement") throw new Error(`${filename ?? "TSX"}: useState 初值暂不支持数组展开`);
|
|
1206
|
+
return staticJsonValue(element, filename);
|
|
1207
|
+
});
|
|
1208
|
+
if (node.type === "ObjectExpression") {
|
|
1209
|
+
const out = {};
|
|
1210
|
+
for (const property of node.properties) {
|
|
1211
|
+
if (property.type === "SpreadElement") throw new Error(`${filename ?? "TSX"}: useState 初值暂不支持对象展开`);
|
|
1212
|
+
if (property.type !== "ObjectProperty") throw new Error(`${filename ?? "TSX"}: useState 初值暂不支持对象方法`);
|
|
1213
|
+
out[objectPropertyKey(property.key, filename)] = staticJsonValue(property.value, filename);
|
|
1214
|
+
}
|
|
1215
|
+
return out;
|
|
1216
|
+
}
|
|
1217
|
+
throw new Error(`${filename ?? "TSX"}: useState 初值必须是静态 JSON 字面量`);
|
|
1218
|
+
}
|
|
1219
|
+
function objectPropertyKey(node, filename) {
|
|
1220
|
+
switch (node.type) {
|
|
1221
|
+
case "Identifier": return node.name;
|
|
1222
|
+
case "StringLiteral":
|
|
1223
|
+
case "NumericLiteral": return String(node.value);
|
|
1224
|
+
default: throw new Error(`${filename ?? "TSX"}: 不支持的对象键 ${node.type}`);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
function sourceForNode(source, node) {
|
|
1228
|
+
if (typeof node.start !== "number" || typeof node.end !== "number") throw new Error("AST 节点缺少源码区间");
|
|
1229
|
+
return source.slice(node.start, node.end);
|
|
1230
|
+
}
|
|
1231
|
+
function jsxElementName(node, filename) {
|
|
1232
|
+
switch (node.type) {
|
|
1233
|
+
case "JSXIdentifier": return node.name;
|
|
1234
|
+
case "JSXMemberExpression": throw new Error(`${filename ?? "TSX"}: 暂不支持命名空间组件`);
|
|
1235
|
+
case "JSXNamespacedName": throw new Error(`${filename ?? "TSX"}: 暂不支持 JSX namespace 标签`);
|
|
1236
|
+
default: throw new Error(`${filename ?? "TSX"}: 不支持的 JSX 标签 ${node.type}`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
function jsxAttributeName(node, filename) {
|
|
1240
|
+
switch (node.type) {
|
|
1241
|
+
case "JSXIdentifier": return node.name;
|
|
1242
|
+
case "JSXNamespacedName": throw new Error(`${filename ?? "TSX"}: 暂不支持 JSX namespace 属性`);
|
|
1243
|
+
default: throw new Error(`${filename ?? "TSX"}: 不支持的 JSX 属性 ${node.type}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function normalizeJsxText(value) {
|
|
1247
|
+
return value.replace(/\s+/g, " ").trim();
|
|
1248
|
+
}
|
|
1249
|
+
function normalizeAttributeName(name, isComponent) {
|
|
1250
|
+
if (name === "className") return "class";
|
|
1251
|
+
if (isComponent) return name;
|
|
1252
|
+
return name.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
|
|
1253
|
+
}
|
|
1254
|
+
function isEventAttribute(name) {
|
|
1255
|
+
return /^on[A-Z]/.test(name);
|
|
1256
|
+
}
|
|
1257
|
+
function eventNameFromAttribute(name) {
|
|
1258
|
+
return name.slice(2).toLowerCase();
|
|
1259
|
+
}
|
|
1260
|
+
function isFunctionLike(node) {
|
|
1261
|
+
return node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" || node.type === "ObjectMethod";
|
|
1262
|
+
}
|
|
1263
|
+
function isPascalCase(value) {
|
|
1264
|
+
return /^[A-Z]/.test(value);
|
|
1265
|
+
}
|
|
1266
|
+
function isJsxNode(node) {
|
|
1267
|
+
return node.type === "JSXElement" || node.type === "JSXFragment";
|
|
1268
|
+
}
|
|
1269
|
+
function unwrapExpression(node) {
|
|
1270
|
+
let current = node;
|
|
1271
|
+
while (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression" || current.type === "ParenthesizedExpression") current = current.expression;
|
|
1272
|
+
return current;
|
|
1273
|
+
}
|
|
1274
|
+
function kebabCase(value) {
|
|
1275
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
|
|
1276
|
+
}
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/project.ts
|
|
1279
|
+
const PAGE_EXTENSIONS = new Set([
|
|
1280
|
+
".tsx",
|
|
1281
|
+
".ts",
|
|
1282
|
+
".jsx"
|
|
1283
|
+
]);
|
|
1284
|
+
function compileAstroForgeProject(options) {
|
|
1285
|
+
const root = resolve(options.root);
|
|
1286
|
+
const config = options.config ?? readAstroForgeConfig(root, options.configFile);
|
|
1287
|
+
const pages = discoverPages(root);
|
|
1288
|
+
if (pages.length === 0) throw new Error(`${root}: 未发现 src/pages 下的页面入口`);
|
|
1289
|
+
const document = createIrDocument(config.manifest, pages);
|
|
1290
|
+
document.app = extractAppModule(root);
|
|
1291
|
+
const visitedFiles = /* @__PURE__ */ new Set();
|
|
1292
|
+
for (const page of pages) {
|
|
1293
|
+
visitedFiles.add(resolve(page.file));
|
|
1294
|
+
const module = extractPageModuleFromTsx(readFileSync(page.file, "utf8"), {
|
|
1295
|
+
route: page.route,
|
|
1296
|
+
filename: page.file,
|
|
1297
|
+
loadStyle: loadStyleImport
|
|
1298
|
+
});
|
|
1299
|
+
document.pages[page.route] = module.page;
|
|
1300
|
+
for (const [name, component] of Object.entries(module.components)) document.components[name] = component;
|
|
1301
|
+
loadCrossFileComponents(document.components, visitedFiles, page.file, module.componentImports);
|
|
1302
|
+
document.pages[page.route].imports = importsFromTemplateAndComponents(document.pages[page.route].template, document.components);
|
|
1303
|
+
}
|
|
1304
|
+
document.assets = collectAssets(root, document);
|
|
1305
|
+
validatePlatformCapabilities(document, config.plugin?.target ?? "vela");
|
|
1306
|
+
const outFile = options.outFile ?? defaultIrOutFile(root, options.cacheDir);
|
|
1307
|
+
writeIrDocument(outFile, document);
|
|
1308
|
+
return {
|
|
1309
|
+
document,
|
|
1310
|
+
outFile,
|
|
1311
|
+
pages
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
function importsFromTemplateAndComponents(template, components) {
|
|
1315
|
+
const imports = {};
|
|
1316
|
+
const visit = (nodes) => {
|
|
1317
|
+
for (const node of nodes) switch (node.kind) {
|
|
1318
|
+
case "element":
|
|
1319
|
+
if (node.value.is_component && components[node.value.tag]) imports[node.value.tag] = node.value.tag;
|
|
1320
|
+
visit(node.value.children);
|
|
1321
|
+
break;
|
|
1322
|
+
case "conditional":
|
|
1323
|
+
for (const branch of node.value.branches) visit(branch.body);
|
|
1324
|
+
break;
|
|
1325
|
+
case "list":
|
|
1326
|
+
visit(node.value.body);
|
|
1327
|
+
break;
|
|
1328
|
+
case "fragment":
|
|
1329
|
+
visit(node.value);
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
visit(template);
|
|
1334
|
+
return imports;
|
|
1335
|
+
}
|
|
1336
|
+
function extractAppModule(root) {
|
|
1337
|
+
const appFile = resolve(root, "src", "app.tsx");
|
|
1338
|
+
if (!existsSync(appFile)) return { lifecycle: {} };
|
|
1339
|
+
return extractAppFromTsx(readFileSync(appFile, "utf8"), appFile);
|
|
1340
|
+
}
|
|
1341
|
+
const COMPONENT_RESOLUTION_EXTENSIONS = [
|
|
1342
|
+
".tsx",
|
|
1343
|
+
".ts",
|
|
1344
|
+
".jsx",
|
|
1345
|
+
""
|
|
1346
|
+
];
|
|
1347
|
+
function loadCrossFileComponents(components, visitedFiles, parentFile, initial) {
|
|
1348
|
+
const queue = initial.map((ref) => ({
|
|
1349
|
+
parent: parentFile,
|
|
1350
|
+
ref
|
|
1351
|
+
}));
|
|
1352
|
+
while (queue.length > 0) {
|
|
1353
|
+
const { parent, ref } = queue.shift();
|
|
1354
|
+
if (components[ref.tag]) continue;
|
|
1355
|
+
const resolved = resolveComponentImport(parent, ref.from);
|
|
1356
|
+
if (!resolved) continue;
|
|
1357
|
+
if (visitedFiles.has(resolved)) continue;
|
|
1358
|
+
visitedFiles.add(resolved);
|
|
1359
|
+
const result = extractComponentFromTsx(readFileSync(resolved, "utf8"), {
|
|
1360
|
+
filename: resolved,
|
|
1361
|
+
exportName: ref.exportName,
|
|
1362
|
+
loadStyle: loadStyleImport
|
|
1363
|
+
});
|
|
1364
|
+
components[ref.tag] = {
|
|
1365
|
+
...result.component,
|
|
1366
|
+
name: ref.tag
|
|
1367
|
+
};
|
|
1368
|
+
for (const nested of result.componentImports) queue.push({
|
|
1369
|
+
parent: resolved,
|
|
1370
|
+
ref: nested
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
function loadStyleImport(specifier, importer) {
|
|
1375
|
+
if (!importer) return void 0;
|
|
1376
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return;
|
|
1377
|
+
const path = resolve(dirname(importer), specifier);
|
|
1378
|
+
if (!existsSync(path)) return void 0;
|
|
1379
|
+
return readFileSync(path, "utf8");
|
|
1380
|
+
}
|
|
1381
|
+
function resolveComponentImport(parent, spec) {
|
|
1382
|
+
const base = resolve(dirname(parent), spec);
|
|
1383
|
+
for (const ext of COMPONENT_RESOLUTION_EXTENSIONS) {
|
|
1384
|
+
const candidate = `${base}${ext}`;
|
|
1385
|
+
if (existsSync(candidate)) return candidate;
|
|
1386
|
+
}
|
|
1387
|
+
for (const ext of [
|
|
1388
|
+
".tsx",
|
|
1389
|
+
".ts",
|
|
1390
|
+
".jsx"
|
|
1391
|
+
]) {
|
|
1392
|
+
const candidate = join(base, `index${ext}`);
|
|
1393
|
+
if (existsSync(candidate)) return candidate;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
function discoverPages(root) {
|
|
1397
|
+
return walkFiles(resolve(root, "src", "pages")).filter((file) => PAGE_EXTENSIONS.has(extname(file))).map((file) => pageModuleFromFile(root, file)).sort((a, b) => compareRoutes(a.route, b.route));
|
|
1398
|
+
}
|
|
1399
|
+
function createRsbuildEntries(root) {
|
|
1400
|
+
const entries = {};
|
|
1401
|
+
for (const page of discoverPages(root)) entries[entryNameFromRoute(page.route)] = `./${toPosix(relative(root, page.file))}`;
|
|
1402
|
+
return entries;
|
|
1403
|
+
}
|
|
1404
|
+
function defaultIrOutFile(root, cacheDir) {
|
|
1405
|
+
return join(cacheDir ? resolve(root, cacheDir) : resolve(root, "node_modules/.cache/astroforge"), "ir-document.json");
|
|
1406
|
+
}
|
|
1407
|
+
function createIrDocument(manifestInput, pages) {
|
|
1408
|
+
const routerPages = {};
|
|
1409
|
+
for (const page of pages) routerPages[page.route] = { component: page.component };
|
|
1410
|
+
const source = manifestSource(manifestInput, pages[0].route, routerPages);
|
|
1411
|
+
return {
|
|
1412
|
+
ir_version: 1,
|
|
1413
|
+
manifest: {
|
|
1414
|
+
package: manifestInput.package,
|
|
1415
|
+
name: manifestInput.name,
|
|
1416
|
+
version_name: manifestInput.versionName,
|
|
1417
|
+
version_code: manifestInput.versionCode,
|
|
1418
|
+
min_platform_version: manifestInput.minPlatformVersion,
|
|
1419
|
+
icon: manifestInput.icon,
|
|
1420
|
+
simulation_version: manifestInput.simulationVersion ?? "default",
|
|
1421
|
+
device_type_list: manifestInput.deviceTypeList,
|
|
1422
|
+
features: manifestInput.features ?? [],
|
|
1423
|
+
config: {
|
|
1424
|
+
log_level: manifestInput.config?.logLevel,
|
|
1425
|
+
design_width: manifestInput.config?.designWidth
|
|
1426
|
+
},
|
|
1427
|
+
router: {
|
|
1428
|
+
entry: pages[0].route,
|
|
1429
|
+
pages: routerPages
|
|
1430
|
+
},
|
|
1431
|
+
source
|
|
1432
|
+
},
|
|
1433
|
+
app: { lifecycle: {} },
|
|
1434
|
+
pages: {},
|
|
1435
|
+
components: {},
|
|
1436
|
+
assets: []
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
function manifestSource(input, defaultEntry, routerPages) {
|
|
1440
|
+
const source = {};
|
|
1441
|
+
for (const [key, value] of Object.entries(input)) {
|
|
1442
|
+
if (value === void 0) continue;
|
|
1443
|
+
source[key] = value;
|
|
1444
|
+
}
|
|
1445
|
+
if (!("simulationVersion" in source)) source.simulationVersion = "default";
|
|
1446
|
+
if (!("features" in source)) source.features = [];
|
|
1447
|
+
if (!("config" in source)) source.config = {};
|
|
1448
|
+
source.router = {
|
|
1449
|
+
entry: defaultEntry,
|
|
1450
|
+
pages: Object.fromEntries(Object.entries(routerPages).map(([route, page]) => [route, { component: page.component }]))
|
|
1451
|
+
};
|
|
1452
|
+
return source;
|
|
1453
|
+
}
|
|
1454
|
+
function pageModuleFromFile(root, file) {
|
|
1455
|
+
let route = stripExtension(toPosix(relative(resolve(root, "src"), file)));
|
|
1456
|
+
if (route.endsWith("/index")) route = route.slice(0, -6);
|
|
1457
|
+
const component = stripExtension(file.split(sep).at(-1) ?? "index");
|
|
1458
|
+
return {
|
|
1459
|
+
route,
|
|
1460
|
+
component,
|
|
1461
|
+
file
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
function walkFiles(root) {
|
|
1465
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
1466
|
+
const files = [];
|
|
1467
|
+
for (const entry of entries) {
|
|
1468
|
+
const path = join(root, entry.name);
|
|
1469
|
+
if (entry.isDirectory()) files.push(...walkFiles(path));
|
|
1470
|
+
else if (entry.isFile()) files.push(path);
|
|
1471
|
+
}
|
|
1472
|
+
return files;
|
|
1473
|
+
}
|
|
1474
|
+
function writeIrDocument(path, document) {
|
|
1475
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1476
|
+
writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
|
|
1477
|
+
}
|
|
1478
|
+
function stripExtension(path) {
|
|
1479
|
+
const ext = extname(path);
|
|
1480
|
+
return ext ? path.slice(0, -ext.length) : path;
|
|
1481
|
+
}
|
|
1482
|
+
function entryNameFromRoute(route) {
|
|
1483
|
+
return route.replace(/\//g, "_");
|
|
1484
|
+
}
|
|
1485
|
+
function compareRoutes(a, b) {
|
|
1486
|
+
if (a === "pages/index") return -1;
|
|
1487
|
+
if (b === "pages/index") return 1;
|
|
1488
|
+
return a.localeCompare(b);
|
|
1489
|
+
}
|
|
1490
|
+
function toPosix(path) {
|
|
1491
|
+
return path.split(sep).join("/");
|
|
1492
|
+
}
|
|
1493
|
+
//#endregion
|
|
1494
|
+
//#region src/index.ts
|
|
1495
|
+
function pluginAstroForge(options = {}) {
|
|
1496
|
+
const target = options.target ?? "vela";
|
|
1497
|
+
if (target !== "vela") throw new Error(`@astralsight/astroforge-rsbuild-plugin: 暂不支持 target=${target}`);
|
|
1498
|
+
return {
|
|
1499
|
+
name: "@astralsight/astroforge-rsbuild-plugin",
|
|
1500
|
+
setup(api) {
|
|
1501
|
+
const root = () => options.root ?? api.context.rootPath;
|
|
1502
|
+
const outFile = () => options.outFile ?? defaultIrOutFile(root(), options.cacheDir);
|
|
1503
|
+
api.modifyRsbuildConfig((config, { mergeRsbuildConfig }) => {
|
|
1504
|
+
return mergeRsbuildConfig(config, {
|
|
1505
|
+
source: { entry: createRsbuildEntries(root()) },
|
|
1506
|
+
tools: { swc(swcConfig) {
|
|
1507
|
+
swcConfig.jsc ??= {};
|
|
1508
|
+
swcConfig.jsc.transform ??= {};
|
|
1509
|
+
swcConfig.jsc.transform.react = {
|
|
1510
|
+
...swcConfig.jsc.transform.react,
|
|
1511
|
+
runtime: "automatic",
|
|
1512
|
+
importSource: "@astralsight/astroforge-core"
|
|
1513
|
+
};
|
|
1514
|
+
return swcConfig;
|
|
1515
|
+
} }
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
const emitIr = () => {
|
|
1519
|
+
const result = compileAstroForgeProject({
|
|
1520
|
+
root: root(),
|
|
1521
|
+
configFile: options.configFile,
|
|
1522
|
+
cacheDir: options.cacheDir,
|
|
1523
|
+
outFile: options.outFile,
|
|
1524
|
+
config: options.manifest ? { manifest: options.manifest } : void 0
|
|
1525
|
+
});
|
|
1526
|
+
api.logger.info(`AstroForge IR 已写入 ${outFile()},页面数:${result.pages.length}`);
|
|
1527
|
+
};
|
|
1528
|
+
api.onBeforeBuild(emitIr);
|
|
1529
|
+
api.onBeforeDevCompile(emitIr);
|
|
1530
|
+
api.expose("astroforge:ir", {
|
|
1531
|
+
emitIr,
|
|
1532
|
+
outFile
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
//#endregion
|
|
1538
|
+
export { compileAstroForgeProject, createRsbuildEntries, defaultIrOutFile, discoverPages, extractPageFromTsx, parseAstroForgeConfig, pluginAstroForge, readAstroForgeConfig };
|