@lark-apaas/miaoda-cli 0.1.32 → 0.1.33-alpha.0e32e59
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 +22 -0
- package/dist/cli/commands/app/index.js +64 -0
- package/dist/cli/commands/deploy/modern.js +6 -5
- package/dist/cli/handlers/app/export-standalone.js +216 -0
- package/dist/cli/handlers/app/index.js +4 -1
- package/dist/services/app/export-standalone/archive.js +33 -0
- package/dist/services/app/export-standalone/copy-tree.js +51 -0
- package/dist/services/app/export-standalone/download.js +52 -0
- package/dist/services/app/export-standalone/index.js +23 -0
- package/dist/services/app/export-standalone/inline-babel.js +91 -0
- package/dist/services/app/export-standalone/naming.js +65 -0
- package/dist/services/app/export-standalone/rewrite.js +80 -0
- package/dist/services/app/export-standalone/scan.js +72 -0
- package/dist/services/app/export-standalone/strategies.js +214 -0
- package/dist/services/deploy/modern/patch/actions.js +6 -4
- package/dist/services/deploy/modern/patch/html-title.js +75 -0
- package/dist/services/deploy/modern/patch/index.js +1 -1
- package/dist/services/deploy/modern/patch/routes.js +13 -3
- package/dist/services/deploy/modern/patch/source-scan.js +3 -40
- package/dist/utils/exclude-patterns.js +49 -0
- package/dist/utils/sandbox-skills.js +13 -1
- package/package.json +1 -1
- package/upgrade/templates/nestjs-react-fullstack/templates/scripts/lint.js +43 -7
|
@@ -0,0 +1,65 @@
|
|
|
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.extFromContentType = extFromContentType;
|
|
7
|
+
exports.localImageName = localImageName;
|
|
8
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
9
|
+
/**
|
|
10
|
+
* `Content-Type` → 文件扩展名。
|
|
11
|
+
*
|
|
12
|
+
* 存储 key 常常**不带扩展名**(线上真实形态 `static%2Fdemo_ve_miaoda`),
|
|
13
|
+
* URL 里推不出类型,只能以服务端响应为准。
|
|
14
|
+
* 未知类型返回空串 —— 宁可没后缀,也不猜一个错的(错后缀会让浏览器按错误类型解析)。
|
|
15
|
+
*/
|
|
16
|
+
const EXT_BY_MIME = {
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/jpeg': '.jpg',
|
|
19
|
+
'image/jpg': '.jpg',
|
|
20
|
+
'image/gif': '.gif',
|
|
21
|
+
'image/webp': '.webp',
|
|
22
|
+
'image/svg+xml': '.svg',
|
|
23
|
+
'image/avif': '.avif',
|
|
24
|
+
'image/bmp': '.bmp',
|
|
25
|
+
'image/x-icon': '.ico',
|
|
26
|
+
'image/vnd.microsoft.icon': '.ico',
|
|
27
|
+
'image/tiff': '.tiff',
|
|
28
|
+
};
|
|
29
|
+
function extFromContentType(contentType) {
|
|
30
|
+
if (!contentType)
|
|
31
|
+
return '';
|
|
32
|
+
const mime = contentType.split(';')[0].trim().toLowerCase();
|
|
33
|
+
return EXT_BY_MIME[mime] ?? '';
|
|
34
|
+
}
|
|
35
|
+
/** 只保留文件名安全字符;其余折成 `_`。顺带挡掉 `/` 与 `..`(防 basename 逃出 images/)。 */
|
|
36
|
+
function sanitize(name) {
|
|
37
|
+
return name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* URL → `images/` 下的本地文件名。
|
|
41
|
+
*
|
|
42
|
+
* 规则:
|
|
43
|
+
* 1. 剥掉 query/hash 后取 pathname
|
|
44
|
+
* 2. `decodeURIComponent` 还原(key 常被编码,如 `static%2Fx` → `static/x`)
|
|
45
|
+
* 3. 取 basename 并清洗危险字符
|
|
46
|
+
* 4. 追加 **完整 URL(含 query)** 的 8 位 hash —— 保证:
|
|
47
|
+
* - 同 URL 幂等(重跑导出不产生新文件)
|
|
48
|
+
* - 不同路径同名不互撞
|
|
49
|
+
* - 同图不同 `x-tos-process` 参数分开存(处理后的图内容不同)
|
|
50
|
+
* 5. 扩展名取自 `Content-Type`,URL 里自带的后缀不作数(服务端才是真相)
|
|
51
|
+
*/
|
|
52
|
+
function localImageName(url, contentType) {
|
|
53
|
+
const pathname = url.split('#')[0].split('?')[0];
|
|
54
|
+
let decoded = pathname;
|
|
55
|
+
try {
|
|
56
|
+
decoded = decodeURIComponent(pathname);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// 畸形 % 转义:退回原串,不抛
|
|
60
|
+
}
|
|
61
|
+
const rawBase = decoded.split('/').filter(Boolean).pop() ?? '';
|
|
62
|
+
const base = sanitize(rawBase).replace(/\.[^.]*$/, '') || 'image';
|
|
63
|
+
const hash = node_crypto_1.default.createHash('sha1').update(url).digest('hex').slice(0, 8);
|
|
64
|
+
return `${base}-${hash}${extFromContentType(contentType)}`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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.TEXT_EXTENSIONS = void 0;
|
|
7
|
+
exports.rewriteTree = rewriteTree;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
/**
|
|
11
|
+
* 需要扫描并改写的文本文件扩展名。
|
|
12
|
+
* 只列产物里真会出现 URL 的类型 —— `images/` 下的二进制不在其中,
|
|
13
|
+
* 避免把 PNG 当 UTF-8 读进来再写回去(会损坏文件)。
|
|
14
|
+
*/
|
|
15
|
+
exports.TEXT_EXTENSIONS = new Set([
|
|
16
|
+
'.html',
|
|
17
|
+
'.htm',
|
|
18
|
+
'.css',
|
|
19
|
+
'.js',
|
|
20
|
+
'.mjs',
|
|
21
|
+
'.cjs',
|
|
22
|
+
// buildless 栈(design-html / html)的源文件**就是产物**,JSX/TS 里的图片
|
|
23
|
+
// URL 同样要扫要改。漏了这几个扩展名会导致图片下载成功、但 .jsx 里的引用
|
|
24
|
+
// 仍指向线上绝对路径 —— 真机踩过(app.jsx 里 5 处未改写)。
|
|
25
|
+
'.jsx',
|
|
26
|
+
'.ts',
|
|
27
|
+
'.tsx',
|
|
28
|
+
'.mts',
|
|
29
|
+
'.cts',
|
|
30
|
+
'.json',
|
|
31
|
+
'.svg',
|
|
32
|
+
'.webmanifest',
|
|
33
|
+
]);
|
|
34
|
+
function* walk(dir, rel = '') {
|
|
35
|
+
for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
37
|
+
const abs = node_path_1.default.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory())
|
|
39
|
+
yield* walk(abs, childRel);
|
|
40
|
+
else
|
|
41
|
+
yield childRel;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 把产物树里所有命中的 URL 改写成 `./images/<name>`。
|
|
46
|
+
*
|
|
47
|
+
* **只改写 mapping 里有的 URL** —— 下载失败的原样保留。改成 `./images/xxx` 但文件不存在
|
|
48
|
+
* 反而制造死链,保留原绝对路径至少在联网访问平台时还能显示。
|
|
49
|
+
*
|
|
50
|
+
* 替换顺序按 URL **长度降序**:`/a/k.png` 是 `/a/k.png?x=1` 的前缀,若先替短的,
|
|
51
|
+
* 长 URL 会被替成 `./images/short.png?x=1` 这种半截产物。
|
|
52
|
+
*
|
|
53
|
+
* 用 `split().join()` 而非 `replace(new RegExp(...))`:URL 里含 `?`、`.`、`+`、`$` 等
|
|
54
|
+
* 正则元字符,转义容易出错;且 `$&` / `$1` 之类替换串语义也是坑。
|
|
55
|
+
*
|
|
56
|
+
* 返回实际发生改动的文件相对路径(posix 风格),用于 JSON 输出的 `rewrittenFiles`。
|
|
57
|
+
*/
|
|
58
|
+
function rewriteTree(root, mapping) {
|
|
59
|
+
if (mapping.size === 0)
|
|
60
|
+
return [];
|
|
61
|
+
const entries = [...mapping.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
62
|
+
const changed = [];
|
|
63
|
+
for (const rel of walk(root)) {
|
|
64
|
+
if (!exports.TEXT_EXTENSIONS.has(node_path_1.default.extname(rel).toLowerCase()))
|
|
65
|
+
continue;
|
|
66
|
+
const abs = node_path_1.default.join(root, rel);
|
|
67
|
+
const original = node_fs_1.default.readFileSync(abs, 'utf-8');
|
|
68
|
+
let next = original;
|
|
69
|
+
for (const [url, name] of entries) {
|
|
70
|
+
if (!next.includes(url))
|
|
71
|
+
continue;
|
|
72
|
+
next = next.split(url).join(`./images/${name}`);
|
|
73
|
+
}
|
|
74
|
+
if (next !== original) {
|
|
75
|
+
node_fs_1.default.writeFileSync(abs, next, 'utf-8');
|
|
76
|
+
changed.push(rel);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return changed.sort();
|
|
80
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 从构建产物文本里扫出需要本地化的应用存储图片 URL。
|
|
4
|
+
*
|
|
5
|
+
* 匹配口径:**只认字面以 `/spark/app/` 开头的同源相对路径**。
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ 这是刻意收窄的口径,会漏判其它挂载前缀(`/spark/p/`、`/spark/r/`、`/app/<appId>`、
|
|
8
|
+
* 反代场景等)。`miaoda-coding` 的 `cd-elements/src/core/tos.ts` 走的是「存储路径中段
|
|
9
|
+
* 子串匹配」,注释里明确记着「改成前缀匹配会让存储图静默跳过,这个错误犯过一次」。
|
|
10
|
+
* 本能力**明知该历史仍选择前缀匹配**(决策见设计文档「图片本地化」)。
|
|
11
|
+
* 若将来要扩范围,正确做法是**切子串匹配**,不是追加枚举前缀。
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.scanSparkAppUrls = scanSparkAppUrls;
|
|
15
|
+
/**
|
|
16
|
+
* URL 终止字符:引号、反引号、空白、尖括号、圆括号、反斜杠。
|
|
17
|
+
* 刻意**不含逗号** —— `?x-tos-process=image/resize,w_400` 里逗号是 URL 的一部分,
|
|
18
|
+
* 排除它会把 query 截断成无效地址。
|
|
19
|
+
*
|
|
20
|
+
* 代价:`srcset="a.png 1x,b.png 2x"` 这种逗号后无空格的写法会把两个 URL 粘成一个。
|
|
21
|
+
* 实际产物里 srcset 由 cd-elements 生成,逗号后带空格,不触发。
|
|
22
|
+
*/
|
|
23
|
+
const SPARK_APP_URL_RE = /\/spark\/app\/[^"'`\s<>()\\]+/g;
|
|
24
|
+
/**
|
|
25
|
+
* URL 前必须是「token 起始位置」——字符串开头,或前一个字符是分隔符。
|
|
26
|
+
*
|
|
27
|
+
* 这条是为了排掉第三方绝对 URL:`https://example.com/spark/app/a/k.png` 里
|
|
28
|
+
* `/spark/app/` 的前一个字符是 `m`(`.com` 的 m),不是分隔符,因此不命中。
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ 不能写成「前一个字符不是 `/`」—— 那个判据对绝对 URL 无效(prev 是 `m` 不是 `/`),
|
|
31
|
+
* 会把第三方地址误判成同源相对路径。
|
|
32
|
+
*/
|
|
33
|
+
const BOUNDARY_CHARS = new Set([
|
|
34
|
+
'"',
|
|
35
|
+
"'",
|
|
36
|
+
'`',
|
|
37
|
+
' ',
|
|
38
|
+
'\t',
|
|
39
|
+
'\n',
|
|
40
|
+
'\r',
|
|
41
|
+
'(',
|
|
42
|
+
'=',
|
|
43
|
+
',',
|
|
44
|
+
';',
|
|
45
|
+
':',
|
|
46
|
+
'{',
|
|
47
|
+
'[',
|
|
48
|
+
'<',
|
|
49
|
+
'>',
|
|
50
|
+
]);
|
|
51
|
+
function isSameOriginRelative(text, index) {
|
|
52
|
+
if (index === 0)
|
|
53
|
+
return true;
|
|
54
|
+
return BOUNDARY_CHARS.has(text[index - 1]);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 扫出去重后的 URL 列表,保持首次出现的顺序(便于测试断言与日志可读)。
|
|
58
|
+
*/
|
|
59
|
+
function scanSparkAppUrls(text) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const m of text.matchAll(SPARK_APP_URL_RE)) {
|
|
63
|
+
if (!isSameOriginRelative(text, m.index))
|
|
64
|
+
continue;
|
|
65
|
+
const url = m[0];
|
|
66
|
+
if (seen.has(url))
|
|
67
|
+
continue;
|
|
68
|
+
seen.add(url);
|
|
69
|
+
out.push(url);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
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.STANDALONE_SUPPORTED_STACKS = void 0;
|
|
7
|
+
exports.buildStandaloneEnv = buildStandaloneEnv;
|
|
8
|
+
exports.liftNestedEntry = liftNestedEntry;
|
|
9
|
+
exports.resolveStrategy = resolveStrategy;
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const error_1 = require("../../../utils/error");
|
|
14
|
+
const exclude_patterns_1 = require("../../../utils/exclude-patterns");
|
|
15
|
+
const logger_1 = require("../../../utils/logger");
|
|
16
|
+
const copy_tree_1 = require("./copy-tree");
|
|
17
|
+
const inline_babel_1 = require("./inline-babel");
|
|
18
|
+
exports.STANDALONE_SUPPORTED_STACKS = new Set([
|
|
19
|
+
'design-html',
|
|
20
|
+
'html',
|
|
21
|
+
'vite-react',
|
|
22
|
+
'nestjs-react-fullstack',
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* standalone 构建 env。
|
|
26
|
+
*
|
|
27
|
+
* **不复用 `deploy/modern/atoms/build.ts` 的 `runBuild()`** —— 它的 `ensureHttpsScheme()`
|
|
28
|
+
* 会把 `'./'` 判成域名(含 `.` 且不以 `/` 开头)拼成 `https://./`,破掉 vite 的 base 替换。
|
|
29
|
+
*
|
|
30
|
+
* 删 `MIAODA_APP_ID` 是刻意的:`scripts/build.sh` 写的是
|
|
31
|
+
* `export CLIENT_BASE_PATH="${MIAODA_APP_ID:+/app/$MIAODA_APP_ID}"`,
|
|
32
|
+
* 变量为空时整个展开为空串,正好得到我们要的空 base path。
|
|
33
|
+
*/
|
|
34
|
+
function buildStandaloneEnv(base) {
|
|
35
|
+
const env = { ...base };
|
|
36
|
+
delete env.MIAODA_APP_ID;
|
|
37
|
+
delete env.CLIENT_BASE_PATH;
|
|
38
|
+
env.MIAODA_BUILD_TARGET = 'standalone';
|
|
39
|
+
env.NODE_ENV = 'production';
|
|
40
|
+
// vite-react 走 build.sh,由它把 MIAODA_RESOURCE_CDN_PREFIX 映射成 ASSETS_CDN_PATH
|
|
41
|
+
env.MIAODA_RESOURCE_CDN_PREFIX = './';
|
|
42
|
+
// nestjs 走 `npm run build:client` 直接调 vite,**没有 build.sh 那层映射** ——
|
|
43
|
+
// 必须直接给 preset 认的名字,否则 publicPath 为空 → base '/' → 产物里是
|
|
44
|
+
// `/assets/x.js` 绝对路径,file:// 下指向文件系统根,页面白屏(真机踩过)。
|
|
45
|
+
// vite-react 那边 build.sh 会用同样的值覆盖它,两条路结果一致。
|
|
46
|
+
env.ASSETS_CDN_PATH = './';
|
|
47
|
+
env.STATIC_ASSETS_BASE_URL = './static';
|
|
48
|
+
return env;
|
|
49
|
+
}
|
|
50
|
+
/** 把 `from` 目录内容并入 `into`(into 已存在,不清空)。from 不存在则静默跳过。 */
|
|
51
|
+
function mergeInto(from, into) {
|
|
52
|
+
if (!node_fs_1.default.existsSync(from))
|
|
53
|
+
return;
|
|
54
|
+
node_fs_1.default.mkdirSync(into, { recursive: true });
|
|
55
|
+
node_fs_1.default.cpSync(from, into, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 把 `<out>/client/index.html` 提到 `<out>/index.html`,并把资源引用降一级。
|
|
59
|
+
*
|
|
60
|
+
* **为什么会嵌一层**:vite 按 input 相对 root 的位置放产物 HTML。nestjs 模板的
|
|
61
|
+
* input 是 `client/index.html`、root 是项目根,所以 HTML 落在 `dist/client/client/`。
|
|
62
|
+
* 平台构建里 `vite-html-output` 插件负责把它提上来,但 standalone 下**刻意不挂**那个
|
|
63
|
+
* 插件——它的上移不修相对引用(平台模式 base 是绝对路径,不需要修)。
|
|
64
|
+
*
|
|
65
|
+
* **为什么必须同时改路径**:standalone 的 base 是 `./`,产物里写的是 `../assets/x`,
|
|
66
|
+
* 那是相对 `<out>/client/` 算的。提到 `<out>/` 后就多了一级,指向不存在的
|
|
67
|
+
* `<out>/../assets/`,页面白屏(实测确认)。层级差恒为 1,故 `../` → `./`。
|
|
68
|
+
*
|
|
69
|
+
* 只改 `src` / `href` 属性开头的 `../`,不做全文替换 —— 否则 inline 脚本里的字符串
|
|
70
|
+
* 常量(如 `var p = "../keep/me"`)会被误伤。
|
|
71
|
+
*/
|
|
72
|
+
function liftNestedEntry(outDir) {
|
|
73
|
+
const nestedDir = node_path_1.default.join(outDir, 'client');
|
|
74
|
+
const nestedHtml = node_path_1.default.join(nestedDir, 'index.html');
|
|
75
|
+
if (!node_fs_1.default.existsSync(nestedHtml))
|
|
76
|
+
return;
|
|
77
|
+
const target = node_path_1.default.join(outDir, 'index.html');
|
|
78
|
+
const html = node_fs_1.default.readFileSync(nestedHtml, 'utf-8');
|
|
79
|
+
const rebased = html.replace(/\b(src|href)=("|')\.\.\/(?!\.)/gi, (_m, attr, quote) => `${attr}=${quote}./`);
|
|
80
|
+
node_fs_1.default.writeFileSync(target, rebased, 'utf-8');
|
|
81
|
+
node_fs_1.default.rmSync(nestedHtml, { force: true });
|
|
82
|
+
// 只删空目录:嵌套目录里若还有别的文件,留着比静默丢弃安全
|
|
83
|
+
if (node_fs_1.default.readdirSync(nestedDir).length === 0)
|
|
84
|
+
node_fs_1.default.rmdirSync(nestedDir);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 从产物树里删掉 sourcemap(`*.map`)。
|
|
88
|
+
*
|
|
89
|
+
* preset 的 prod 配置是 `sourcemap: 'hidden'` —— map 文件会产出,但产物里**没有**
|
|
90
|
+
* `sourceMappingURL` 注释,即没人引用它。留在 standalone 包里有两个实打实的问题:
|
|
91
|
+
*
|
|
92
|
+
* 1. **体积**:实测真实应用 4.7MB map / 5.9MB 总体积,占 80%
|
|
93
|
+
* 2. **泄源码**:map 的 `sourcesContent` 内嵌原始源码(实测含 12 个业务文件,
|
|
94
|
+
* 如 `src/lib/utils.ts`)。standalone 包是拿来分发给人的,等于附赠全部源码
|
|
95
|
+
*
|
|
96
|
+
* 只对有编译步骤的 stack 调用;buildless 栈(design-html / html)的产物就是源码,
|
|
97
|
+
* 那里的 `.map`(若有)是用户自己的文件,不该动。
|
|
98
|
+
*/
|
|
99
|
+
function dropSourceMaps(outDir) {
|
|
100
|
+
const walk = (dir) => {
|
|
101
|
+
for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
const abs = node_path_1.default.join(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory())
|
|
104
|
+
walk(abs);
|
|
105
|
+
else if (entry.name.toLowerCase().endsWith('.map'))
|
|
106
|
+
node_fs_1.default.rmSync(abs, { force: true });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
walk(outDir);
|
|
110
|
+
}
|
|
111
|
+
/** 从产物树里删掉 routes.json —— 平台/server 路由用,standalone 无人读取。 */
|
|
112
|
+
function dropRoutesJson(outDir) {
|
|
113
|
+
node_fs_1.default.rmSync(node_path_1.default.join(outDir, 'routes.json'), { force: true });
|
|
114
|
+
}
|
|
115
|
+
function runProjectBuild(projectDir, command) {
|
|
116
|
+
try {
|
|
117
|
+
(0, node_child_process_1.execSync)(command, {
|
|
118
|
+
cwd: projectDir,
|
|
119
|
+
stdio: 'inherit',
|
|
120
|
+
env: buildStandaloneEnv(process.env),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
throw new error_1.AppError('EXPORT_STANDALONE_BUILD_FAILED', `${command} failed: ${err.message}`, { next_actions: ['先手动跑一次该构建命令确认工程本身可构建'] });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** design-html:项目根纯拷贝,套 deploy 同一份 EXCLUDES。 */
|
|
128
|
+
const designHtmlStrategy = ({ projectDir, outDir }) => {
|
|
129
|
+
(0, logger_1.log)('export-standalone', 'Copying source (design-html)...');
|
|
130
|
+
(0, copy_tree_1.copyTree)(projectDir, outDir, exclude_patterns_1.EXCLUDES);
|
|
131
|
+
(0, inline_babel_1.inlineBabelScripts)(outDir);
|
|
132
|
+
dropRoutesJson(outDir);
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* html:拷 `src/`,**跳过 `coding-html-devserver build`**。
|
|
136
|
+
* 该命令会注入 slardar/tea 外链与 `{{appName}}` HBS 占位符,standalone 全要撤销,
|
|
137
|
+
* 调它再逐项撤销不如不调(设计文档「html 跳过 coding-html-devserver build」)。
|
|
138
|
+
*/
|
|
139
|
+
const htmlStrategy = ({ projectDir, outDir }) => {
|
|
140
|
+
(0, logger_1.log)('export-standalone', 'Copying src/ (html)...');
|
|
141
|
+
(0, copy_tree_1.copyTree)(node_path_1.default.join(projectDir, 'src'), outDir);
|
|
142
|
+
(0, inline_babel_1.inlineBabelScripts)(outDir);
|
|
143
|
+
dropRoutesJson(outDir);
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* vite-react:跑 `scripts/build.sh`,再把按上传通道拆的四个目录按可访问性合并。
|
|
147
|
+
* `output_capabilities` 丢弃 —— 平台侧注册用,运行时走已打进 bundle 的
|
|
148
|
+
* `virtual:capabilities` 虚拟模块,产物目录不引用它。
|
|
149
|
+
*/
|
|
150
|
+
const viteReactStrategy = ({ projectDir, outDir }) => {
|
|
151
|
+
(0, logger_1.log)('export-standalone', 'Building (vite-react)...');
|
|
152
|
+
runProjectBuild(projectDir, 'bash scripts/build.sh');
|
|
153
|
+
const dist = node_path_1.default.join(projectDir, 'dist');
|
|
154
|
+
node_fs_1.default.rmSync(outDir, { recursive: true, force: true });
|
|
155
|
+
node_fs_1.default.mkdirSync(outDir, { recursive: true });
|
|
156
|
+
mergeInto(node_path_1.default.join(dist, 'output'), outDir);
|
|
157
|
+
mergeInto(node_path_1.default.join(dist, 'output_resource', 'assets'), node_path_1.default.join(outDir, 'assets'));
|
|
158
|
+
mergeInto(node_path_1.default.join(dist, 'output_static'), node_path_1.default.join(outDir, 'static'));
|
|
159
|
+
dropSourceMaps(outDir);
|
|
160
|
+
dropRoutesJson(outDir);
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* nestjs-react-fullstack:只跑 `build:client`。
|
|
164
|
+
* 完整 `scripts/build.sh` 会跑 `action-plugin init` / `gen:openapi` / `nest build` /
|
|
165
|
+
* `prune-smart`,standalone 只要 client 那一半;且 `action-plugin init` 依赖平台 env,
|
|
166
|
+
* 是已知易失败点。
|
|
167
|
+
*
|
|
168
|
+
* 产物是单目录 `dist/client`,无需合并;但入口 HTML 嵌在 `dist/client/client/` 下
|
|
169
|
+
* (standalone 不挂 preset 的 vite-html-output 插件),需 `liftNestedEntry` 提到根。
|
|
170
|
+
*/
|
|
171
|
+
const nestjsStrategy = ({ projectDir, outDir }) => {
|
|
172
|
+
(0, logger_1.log)('export-standalone', 'Building client (nestjs-react-fullstack)...');
|
|
173
|
+
// 构建到**独立临时目录**,不碰 `dist/client`。
|
|
174
|
+
//
|
|
175
|
+
// 两个原因:
|
|
176
|
+
// 1. preset 设了 `emptyOutDir: false`(它指望 `scripts/build.sh` 先 `rm -rf dist`),
|
|
177
|
+
// 而我们绕过 build.sh 直接跑 `build:client`,没人清目录 —— 复用 `dist/client`
|
|
178
|
+
// 会把上次构建的 chunk 一起带进产物(真机见过新旧两个 index-<hash>.js 并存)。
|
|
179
|
+
// 2. `dist/client` 是平台链路的产物目录(dev 期 html-output 会往里写、NestJS 从
|
|
180
|
+
// 那儿取 HTML),导出这种旁路动作**不该清它**,否则破坏既有目录结构。
|
|
181
|
+
//
|
|
182
|
+
// vite CLI 的 `--outDir` 优先级高于 config,`--emptyOutDir` 覆盖 `emptyOutDir: false`。
|
|
183
|
+
// 放在 dist/ 下是为了留在 vite root 内(root 外 vite 会告警)。
|
|
184
|
+
const tmpDist = node_path_1.default.join(projectDir, 'dist', '.standalone-client');
|
|
185
|
+
try {
|
|
186
|
+
runProjectBuild(projectDir, `npm run build:client -- --outDir ${JSON.stringify(node_path_1.default.relative(projectDir, tmpDist))} --emptyOutDir`);
|
|
187
|
+
if (!node_fs_1.default.existsSync(tmpDist)) {
|
|
188
|
+
throw new error_1.AppError('EXPORT_STANDALONE_EMPTY_OUTPUT', `client 构建产物 ${node_path_1.default.relative(projectDir, tmpDist)} 不存在`);
|
|
189
|
+
}
|
|
190
|
+
node_fs_1.default.rmSync(outDir, { recursive: true, force: true });
|
|
191
|
+
node_fs_1.default.mkdirSync(outDir, { recursive: true });
|
|
192
|
+
mergeInto(tmpDist, outDir);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
// 临时构建目录不留在用户项目里
|
|
196
|
+
node_fs_1.default.rmSync(tmpDist, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
liftNestedEntry(outDir);
|
|
199
|
+
dropSourceMaps(outDir);
|
|
200
|
+
dropRoutesJson(outDir);
|
|
201
|
+
};
|
|
202
|
+
const STRATEGY_BY_STACK = new Map([
|
|
203
|
+
['design-html', designHtmlStrategy],
|
|
204
|
+
['html', htmlStrategy],
|
|
205
|
+
['vite-react', viteReactStrategy],
|
|
206
|
+
['nestjs-react-fullstack', nestjsStrategy],
|
|
207
|
+
]);
|
|
208
|
+
function resolveStrategy(stack) {
|
|
209
|
+
const strategy = STRATEGY_BY_STACK.get(stack);
|
|
210
|
+
if (!strategy) {
|
|
211
|
+
throw new error_1.AppError('EXPORT_STANDALONE_STACK_UNSUPPORTED', `stack "${stack}" 不支持 standalone 导出`, { next_actions: [`支持的 stack:${[...exports.STANDALONE_SUPPORTED_STACKS].join(', ')}`] });
|
|
212
|
+
}
|
|
213
|
+
return strategy;
|
|
214
|
+
}
|
|
@@ -36,8 +36,10 @@ function isHtml(rel) {
|
|
|
36
36
|
/**
|
|
37
37
|
* 把 changeset 组成 TosFileAction[]:
|
|
38
38
|
* - create/update 读本地文件、自动编码;delete 仅 FilePath。
|
|
39
|
-
* - .html 被 create / delete
|
|
40
|
-
*
|
|
39
|
+
* - 任一 .html 被 create / update / delete 时,追加重算 routes.json 的 UPDATE action。
|
|
40
|
+
* update 也算:routes.json 的 name 取自 HTML 的 `<title>`,改方案名就是一次纯 update,
|
|
41
|
+
* 只按 create/delete 判定会让改名永远进不了 routes.json。
|
|
42
|
+
* - 纯非 .html 改动(js / css / 图片)不动 routes.json。
|
|
41
43
|
*/
|
|
42
44
|
function buildTosActions(projectDir, cs) {
|
|
43
45
|
const actions = [];
|
|
@@ -48,8 +50,8 @@ function buildTosActions(projectDir, cs) {
|
|
|
48
50
|
for (const rel of cs.deletes) {
|
|
49
51
|
actions.push({ actionType: index_1.TosActionType.DELETE, filePath: normalizeRel(rel) });
|
|
50
52
|
}
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
53
|
+
const routesMayChange = [...cs.creates, ...cs.updates, ...cs.deletes].some(isHtml);
|
|
54
|
+
if (routesMayChange) {
|
|
53
55
|
actions.push({
|
|
54
56
|
actionType: index_1.TosActionType.UPDATE,
|
|
55
57
|
filePath: 'routes.json',
|
|
@@ -0,0 +1,75 @@
|
|
|
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.extractTitle = extractTitle;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
/**
|
|
9
|
+
* 读 `<title>` 时的扫描窗口上限(字节)。title 规范上在 `<head>` 里、正常紧贴文档开头,
|
|
10
|
+
* 64KB 足以覆盖 head 里塞了内联 style / script 的情况,同时给大 HTML 兜住 IO 上限
|
|
11
|
+
* (generateRoutes 会遍历工程内所有 .html,不能整文件读)。
|
|
12
|
+
*/
|
|
13
|
+
const HEAD_SCAN_BYTES = 64 * 1024;
|
|
14
|
+
const TITLE_RE = /<title[^>]*>([^<]*)<\/title>/i;
|
|
15
|
+
const HEAD_CLOSE_RE = /<\/head\s*>/i;
|
|
16
|
+
const COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
17
|
+
const ENTITY_RE = /&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi;
|
|
18
|
+
/** HTML 里可能出现在 title 文本中的命名实体。未列出的原样保留,不做全量实体表。 */
|
|
19
|
+
const NAMED_ENTITIES = {
|
|
20
|
+
amp: '&',
|
|
21
|
+
lt: '<',
|
|
22
|
+
gt: '>',
|
|
23
|
+
quot: '"',
|
|
24
|
+
apos: "'",
|
|
25
|
+
nbsp: ' ',
|
|
26
|
+
};
|
|
27
|
+
/** 把 title 文本里的实体还原成明文——name 是给前端直接当文本渲染的,不该带 `&`。 */
|
|
28
|
+
function decodeEntities(s) {
|
|
29
|
+
return s.replace(ENTITY_RE, (matched, body) => {
|
|
30
|
+
if (!body.startsWith('#')) {
|
|
31
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? matched;
|
|
32
|
+
}
|
|
33
|
+
const code = /^#x/i.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
34
|
+
if (!Number.isInteger(code) || code <= 0 || code > 0x10ffff)
|
|
35
|
+
return matched;
|
|
36
|
+
return String.fromCodePoint(code);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** 只读文件头部 HEAD_SCAN_BYTES 字节。尾部可能截断多字节字符,对 title 提取无影响。 */
|
|
40
|
+
function readHead(absPath) {
|
|
41
|
+
const fd = node_fs_1.default.openSync(absPath, 'r');
|
|
42
|
+
try {
|
|
43
|
+
const buf = Buffer.alloc(HEAD_SCAN_BYTES);
|
|
44
|
+
const bytes = node_fs_1.default.readSync(fd, buf, 0, HEAD_SCAN_BYTES, 0);
|
|
45
|
+
return buf.subarray(0, bytes).toString('utf-8');
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
node_fs_1.default.closeSync(fd);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 取 HTML 的 `<title>` 文本作为该页面的方案名。
|
|
53
|
+
*
|
|
54
|
+
* - 扫描范围截到第一个 `</head>`:body 里的 `<title>` 不算——内联 SVG 图标的 a11y
|
|
55
|
+
* `<title>` 很常见,抓到就会把图标描述当成方案名。找不到 `</head>` 时退回整个窗口。
|
|
56
|
+
* - 先剔 HTML 注释,避免抓到被注释掉的 title。
|
|
57
|
+
* - 实体还原 + 空白折叠 + trim;空 title / 无 title / 读失败一律返回 undefined,
|
|
58
|
+
* 由消费侧 fallback 到 path(旧应用没有 title 时保持显示页面地址)。
|
|
59
|
+
*/
|
|
60
|
+
function extractTitle(absPath) {
|
|
61
|
+
let head;
|
|
62
|
+
try {
|
|
63
|
+
head = readHead(absPath);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const headClose = HEAD_CLOSE_RE.exec(head)?.index ?? -1;
|
|
69
|
+
const scope = (headClose >= 0 ? head.slice(0, headClose) : head).replace(COMMENT_RE, '');
|
|
70
|
+
const raw = TITLE_RE.exec(scope)?.[1];
|
|
71
|
+
if (raw === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
const text = decodeEntities(raw).replace(/\s+/g, ' ').trim();
|
|
74
|
+
return text === '' ? undefined : text;
|
|
75
|
+
}
|
|
@@ -11,7 +11,7 @@ const actions_1 = require("./actions");
|
|
|
11
11
|
var actions_2 = require("./actions");
|
|
12
12
|
Object.defineProperty(exports, "buildTosActions", { enumerable: true, get: function () { return actions_2.buildTosActions; } });
|
|
13
13
|
/**
|
|
14
|
-
* design-html 增量发布:读本地文件组 TosFileAction
|
|
14
|
+
* design-html 增量发布:读本地文件组 TosFileAction(改动含 .html 时带重算的
|
|
15
15
|
* routes.json),调后端 applyTosDiff 应用到 latest。scope 限 design_local_deploy。
|
|
16
16
|
*/
|
|
17
17
|
async function patchDesignDeploy(opts) {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.generateRoutes = generateRoutes;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const html_title_1 = require("./html-title");
|
|
4
9
|
const source_scan_1 = require("./source-scan");
|
|
5
10
|
/**
|
|
6
11
|
* .html → route path 推导(相对路径,不带 base 前缀)。rel 为 posix 相对路径。
|
|
@@ -16,8 +21,9 @@ function toRoute(rel) {
|
|
|
16
21
|
}
|
|
17
22
|
/**
|
|
18
23
|
* 扫描 projectDir 下所有 .html(套 source-scan 的 EXCLUDES),生成 routes.json 文本(含尾换行)。
|
|
19
|
-
* 每项为 { path, file }:path 为相对路径(不带 CLIENT_BASE_PATH 前缀,由消费侧拼接),
|
|
20
|
-
* file 为该路由对应的原始 .html
|
|
24
|
+
* 每项为 { path, file, name? }:path 为相对路径(不带 CLIENT_BASE_PATH 前缀,由消费侧拼接),
|
|
25
|
+
* file 为该路由对应的原始 .html 源文件,name 为该文件 `<title>` 解析出的方案名(可缺省)。
|
|
26
|
+
* 按 path 排序、同 path 取字典序靠前的 file 去重。
|
|
21
27
|
* 零 html 时输出空数组——不伪造解析不到文件的根路由。
|
|
22
28
|
*/
|
|
23
29
|
function generateRoutes(projectDir) {
|
|
@@ -27,7 +33,11 @@ function generateRoutes(projectDir) {
|
|
|
27
33
|
if (!byPath.has(entry.path))
|
|
28
34
|
byPath.set(entry.path, entry);
|
|
29
35
|
}
|
|
30
|
-
|
|
36
|
+
// title 只读去重后胜出的 file:同 path 多源(foo.html / foo/index.html)时不做无用 IO。
|
|
37
|
+
const routes = [...byPath.values()].map((entry) => {
|
|
38
|
+
const name = (0, html_title_1.extractTitle)(node_path_1.default.join(projectDir, entry.file));
|
|
39
|
+
return name === undefined ? entry : { ...entry, name };
|
|
40
|
+
});
|
|
31
41
|
return JSON.stringify(routes, null, 2) + '\n';
|
|
32
42
|
}
|
|
33
43
|
function byPathThenFile(a, b) {
|
|
@@ -7,45 +7,8 @@ exports.EXCLUDES = void 0;
|
|
|
7
7
|
exports.listSourceFiles = listSourceFiles;
|
|
8
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
-
|
|
11
|
-
exports
|
|
12
|
-
'.git',
|
|
13
|
-
'node_modules',
|
|
14
|
-
'dist',
|
|
15
|
-
'scripts',
|
|
16
|
-
'package.json',
|
|
17
|
-
'package-lock.json',
|
|
18
|
-
'pnpm-lock.yaml',
|
|
19
|
-
'yarn.lock',
|
|
20
|
-
'.gitignore',
|
|
21
|
-
'.npmrc',
|
|
22
|
-
// agent / 编辑器配置目录与指令文件:只服务本地开发,不属于应用资产。
|
|
23
|
-
// 注意 .claude/skills 在 flat layout 下是指向 ../.agents/skills 的软链,而 listSourceFiles
|
|
24
|
-
// 会 follow 软链,所以必须按 basename 把 .claude 整个排掉,否则 skills 会从软链侧被带进产物。
|
|
25
|
-
'.agent',
|
|
26
|
-
'.agents',
|
|
27
|
-
'.claude',
|
|
28
|
-
'.codex',
|
|
29
|
-
'.cursor',
|
|
30
|
-
'.gemini',
|
|
31
|
-
'.trae',
|
|
32
|
-
'.windsurf',
|
|
33
|
-
'.vscode',
|
|
34
|
-
'.idea',
|
|
35
|
-
'AGENTS.md',
|
|
36
|
-
'CLAUDE.md',
|
|
37
|
-
'GEMINI.md',
|
|
38
|
-
'skills',
|
|
39
|
-
'.env',
|
|
40
|
-
'.env.local',
|
|
41
|
-
'README.md',
|
|
42
|
-
'.DS_Store',
|
|
43
|
-
'.spark',
|
|
44
|
-
// 本地开发的临时/运行时目录:tmp 里的 .html 是草稿或中间产物,logs 是本地日志,
|
|
45
|
-
// 两者既不进产物也不该出现在 routes.json。
|
|
46
|
-
'tmp',
|
|
47
|
-
'logs',
|
|
48
|
-
]);
|
|
10
|
+
const exclude_patterns_1 = require("../../../../utils/exclude-patterns");
|
|
11
|
+
Object.defineProperty(exports, "EXCLUDES", { enumerable: true, get: function () { return exclude_patterns_1.EXCLUDES; } });
|
|
49
12
|
/**
|
|
50
13
|
* 递归列出 root 下所有文件的相对 posix 路径,跳过 EXCLUDES(任意层级 basename),
|
|
51
14
|
* 用 statSync 判定目录/文件以 follow 符号链接(含指向目录的软链),断链等无法 stat 的条目跳过。
|
|
@@ -54,7 +17,7 @@ function listSourceFiles(root) {
|
|
|
54
17
|
const out = [];
|
|
55
18
|
const walk = (dir, rel) => {
|
|
56
19
|
for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
57
|
-
if (
|
|
20
|
+
if (exclude_patterns_1.EXCLUDES.has(entry.name))
|
|
58
21
|
continue;
|
|
59
22
|
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
60
23
|
const abs = node_path_1.default.join(dir, entry.name);
|