@lark-apaas/miaoda-cli 0.1.36 → 0.1.38
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 +23 -0
- package/dist/cli/commands/app/index.js +81 -0
- package/dist/cli/handlers/app/index.js +6 -1
- package/dist/cli/handlers/app/init.js +10 -2
- package/dist/cli/handlers/app/migrate.js +2 -4
- package/dist/cli/handlers/app/pack.js +256 -0
- package/dist/cli/handlers/skills/sync.js +56 -31
- package/dist/config/sync-configs/design-stack.js +1 -1
- package/dist/config/sync-configs/index.js +3 -1
- package/dist/config/sync-configs/nestjs-react-fullstack.js +4 -4
- package/dist/config/sync-configs/vite-react.js +20 -0
- package/dist/services/app/pack/archive.js +35 -0
- package/dist/services/app/pack/copy-tree.js +51 -0
- package/dist/services/app/pack/download.js +52 -0
- package/dist/services/app/pack/index.js +27 -0
- package/dist/services/app/pack/inline-babel.js +91 -0
- package/dist/services/app/pack/naming.js +65 -0
- package/dist/services/app/pack/rewrite.js +80 -0
- package/dist/services/app/pack/scan.js +95 -0
- package/dist/services/app/pack/strategies.js +313 -0
- package/dist/services/app/pack/upload.js +110 -0
- package/dist/services/deploy/modern/patch/source-scan.js +3 -40
- package/dist/utils/coding-steering.js +179 -5
- package/dist/utils/dir-lock.js +103 -0
- package/dist/utils/env.js +3 -3
- package/dist/utils/exclude-patterns.js +49 -0
- package/dist/utils/file-ops.js +43 -0
- package/dist/utils/sandbox-skills.js +47 -30
- package/package.json +1 -1
- package/upgrade/templates/nestjs-react-fullstack/templates/scripts/build.sh +5 -0
- package/upgrade/templates/vite-react/templates/scripts/build.sh +61 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vite-react sync 规则(镜像 fullstack-cli 的 viteReactProfile):只同步 build.sh 一支,
|
|
4
|
+
* 回溯 public/* → dist/output/ 修复。
|
|
5
|
+
* ⚠️ build.sh 源须与 miaoda-coding 模板、fullstack-cli `templates/vite-react/build.sh` 逐字一致。
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.SYNC_CONFIG = void 0;
|
|
9
|
+
exports.SYNC_CONFIG = {
|
|
10
|
+
sync: [
|
|
11
|
+
// 覆盖 scripts/build.sh:回溯 public/* → dist/output/(同源根 /app/<appId>/*)修复
|
|
12
|
+
{
|
|
13
|
+
type: 'file',
|
|
14
|
+
from: 'scripts/build.sh',
|
|
15
|
+
to: 'scripts/build.sh',
|
|
16
|
+
overwrite: true,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
exports.default = exports.SYNC_CONFIG;
|
|
@@ -0,0 +1,35 @@
|
|
|
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('PACK_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('PACK_ARCHIVE_FAILED', `zip failed: ${err.message}`, {
|
|
32
|
+
next_actions: ['确认运行环境有 zip 命令(沙箱镜像应自带)'],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -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('PACK_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)(`pack: 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)(`pack: 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,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PACK_SUPPORTED_STACKS = exports.resolveStrategy = exports.buildStandaloneEnv = exports.uploadZip = exports.redactUrl = exports.assertValidUploadUrl = 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 upload_1 = require("./upload");
|
|
21
|
+
Object.defineProperty(exports, "assertValidUploadUrl", { enumerable: true, get: function () { return upload_1.assertValidUploadUrl; } });
|
|
22
|
+
Object.defineProperty(exports, "redactUrl", { enumerable: true, get: function () { return upload_1.redactUrl; } });
|
|
23
|
+
Object.defineProperty(exports, "uploadZip", { enumerable: true, get: function () { return upload_1.uploadZip; } });
|
|
24
|
+
var strategies_1 = require("./strategies");
|
|
25
|
+
Object.defineProperty(exports, "buildStandaloneEnv", { enumerable: true, get: function () { return strategies_1.buildStandaloneEnv; } });
|
|
26
|
+
Object.defineProperty(exports, "resolveStrategy", { enumerable: true, get: function () { return strategies_1.resolveStrategy; } });
|
|
27
|
+
Object.defineProperty(exports, "PACK_SUPPORTED_STACKS", { enumerable: true, get: function () { return strategies_1.PACK_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)(`pack: 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
|
+
}
|
|
@@ -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,95 @@
|
|
|
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
|
+
* aPaaS 存储路径特征段。命中其一才认为是「应用存储里的资源」。
|
|
58
|
+
*
|
|
59
|
+
* **为什么必须限定**:只按 `/spark/app/` 前缀匹配会把**接口地址**一并扫走 ——
|
|
60
|
+
* 真机踩过,SDK 里的 `/spark/app/<id>/runtime/api/v1/observability/logs/collect`、
|
|
61
|
+
* `.../permissions/roles` 四条接口进了下载队列,全部 404 后被报成「4 张图片下载失败」。
|
|
62
|
+
*
|
|
63
|
+
* 这三段与 `miaoda-coding` 的 `cd-elements/src/core/tos.ts` 的 `SRC_ALLOWLIST` 同一份。
|
|
64
|
+
*/
|
|
65
|
+
const STORAGE_PATH_SEGMENTS = [
|
|
66
|
+
'/runtime/api/v1/storage/object/',
|
|
67
|
+
'/aily/api/v1/feisuda/attachments/',
|
|
68
|
+
'/aily/api/v1/files/static/',
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* 模板字符串插值标记:`/spark/app/${e}/...`。压缩后的 SDK 里大量存在,它们**不是具体 URL**
|
|
72
|
+
* ——变量在运行时才求值,照原样请求必然 404。
|
|
73
|
+
*/
|
|
74
|
+
const TEMPLATE_INTERPOLATION = '${';
|
|
75
|
+
/**
|
|
76
|
+
* 扫出去重后的 URL 列表,保持首次出现的顺序(便于测试断言与日志可读)。
|
|
77
|
+
*/
|
|
78
|
+
function scanSparkAppUrls(text) {
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const m of text.matchAll(SPARK_APP_URL_RE)) {
|
|
82
|
+
if (!isSameOriginRelative(text, m.index))
|
|
83
|
+
continue;
|
|
84
|
+
const url = m[0];
|
|
85
|
+
if (url.includes(TEMPLATE_INTERPOLATION))
|
|
86
|
+
continue;
|
|
87
|
+
if (!STORAGE_PATH_SEGMENTS.some((seg) => url.includes(seg)))
|
|
88
|
+
continue;
|
|
89
|
+
if (seen.has(url))
|
|
90
|
+
continue;
|
|
91
|
+
seen.add(url);
|
|
92
|
+
out.push(url);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|