@lark-apaas/miaoda-cli 0.1.32 → 0.1.33-alpha.155f17e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,9 +48,31 @@ miaoda file ls --output json
48
48
  | `miaoda observability ...` | 线上日志、链路、前端源码堆栈反查、监控指标、运营指标 |
49
49
  | `miaoda comment ...` | 应用评论:查看评论列表、解决评论(仅 design-html 场景开放) |
50
50
  | `miaoda registry ...` | 组件 registry:列出 / copy-in SDK 文件(本地执行) |
51
+ | `miaoda app ...` | 应用生命周期:元数据、初始化、平台同步、源码导出、静态产物导出 |
51
52
 
52
53
  完整命令通过 `miaoda --help` 或 `miaoda <domain> --help` 查看。
53
54
 
55
+ ## 导出可本地访问的静态产物
56
+
57
+ `miaoda app export-standalone` 把当前应用构建成一份自包含静态产物,可脱离妙搭平台本地打开。
58
+
59
+ ```bash
60
+ miaoda app export-standalone # → dist/standalone/ + dist/standalone.zip
61
+ miaoda app export-standalone --out dist/share
62
+ miaoda app export-standalone --json
63
+ ```
64
+
65
+ 支持 `design-html` / `html` / `vite-react` / `nestjs-react-fullstack`。页面与代码里字面以
66
+ `/spark/app` 开头的存储图片会经沙箱网关(localhost:8080)下载到 `images/` 并改写成相对路径。
67
+
68
+ 默认输出落在 `dist/` 下是刻意的:`dist` 已在部署扫描的 EXCLUDES 与各模板 `.gitignore` 中,
69
+ 产物不会污染部署包、不会被误提交;代价是下一次构建会清掉它。
70
+
71
+ **只保证纯静态可看** —— 接口调用会失败,不做数据快照。多路由 SPA 在 `file://` 下不可用
72
+ (`BrowserRouter` 匹配不上路径),需用本地 HTTP server 打开,命令会在 `warnings` 里提示。
73
+
74
+ > 与 `miaoda app export` **语义无关**:后者导出的是源码归档(创意应用升级链路的只读参考物)。
75
+
54
76
  ## 组件 registry
55
77
 
56
78
  `miaoda registry` 提供 shadcn 式的「组件 registry」:把 SDK 文件**拷进**当前项目(copy-in),而非作为依赖安装。registry 是独立 npm 包,按运行时分发(design-html / html → buildless runtime → `@lark-apaas/coding-registry-buildless`)。
@@ -22,6 +22,7 @@ function registerAppCommands(program, opts = {}) {
22
22
  registerAppGet(appCmd);
23
23
  registerAppUpdate(appCmd);
24
24
  registerAppExport(appCmd);
25
+ registerAppExportStandalone(appCmd);
25
26
  if (opts.includeInit) {
26
27
  registerAppInit(appCmd);
27
28
  registerAppSync(appCmd);
@@ -127,6 +128,10 @@ function registerAppExport(parent) {
127
128
  .option('--out <dir>', '输出目录(相对当前目录)', 'source_package/creative')
128
129
  .option('--checkpoint <n>', '按检查点版本导出;缺省为最新提交(HEAD)')
129
130
  .addHelpText('after', `
131
+ 与 'miaoda app export-standalone' 的区别(**两个命令语义无关,别选错**)
132
+ app export 导出源码归档(本命令)
133
+ app export-standalone 导出可本地访问的构建产物
134
+
130
135
  说明
131
136
  从服务端导出源应用(可以不是当前应用)的源码归档并解压到 --out 目录,导出完成后写
132
137
  --out/.upgrade-manifest.json(含 sourceAppId / 导出时间 / 文件数),供升级链路幂等
@@ -173,6 +178,65 @@ JSON 输出
173
178
  });
174
179
  }));
175
180
  }
181
+ function registerAppExportStandalone(parent) {
182
+ const cmd = parent
183
+ .command('export-standalone')
184
+ .description('把当前应用构建成自包含静态产物(目录 + zip),可脱离妙搭平台本地访问')
185
+ .option('--dir <path>', '项目目录,默认 cwd(需含 .spark/meta.json)')
186
+ .option('--out <dir>', '输出目录(相对项目根)', index_1.DEFAULT_STANDALONE_OUT)
187
+ .option('--dev-server <url>', '图片下载的请求前缀,默认沙箱网关 http://localhost:8080')
188
+ .addHelpText('after', `
189
+ 与 'miaoda app export' 的区别(**两个命令语义无关,别选错**)
190
+ app export 导出源码归档(创意应用升级链路的只读参考物)
191
+ app export-standalone 导出可本地访问的构建产物(本命令)
192
+
193
+ 支持的 stack
194
+ design-html / html / vite-react / nestjs-react-fullstack
195
+
196
+ 产物
197
+ <out>/ index.html + assets/ + images/(+ static/,vite 系有 shared/static 时)
198
+ <out>.zip 同内容的压缩包
199
+ 默认 <out> 是 dist/standalone —— 落在 dist/ 下是刻意的:dist 已在部署扫描的
200
+ EXCLUDES 与各模板 .gitignore 中,产物不会污染部署包、不会被误提交。
201
+ 代价:下一次构建会 rm -rf dist,导出产物是易失的。
202
+
203
+ 图片本地化
204
+ 只处理页面/代码里字面以 /spark/app 开头的 URL,经沙箱网关(localhost:8080)下载到
205
+ <out>/images/ 并改写成相对路径。其它挂载前缀(/spark/p/、/app/<id>、反代)不处理。
206
+ 接口运行时返回的 download_url 属动态 URL,静态扫描覆盖不到。
207
+
208
+ 已知限制
209
+ 只保证纯静态可看——接口调用会失败,不做数据快照。
210
+ 多路由 SPA 在 file:// 下不可用(BrowserRouter 匹配不上路径、pushState 抛 SecurityError),
211
+ 请用本地 HTTP server 打开;检测到时会在 warnings 里给 SPA_ROUTER_FILE_PROTOCOL。
212
+ design-html 的 cd-elements SDK 是 CDN 外链,断网打开自定义元素不渲染。
213
+
214
+ JSON 输出
215
+ {"data": {"appId": "...", "stack": "...", "out": "dist/standalone", "zip": "dist/standalone.zip",
216
+ "fileCount": N, "bytes": N,
217
+ "images": {"matched": N, "downloaded": N, "failed": [{"url": "...", "reason": "..."}]},
218
+ "rewrittenFiles": [...],
219
+ "warnings": [{"code": "...", "message": "..."}]}}
220
+
221
+ 失败
222
+ 单张图片下载失败 → 跳过并计入 images.failed + warnings,退出码 0。
223
+ 命中 > 0 但全部失败 → EXPORT_STANDALONE_IMAGE_ALL_FAILED,退出码 1(系统性问题)。
224
+ stack 不支持 → EXPORT_STANDALONE_STACK_UNSUPPORTED。
225
+ 构建失败 → EXPORT_STANDALONE_BUILD_FAILED。
226
+
227
+ 示例
228
+ $ miaoda app export-standalone
229
+ $ miaoda app export-standalone --out dist/share
230
+ $ miaoda app export-standalone --json
231
+ `);
232
+ cmd.action((0, shared_1.withHelp)(cmd, async (rawOpts) => {
233
+ await (0, index_1.handleAppExportStandalone)({
234
+ dir: rawOpts.dir,
235
+ out: rawOpts.out,
236
+ devServer: rawOpts.devServer,
237
+ });
238
+ }));
239
+ }
176
240
  function registerAppGet(parent) {
177
241
  const cmd = parent
178
242
  .command('get')
@@ -72,7 +72,7 @@ JSON 输出(stdout)
72
72
  }));
73
73
  const patchCmd = deployCmd
74
74
  .command('patch')
75
- .description('design-html 增量发布:按文件 create/update/delete,.html 增删时同步 routes.json')
75
+ .description('design-html 增量发布:按文件 create/update/delete,.html 变动时同步 routes.json')
76
76
  .option('--create <relpath>', '新增文件(可重复)', shared_1.collectRepeatedOption, [])
77
77
  .option('--update <relpath>', '覆盖已有文件(可重复)', shared_1.collectRepeatedOption, [])
78
78
  .option('--delete <relpath>', '删除文件(可重复)', shared_1.collectRepeatedOption, [])
@@ -80,10 +80,11 @@ JSON 输出(stdout)
80
80
  .addHelpText('after', `
81
81
  --dir 为项目目录(与 deploy 同名参数,默认当前目录)。
82
82
  仅支持 design-html(design_local_deploy)。路径为工程根相对路径(= 服务端 latest 下 key),
83
- 禁绝对路径与 ".."。--create/--update 的本地文件必须存在。.html 被新增/删除(路由集合变化)
84
- 时自动重算并随包更新 routes.json;纯 --update 现有 .html 只改内容,不动 routes.json。
85
- routes.json 每项为 {path, file}:path 为相对路径(不含 base 前缀,由消费侧拼接),file 为
86
- 对应源文件(如 / → index.html)。
83
+ 禁绝对路径与 ".."。--create/--update 的本地文件必须存在。改动里含任意 .html(增 / 改 / 删)
84
+ 时自动重算并随包更新 routes.json;纯非 .html 改动不动 routes.json。
85
+ routes.json 每项为 {path, file, name?}:path 为相对路径(不含 base 前缀,由消费侧拼接),
86
+ file 为对应源文件(如 / → index.html),name 为该 HTML 的 <title> 文本(方案名,可缺省,
87
+ 缺省时由消费侧 fallback 到 path)。
87
88
 
88
89
  JSON 输出
89
90
  {"data": {"upsertCount": <n>, "deleteCount": <n>, "actionsSent": <n>, "routesRegenerated": <bool>}}
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_STANDALONE_OUT = void 0;
7
+ exports.handleAppExportStandalone = handleAppExportStandalone;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const spark_meta_1 = require("../../../utils/spark-meta");
11
+ const error_1 = require("../../../utils/error");
12
+ const output_1 = require("../../../utils/output");
13
+ const logger_1 = require("../../../utils/logger");
14
+ const index_1 = require("../../../services/app/export-standalone/index");
15
+ exports.DEFAULT_STANDALONE_OUT = 'dist/standalone';
16
+ /** 校验相对输出路径:禁绝对路径、禁含 ".." 段(与 app export 的 normalizeOutDir 同口径)。 */
17
+ function normalizeOutDir(rel) {
18
+ if (node_path_1.default.isAbsolute(rel) || rel.startsWith('/')) {
19
+ throw new error_1.AppError('ARGS_INVALID', `输出目录不能是绝对路径:${rel}`);
20
+ }
21
+ const posix = rel.split(node_path_1.default.sep).join('/').replace(/^\.\//, '');
22
+ if (posix.split('/').includes('..')) {
23
+ throw new error_1.AppError('ARGS_INVALID', `输出目录不能包含 "..":${rel}`);
24
+ }
25
+ return posix;
26
+ }
27
+ /** 沙箱网关地址:它才是代理 `/spark/app` 的那个服务(响应头 `Server: openresty`)。 */
28
+ const SANDBOX_GATEWAY_BASE = 'http://localhost:8080';
29
+ /**
30
+ * 图片下载的请求前缀。
31
+ *
32
+ * **刻意写死 8080,不读 `CLIENT_DEV_PORT`。** 那个 env 表示的是「应用自己的 dev server
33
+ * 监听端口」,与「谁在代理 `/spark/app`」是两回事:design-html / html 用
34
+ * `coding-static-server`(默认 8001,纯静态、完全不代理),读了它会把请求打到静态
35
+ * server 上,稳定 404。真机踩过。
36
+ */
37
+ function resolveDevServerBase(explicit) {
38
+ if (explicit)
39
+ return explicit.replace(/\/+$/, '');
40
+ return SANDBOX_GATEWAY_BASE;
41
+ }
42
+ /** 遍历产物树里的文本文件,返回 posix 相对路径。 */
43
+ function* walkText(dir, rel = '') {
44
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
45
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
46
+ const abs = node_path_1.default.join(dir, entry.name);
47
+ if (entry.isDirectory())
48
+ yield* walkText(abs, childRel);
49
+ else if (index_1.TEXT_EXTENSIONS.has(node_path_1.default.extname(entry.name).toLowerCase()))
50
+ yield childRel;
51
+ }
52
+ }
53
+ /** 统计产物树的文件数与总字节数。 */
54
+ function measure(dir) {
55
+ let fileCount = 0;
56
+ let bytes = 0;
57
+ const walk = (cur) => {
58
+ for (const entry of node_fs_1.default.readdirSync(cur, { withFileTypes: true })) {
59
+ const abs = node_path_1.default.join(cur, entry.name);
60
+ if (entry.isDirectory())
61
+ walk(abs);
62
+ else {
63
+ fileCount += 1;
64
+ bytes += node_fs_1.default.statSync(abs).size;
65
+ }
66
+ }
67
+ };
68
+ walk(dir);
69
+ return { fileCount, bytes };
70
+ }
71
+ /**
72
+ * 扫产物里是否残留 BrowserRouter —— standalone 不做 HashRouter 改造(明确决策),
73
+ * 多路由 SPA 在 file:// 下 `location.pathname` 是完整文件系统路径,匹配不上任何 Route,
74
+ * 且 pushState 在 opaque origin 下抛 SecurityError。检测到就告知用户改用本地 server。
75
+ */
76
+ function detectBrowserRouter(outDir) {
77
+ for (const rel of walkText(outDir)) {
78
+ const text = node_fs_1.default.readFileSync(node_path_1.default.join(outDir, rel), 'utf-8');
79
+ if (/\b(BrowserRouter|createBrowserRouter)\b/.test(text))
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ /**
85
+ * miaoda app export-standalone [--out <dir>]
86
+ *
87
+ * 把当前应用构建成自包含静态产物:按 stack 走各自的 strategy 产出可静态服务的目录树,
88
+ * 再扫出 `/spark/app` 图片经 dev server 代理下载到 `images/` 并改写成相对路径,最后打 zip。
89
+ *
90
+ * 设计文档:docs/superpowers/specs/2026-08-19-app-export-standalone-design.md
91
+ */
92
+ async function handleAppExportStandalone(opts) {
93
+ const projectDir = node_path_1.default.resolve(opts.dir ?? process.cwd());
94
+ const meta = (0, spark_meta_1.readSparkMeta)(projectDir);
95
+ if (!meta.stack) {
96
+ throw new error_1.AppError('ARGS_INVALID', '.spark/meta.json 缺少 stack —— 请先跑 miaoda app init', {
97
+ next_actions: ['cd 到应用根目录后重试,或先执行 miaoda app init'],
98
+ });
99
+ }
100
+ const strategy = (0, index_1.resolveStrategy)(meta.stack);
101
+ const outRel = normalizeOutDir(opts.out ?? exports.DEFAULT_STANDALONE_OUT);
102
+ const outDir = node_path_1.default.join(projectDir, outRel);
103
+ const zipRel = `${outRel}.zip`;
104
+ const zipPath = node_path_1.default.join(projectDir, zipRel);
105
+ // 1. stack-specific:产出可静态服务的目录树
106
+ strategy({ projectDir, outDir });
107
+ if (!node_fs_1.default.existsSync(outDir) || node_fs_1.default.readdirSync(outDir).length === 0) {
108
+ throw new error_1.AppError('EXPORT_STANDALONE_EMPTY_OUTPUT', '构建产物为空', {
109
+ next_actions: ['先手动跑一次该 stack 的构建命令确认工程本身可构建'],
110
+ });
111
+ }
112
+ // 2. 扫描
113
+ const urls = [];
114
+ const seen = new Set();
115
+ for (const rel of walkText(outDir)) {
116
+ for (const url of (0, index_1.scanSparkAppUrls)(node_fs_1.default.readFileSync(node_path_1.default.join(outDir, rel), 'utf-8'))) {
117
+ if (seen.has(url))
118
+ continue;
119
+ seen.add(url);
120
+ urls.push(url);
121
+ }
122
+ }
123
+ // 3. 下载
124
+ const devServerBase = resolveDevServerBase(opts.devServer);
125
+ (0, logger_1.log)('export-standalone', `Matched ${String(urls.length)} image url(s), fetching via ${devServerBase}`);
126
+ const { mapping, failed } = await (0, index_1.downloadImages)(urls, {
127
+ imagesDir: node_path_1.default.join(outDir, 'images'),
128
+ devServerBase,
129
+ });
130
+ // 命中 > 0 且全部失败 = 系统性问题(代理挂了 / 路径口径错),静默成功会误导调用方
131
+ if (urls.length > 0 && mapping.size === 0) {
132
+ throw new error_1.AppError('EXPORT_STANDALONE_IMAGE_ALL_FAILED', `命中 ${String(urls.length)} 张图片但全部下载失败`, {
133
+ next_actions: [
134
+ '确认 dev server 正在运行且能代理 /spark/app 路径',
135
+ `首个失败:${failed[0]?.url ?? '?'} → ${failed[0]?.reason ?? 'unknown'}`,
136
+ ...(failed.length > 1
137
+ ? [`其余 ${String(failed.length - 1)} 条见 --verbose(每条请求地址都会打到 stderr)`]
138
+ : []),
139
+ `实际请求前缀:${devServerBase}${opts.devServer ? '(--dev-server 指定)' : '(沙箱网关默认)'}`,
140
+ '若端口不对,可用 --dev-server http://localhost:<port> 覆盖',
141
+ ],
142
+ });
143
+ }
144
+ // 4. 改写
145
+ const rewrittenFiles = (0, index_1.rewriteTree)(outDir, mapping);
146
+ // 5. 打包
147
+ (0, index_1.zipDir)(outDir, zipPath);
148
+ // 6. 输出
149
+ const warnings = [];
150
+ if (failed.length > 0) {
151
+ warnings.push({
152
+ code: 'IMAGE_DOWNLOAD_PARTIAL_FAILURE',
153
+ message: `${String(failed.length)} 张图片下载失败,产物中保留原始绝对路径(离线打不开)`,
154
+ });
155
+ }
156
+ if (detectBrowserRouter(outDir)) {
157
+ warnings.push({
158
+ code: 'SPA_ROUTER_FILE_PROTOCOL',
159
+ message: '检测到 BrowserRouter:产物在 file:// 下不可用(路由匹配不上、pushState 抛 SecurityError),请用本地 HTTP server 打开',
160
+ });
161
+ }
162
+ const { fileCount, bytes } = measure(outDir);
163
+ (0, output_1.emit)({
164
+ data: {
165
+ appId: meta.app_id ?? '',
166
+ stack: meta.stack,
167
+ out: outRel,
168
+ zip: zipRel,
169
+ fileCount,
170
+ bytes,
171
+ images: {
172
+ matched: urls.length,
173
+ downloaded: mapping.size,
174
+ failed,
175
+ },
176
+ rewrittenFiles,
177
+ warnings,
178
+ },
179
+ });
180
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SUPPORTED_STACKS = exports.handleAppMigrate = exports.UPGRADE_MANIFEST_FILENAME = exports.DEFAULT_EXPORT_OUT = exports.handleAppExport = exports.handleAppUpgrade = exports.handleAppSync = exports.handleAppInit = exports.handleAppUpdate = exports.handleAppGet = void 0;
3
+ exports.DEFAULT_STANDALONE_OUT = exports.handleAppExportStandalone = exports.SUPPORTED_STACKS = exports.handleAppMigrate = exports.UPGRADE_MANIFEST_FILENAME = exports.DEFAULT_EXPORT_OUT = exports.handleAppExport = exports.handleAppUpgrade = exports.handleAppSync = exports.handleAppInit = exports.handleAppUpdate = exports.handleAppGet = void 0;
4
4
  var get_1 = require("./get");
5
5
  Object.defineProperty(exports, "handleAppGet", { enumerable: true, get: function () { return get_1.handleAppGet; } });
6
6
  var update_1 = require("./update");
@@ -19,3 +19,6 @@ Object.defineProperty(exports, "handleAppMigrate", { enumerable: true, get: func
19
19
  // commands 层渲染 help 时需要这份枚举;从 handler barrel 转发,避免 commands → services 越界
20
20
  var index_1 = require("../../../services/app/init/index");
21
21
  Object.defineProperty(exports, "SUPPORTED_STACKS", { enumerable: true, get: function () { return index_1.SUPPORTED_STACKS; } });
22
+ var export_standalone_1 = require("./export-standalone");
23
+ Object.defineProperty(exports, "handleAppExportStandalone", { enumerable: true, get: function () { return export_standalone_1.handleAppExportStandalone; } });
24
+ Object.defineProperty(exports, "DEFAULT_STANDALONE_OUT", { enumerable: true, get: function () { return export_standalone_1.DEFAULT_STANDALONE_OUT; } });
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.zipDir = zipDir;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const error_1 = require("../../../utils/error");
11
+ /**
12
+ * 把 `dir` 的**内容**打成 zip(解压后直接是 index.html,不多一层目录)。
13
+ *
14
+ * 走系统 `zip` 而非引 npm 依赖:仓库现有的 `app export` 也是 shell 出去调 `unzip`/`tar`,
15
+ * 保持一致,且避免为一个收尾动作增加运行时依赖。
16
+ *
17
+ * 覆盖语义:`zip` 默认是**增量更新**已存在的归档(会残留上次的文件),
18
+ * 所以先删掉目标文件再打,保证重跑导出幂等。
19
+ */
20
+ function zipDir(dir, zipPath) {
21
+ if (!node_fs_1.default.existsSync(dir)) {
22
+ throw new error_1.AppError('EXPORT_STANDALONE_ARCHIVE_FAILED', `待打包目录不存在:${dir}`);
23
+ }
24
+ node_fs_1.default.rmSync(zipPath, { force: true });
25
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(zipPath), { recursive: true });
26
+ try {
27
+ // cwd=dir + '.' → 归档内是目录内容而非目录本身
28
+ (0, node_child_process_1.execFileSync)('zip', ['-qr', zipPath, '.'], { cwd: dir, stdio: 'pipe' });
29
+ }
30
+ catch (err) {
31
+ throw new error_1.AppError('EXPORT_STANDALONE_ARCHIVE_FAILED', `zip failed: ${err.message}`, { next_actions: ['确认运行环境有 zip 命令(沙箱镜像应自带)'] });
32
+ }
33
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.copyTree = copyTree;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const error_1 = require("../../../utils/error");
10
+ /**
11
+ * 递归拷贝目录树到 `out`(先清空 out)。
12
+ *
13
+ * `design-html` 与 `html` 共用:两者的 standalone 构建都只是纯文件搬运,
14
+ * 差别仅在 `root`(项目根 vs `src/`)与 `excludes`(EXCLUDES vs 无)。
15
+ *
16
+ * 用 `statSync` 判目录以 **follow 符号链接**,与 `source-scan.ts` 的 `listSourceFiles`
17
+ * 同口径(design-html 的 `.claude/skills` 是指向 `.agents/skills` 的软链)。
18
+ */
19
+ function copyTree(root, out, excludes) {
20
+ if (!node_fs_1.default.existsSync(root)) {
21
+ throw new error_1.AppError('EXPORT_STANDALONE_EMPTY_OUTPUT', `源目录不存在:${root}`, {
22
+ next_actions: ['确认已在应用根目录下执行,且项目结构完整'],
23
+ });
24
+ }
25
+ node_fs_1.default.rmSync(out, { recursive: true, force: true });
26
+ node_fs_1.default.mkdirSync(out, { recursive: true });
27
+ const walk = (dir, rel) => {
28
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
29
+ if (excludes?.has(entry.name))
30
+ continue;
31
+ const abs = node_path_1.default.join(dir, entry.name);
32
+ const childRel = rel ? node_path_1.default.join(rel, entry.name) : entry.name;
33
+ let isDir;
34
+ try {
35
+ isDir = node_fs_1.default.statSync(abs).isDirectory();
36
+ }
37
+ catch {
38
+ continue; // 断链等无法 stat 的条目跳过
39
+ }
40
+ if (isDir) {
41
+ walk(abs, childRel);
42
+ }
43
+ else {
44
+ const dest = node_path_1.default.join(out, childRel);
45
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(dest), { recursive: true });
46
+ node_fs_1.default.copyFileSync(abs, dest);
47
+ }
48
+ }
49
+ };
50
+ walk(root, '');
51
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.downloadImages = downloadImages;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const logger_1 = require("../../../utils/logger");
10
+ const naming_1 = require("./naming");
11
+ const DEFAULT_TIMEOUT_MS = 30_000;
12
+ /**
13
+ * 经沙箱 dev server 代理批量下载图片。
14
+ *
15
+ * **单张失败不抛**——记进 `failed` 继续走。整体导出的成败判定由 handler 依据
16
+ * 「命中数 / 成功数」决定(设计文档「失败口径」:部分失败退 0,全失败退 1)。
17
+ *
18
+ * 串行下载:图片量级通常是个位数到几十,并发收益有限,而串行的日志与失败归因更清晰。
19
+ */
20
+ async function downloadImages(urls, opts) {
21
+ const mapping = new Map();
22
+ const failed = [];
23
+ if (urls.length === 0)
24
+ return { mapping, failed };
25
+ node_fs_1.default.mkdirSync(opts.imagesDir, { recursive: true });
26
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27
+ for (const url of urls) {
28
+ const target = `${opts.devServerBase}${url}`;
29
+ (0, logger_1.debug)(`export-standalone: GET ${target}`);
30
+ try {
31
+ // 刻意串行(见函数注释):图片量级通常个位数到几十,并发收益有限;
32
+ // 串行能让每张图的失败归因清晰,且不对沙箱 dev server 造成并发压力。
33
+ const resp = await fetch(target, { signal: AbortSignal.timeout(timeoutMs) });
34
+ if (!resp.ok) {
35
+ // resp.url 是**跟随重定向后**的最终地址。沙箱代理对存储图返回 302 指向签名 CDN,
36
+ // 失败发生在哪一跳,只看状态码分不出来,必须把最终地址带出来。
37
+ const finalUrl = resp.url && resp.url !== target ? ` (final: ${resp.url})` : '';
38
+ failed.push({ url, reason: `HTTP ${String(resp.status)}${finalUrl}` });
39
+ continue;
40
+ }
41
+ const buf = Buffer.from(await resp.arrayBuffer());
42
+ const name = (0, naming_1.localImageName)(url, resp.headers.get('content-type') ?? undefined);
43
+ node_fs_1.default.writeFileSync(node_path_1.default.join(opts.imagesDir, name), buf);
44
+ mapping.set(url, name);
45
+ (0, logger_1.debug)(`export-standalone: downloaded ${url} -> images/${name} (${String(buf.length)}B)`);
46
+ }
47
+ catch (err) {
48
+ failed.push({ url, reason: err instanceof Error ? err.message : String(err) });
49
+ }
50
+ }
51
+ return { mapping, failed };
52
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STANDALONE_SUPPORTED_STACKS = exports.resolveStrategy = exports.buildStandaloneEnv = exports.scanSparkAppUrls = exports.TEXT_EXTENSIONS = exports.rewriteTree = exports.localImageName = exports.extFromContentType = exports.inlineBabelScripts = exports.downloadImages = exports.copyTree = exports.zipDir = void 0;
4
+ var archive_1 = require("./archive");
5
+ Object.defineProperty(exports, "zipDir", { enumerable: true, get: function () { return archive_1.zipDir; } });
6
+ var copy_tree_1 = require("./copy-tree");
7
+ Object.defineProperty(exports, "copyTree", { enumerable: true, get: function () { return copy_tree_1.copyTree; } });
8
+ var download_1 = require("./download");
9
+ Object.defineProperty(exports, "downloadImages", { enumerable: true, get: function () { return download_1.downloadImages; } });
10
+ var inline_babel_1 = require("./inline-babel");
11
+ Object.defineProperty(exports, "inlineBabelScripts", { enumerable: true, get: function () { return inline_babel_1.inlineBabelScripts; } });
12
+ var naming_1 = require("./naming");
13
+ Object.defineProperty(exports, "extFromContentType", { enumerable: true, get: function () { return naming_1.extFromContentType; } });
14
+ Object.defineProperty(exports, "localImageName", { enumerable: true, get: function () { return naming_1.localImageName; } });
15
+ var rewrite_1 = require("./rewrite");
16
+ Object.defineProperty(exports, "rewriteTree", { enumerable: true, get: function () { return rewrite_1.rewriteTree; } });
17
+ Object.defineProperty(exports, "TEXT_EXTENSIONS", { enumerable: true, get: function () { return rewrite_1.TEXT_EXTENSIONS; } });
18
+ var scan_1 = require("./scan");
19
+ Object.defineProperty(exports, "scanSparkAppUrls", { enumerable: true, get: function () { return scan_1.scanSparkAppUrls; } });
20
+ var strategies_1 = require("./strategies");
21
+ Object.defineProperty(exports, "buildStandaloneEnv", { enumerable: true, get: function () { return strategies_1.buildStandaloneEnv; } });
22
+ Object.defineProperty(exports, "resolveStrategy", { enumerable: true, get: function () { return strategies_1.resolveStrategy; } });
23
+ Object.defineProperty(exports, "STANDALONE_SUPPORTED_STACKS", { enumerable: true, get: function () { return strategies_1.STANDALONE_SUPPORTED_STACKS; } });
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.inlineBabelScripts = inlineBabelScripts;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const logger_1 = require("../../../utils/logger");
10
+ /**
11
+ * 匹配**完整的 `<script>` 元素**(含闭合标签),而不是只匹配开标签。
12
+ *
13
+ * 属性区用 `(?:"[^"]*"|'[^']*'|[^>])*` 而非 `[^>]*` —— 后者会被属性值里的 `>` 提前截断。
14
+ * 元素体用非贪婪 `[\s\S]*?` 配到最近的 `</script>`:带 `src` 的标签体按规范会被忽略,
15
+ * 整体替换掉最直白,不必去拼接原闭合标签。
16
+ */
17
+ const SCRIPT_EL_RE = /<script((?:"[^"]*"|'[^']*'|[^>])*)>([\s\S]*?)<\/script\s*>/gi;
18
+ /** Babel Standalone 认这两种 type。 */
19
+ const BABEL_TYPE_RE = /\btype\s*=\s*["']text\/(?:babel|jsx)["']/i;
20
+ /** 取 src 属性值(带引号形式;Vite/手写产物里都是带引号的)。 */
21
+ const SRC_ATTR_RE = /\bsrc\s*=\s*["']([^"']+)["']/i;
22
+ /** 远端 / 内联数据源:不是本地文件,不内联。 */
23
+ const REMOTE_SRC_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
24
+ /** 需要处理的 HTML 扩展名。 */
25
+ const HTML_EXT = new Set(['.html', '.htm']);
26
+ function* walkHtml(dir, rel = '') {
27
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
28
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
29
+ const abs = node_path_1.default.join(dir, entry.name);
30
+ if (entry.isDirectory())
31
+ yield* walkHtml(abs, childRel);
32
+ else if (HTML_EXT.has(node_path_1.default.extname(entry.name).toLowerCase()))
33
+ yield childRel;
34
+ }
35
+ }
36
+ /**
37
+ * 把 `<script type="text/babel" src="x.jsx"></script>` 的外链内容内联进 HTML。
38
+ *
39
+ * **为什么必须做**:Babel Standalone 是用 **XHR** 去取 `src` 指向的文件的。`file://` 下
40
+ * 文档 origin 是 `null`,浏览器只允许 XHR 访问 chrome/data/http/https 等协议,file 被禁:
41
+ *
42
+ * ```
43
+ * Access to XMLHttpRequest at 'file:///.../app.jsx' from origin 'null' has been
44
+ * blocked by CORS policy: Cross origin requests are only supported for protocol
45
+ * schemes: chrome, ..., data, http, https, ...
46
+ * ```
47
+ *
48
+ * 结果是 JSX 一个都没执行、页面白屏(`Uncaught ReferenceError: App is not defined`)。
49
+ * 内联之后 Babel 直接读标签内容,不发请求,`file://` 下正常渲染(已实测)。
50
+ *
51
+ * 只对 buildless 的 `design-html` / `html` 生效 —— vite 系产物已经打包过,不存在这种标签。
52
+ *
53
+ * 保留原 `.jsx` 文件不删:起 HTTP server 时两条路都能走,删了反而少一条。
54
+ */
55
+ function inlineBabelScripts(root) {
56
+ const changed = [];
57
+ const rootResolved = node_path_1.default.resolve(root);
58
+ for (const rel of walkHtml(root)) {
59
+ const htmlPath = node_path_1.default.join(root, rel);
60
+ const htmlDir = node_path_1.default.dirname(htmlPath);
61
+ const original = node_fs_1.default.readFileSync(htmlPath, 'utf-8');
62
+ const next = original.replace(SCRIPT_EL_RE, (el, attrs) => {
63
+ if (!BABEL_TYPE_RE.test(attrs))
64
+ return el;
65
+ const m = SRC_ATTR_RE.exec(attrs);
66
+ if (!m)
67
+ return el; // 已是内联块
68
+ const src = m[1];
69
+ if (REMOTE_SRC_RE.test(src))
70
+ return el; // http(s) / 协议相对 / data:
71
+ const target = node_path_1.default.resolve(htmlDir, src);
72
+ // 防路径穿越:只内联产物树内的文件,否则可能把树外的内容打进产物
73
+ if (target !== rootResolved && !target.startsWith(rootResolved + node_path_1.default.sep))
74
+ return el;
75
+ if (!node_fs_1.default.existsSync(target))
76
+ return el;
77
+ // `</script` 出现在 JS 字符串里会提前闭合标签,必须转义
78
+ const code = node_fs_1.default.readFileSync(target, 'utf-8').replace(/<\/script/gi, '<\\/script');
79
+ const keptAttrs = attrs.replace(SRC_ATTR_RE, '').replace(/\s+/g, ' ').trimEnd();
80
+ (0, logger_1.debug)(`export-standalone: inlined ${src} into ${rel}`);
81
+ return `<script${keptAttrs} data-inlined-from="${src}">\n${code}\n</script>`;
82
+ });
83
+ // 用「文本是否变化」判定,不用外层 flag —— 回调里的赋值 TS 控制流分析看不到,
84
+ // 会把 `if (hit)` 判成恒假(@typescript-eslint/no-unnecessary-condition)。
85
+ if (next !== original) {
86
+ node_fs_1.default.writeFileSync(htmlPath, next, 'utf-8');
87
+ changed.push(rel);
88
+ }
89
+ }
90
+ return changed.sort();
91
+ }