@oj-module/cli 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/framework-dev.md +5 -0
- package/devkit/manual.md +99 -40
- package/devkit/menu-rules.md +375 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index-CLdhl3Qd.js → index-B3g19Uet.js} +76 -3
- package/shell-dist/assets/runtime.js +224 -126
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +102 -0
- package/src/__tests__/usage.test.ts +69 -0
- package/src/build.ts +20 -2
- package/src/bundle.ts +27 -1
- package/src/config.ts +16 -2
- package/src/index.ts +18 -1
- package/src/manifest-routes-derive.ts +130 -0
- package/src/preview-config.ts +12 -5
- package/src/preview.ts +31 -10
- package/src/site-base.ts +103 -0
- package/src/usage.ts +119 -0
- package/vendor/host-versions.json +3 -3
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.2.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.17",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.17",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.17",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { DerivedRoute, ModuleDefinitionLike } from "../manifest-routes-derive";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { deriveManifestRoutes, reconcileManifestRoutes } from "../manifest-routes-derive";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BDD:R5 manifest.routes 由 entry.ts 路由 handle 自动派生(单一真源,避免手工双写漂移)。
|
|
8
|
+
* - d-1:三类(main/admin/hidden)路由均派生,字段口径正确;
|
|
9
|
+
* - d-2:无 handle.category 的路由(框架/纯页面/动态段子页)跳过,不进 manifest;
|
|
10
|
+
* - d-3:既有 manifest routes 与派生结果漂移时,reconcile 以 entry.ts 为准并发警告(不阻断)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function def(routes: ModuleDefinitionLike["routes"]): ModuleDefinitionLike {
|
|
14
|
+
return { name: "wiki", routes };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("deriveManifestRoutes(entry.ts → manifest routes)", () => {
|
|
18
|
+
it("d-1 三类 category 均派生,字段口径正确(path/category/order/title_key)", () => {
|
|
19
|
+
const d = def([
|
|
20
|
+
{ path: "/space", handle: { category: "main", order: 200, title: "wiki:menu.space" } },
|
|
21
|
+
{ path: "/admin", handle: { category: "admin", order: 220, title: "wiki:menu.admin" } },
|
|
22
|
+
{ path: "/wiki/status", handle: { category: "hidden", order: 201, title: "wiki:menu.status" } },
|
|
23
|
+
]);
|
|
24
|
+
const out = deriveManifestRoutes(d);
|
|
25
|
+
expect(out).toEqual([
|
|
26
|
+
{ path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
|
|
27
|
+
{ path: "/admin", category: "admin", order: 220, title_key: "wiki:menu.admin" },
|
|
28
|
+
{ path: "/wiki/status", category: "hidden", order: 201, title_key: "wiki:menu.status" },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("d-2 无 category 的路由跳过:框架页/动态段子页/纯组件页不进 manifest", () => {
|
|
33
|
+
const d = def([
|
|
34
|
+
{ path: "/space", handle: { category: "main", order: 200, title: "wiki:menu.space" } },
|
|
35
|
+
// 动态段子页(空间成员页):不进菜单,无 category → 跳过
|
|
36
|
+
{ path: ":id/members", handle: { title: "wiki:menu.members", hideInMenu: true } },
|
|
37
|
+
// 框架/纯页面路由:无 category → 跳过
|
|
38
|
+
{ path: "/dashboard", handle: { title: "framework:menu.dashboard" } },
|
|
39
|
+
]);
|
|
40
|
+
const out = deriveManifestRoutes(d);
|
|
41
|
+
expect(out).toEqual([
|
|
42
|
+
{ path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("d-2b 缺 order 的 category 路由仍派生(order 省略,交 C2-17 契约校验报必填)", () => {
|
|
47
|
+
const d = def([
|
|
48
|
+
{ path: "/space", handle: { category: "main", title: "wiki:menu.space" } },
|
|
49
|
+
]);
|
|
50
|
+
const out = deriveManifestRoutes(d);
|
|
51
|
+
expect(out).toEqual([{ path: "/space", category: "main", title_key: "wiki:menu.space" }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("d-2c routes 缺失/空 → 派生为空数组(不抛)", () => {
|
|
55
|
+
expect(deriveManifestRoutes(def(undefined))).toEqual([]);
|
|
56
|
+
expect(deriveManifestRoutes(def([]))).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("reconcileManifestRoutes(entry.ts 为准 + 漂移警告)", () => {
|
|
61
|
+
it("d-3a 既有 routes 与派生一致:不警告,采用派生", () => {
|
|
62
|
+
const existing: DerivedRoute[] = [
|
|
63
|
+
{ path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
|
|
64
|
+
];
|
|
65
|
+
const derived: DerivedRoute[] = [
|
|
66
|
+
{ path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
|
|
67
|
+
];
|
|
68
|
+
const { routes, warnings } = reconcileManifestRoutes(existing, derived);
|
|
69
|
+
expect(routes).toEqual(derived);
|
|
70
|
+
expect(warnings).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("d-3b 既有 routes 与派生漂移:以派生为准并发警告(不阻断)", () => {
|
|
74
|
+
const existing: DerivedRoute[] = [
|
|
75
|
+
// 旧 manifest 手写的 order 与 category 与 entry.ts 不一致
|
|
76
|
+
{ path: "/space", category: "admin", order: 999, title_key: "wiki:menu.space" },
|
|
77
|
+
];
|
|
78
|
+
const derived: DerivedRoute[] = [
|
|
79
|
+
{ path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
|
|
80
|
+
];
|
|
81
|
+
const { routes, warnings } = reconcileManifestRoutes(existing, derived);
|
|
82
|
+
expect(routes).toEqual(derived); // 以 entry.ts 为准
|
|
83
|
+
expect(warnings).toHaveLength(1);
|
|
84
|
+
expect(warnings[0]).toContain("/space");
|
|
85
|
+
expect(warnings[0]).toContain("entry.ts 为准");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("d-3c 派生为空(未迁移方案未声明 category):保留既有 routes,不覆盖", () => {
|
|
89
|
+
const existing: DerivedRoute[] = [
|
|
90
|
+
{ path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
|
|
91
|
+
];
|
|
92
|
+
const { routes, warnings } = reconcileManifestRoutes(existing, []);
|
|
93
|
+
expect(routes).toEqual(existing); // 沿用既有,向后兼容
|
|
94
|
+
expect(warnings).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("d-3d 既有为空且派生为空:结果为空,无警告", () => {
|
|
98
|
+
const { routes, warnings } = reconcileManifestRoutes([], []);
|
|
99
|
+
expect(routes).toEqual([]);
|
|
100
|
+
expect(warnings).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getCommandHelp, resolveHelpRequest } from "../usage";
|
|
3
|
+
|
|
4
|
+
describe("getCommandHelp", () => {
|
|
5
|
+
it("返回命令级帮助(init)", () => {
|
|
6
|
+
const t = getCommandHelp("init");
|
|
7
|
+
expect(t).toBeDefined();
|
|
8
|
+
expect(t).toContain("ojm init");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("子命令优先:market setup 命中专属条目", () => {
|
|
12
|
+
expect(getCommandHelp("market", "setup")).toContain("ojm market setup");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("子命令无专属条目时回退到命令级(market 无 sub)", () => {
|
|
16
|
+
const t = getCommandHelp("market");
|
|
17
|
+
expect(t).toContain("ojm market");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("trust 子命令(add/revoke/confirm/ls)各自有专属条目", () => {
|
|
21
|
+
for (const sub of ["ls", "add", "revoke", "confirm"]) {
|
|
22
|
+
const t = getCommandHelp("trust", sub);
|
|
23
|
+
expect(t, `trust ${sub}`).toBeDefined();
|
|
24
|
+
expect(t).toContain(`ojm trust ${sub}`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("未知命令返回 undefined", () => {
|
|
29
|
+
expect(getCommandHelp("nope")).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("resolveHelpRequest", () => {
|
|
34
|
+
it("ojm --help → 顶层(无 cmd)", () => {
|
|
35
|
+
expect(resolveHelpRequest(["--help"])).toEqual({ cmd: undefined, sub: undefined });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ojm -h → 顶层(无 cmd)", () => {
|
|
39
|
+
expect(resolveHelpRequest(["-h"])).toEqual({ cmd: undefined, sub: undefined });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("ojm init --help → cmd=init", () => {
|
|
43
|
+
expect(resolveHelpRequest(["init", "--help"])).toEqual({ cmd: "init", sub: undefined });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("ojm trust add --help → cmd=trust sub=add", () => {
|
|
47
|
+
expect(resolveHelpRequest(["trust", "add", "--help"])).toEqual({ cmd: "trust", sub: "add" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("ojm market setup --help → cmd=market sub=setup", () => {
|
|
51
|
+
expect(resolveHelpRequest(["market", "setup", "--help"])).toEqual({ cmd: "market", sub: "setup" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("ojm help init → cmd=init", () => {
|
|
55
|
+
expect(resolveHelpRequest(["help", "init"])).toEqual({ cmd: "init", sub: undefined });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ojm help trust add → cmd=trust sub=add", () => {
|
|
59
|
+
expect(resolveHelpRequest(["help", "trust", "add"])).toEqual({ cmd: "trust", sub: "add" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ojm help → 顶层(无 cmd)", () => {
|
|
63
|
+
expect(resolveHelpRequest(["help"])).toEqual({ cmd: undefined, sub: undefined });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("非帮助调用返回 null(ojm build)", () => {
|
|
67
|
+
expect(resolveHelpRequest(["build", "--bundle"])).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/build.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Plugin as EsbuildPlugin } from "esbuild";
|
|
2
2
|
import type { Plugin } from "vite";
|
|
3
|
+
import type { RouteDefinitionLike } from "./manifest-routes-derive";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
4
5
|
import crypto from "node:crypto";
|
|
5
6
|
import fs from "node:fs";
|
|
@@ -12,6 +13,7 @@ import { build } from "vite";
|
|
|
12
13
|
import { loadModulesConfig, resolveModuleEntry } from "./config";
|
|
13
14
|
import { resolveConfigPath, resolveLayout } from "./layout";
|
|
14
15
|
import { isSharedDep, SHARED_DEPS } from "./shared-deps";
|
|
16
|
+
import { normalizeSiteBase, rewriteIndexHtml } from "./site-base";
|
|
15
17
|
import { resolveOjBin } from "./vendor";
|
|
16
18
|
import { checkSharedVersions, resolveShellDist } from "./versions";
|
|
17
19
|
|
|
@@ -138,6 +140,9 @@ export const mapTree = _fn;
|
|
|
138
140
|
export const getAllExpandedKeys = _fn;
|
|
139
141
|
export const getAppInfo = _fn;
|
|
140
142
|
export const getRedirectPath = _fn;
|
|
143
|
+
// 站点部署 base(子路径部署):stub 只需占位,元数据读取期不拼 URL
|
|
144
|
+
export const resolveSiteBase = _fn;
|
|
145
|
+
export const withSiteBase = _fn;
|
|
141
146
|
export const getYesNoOptions = _fn;
|
|
142
147
|
export const getBooleanOptions = _fn;
|
|
143
148
|
// 模块契约
|
|
@@ -387,6 +392,9 @@ export async function readModuleDefinition(entryFile: string, projectRoot: strin
|
|
|
387
392
|
version: string
|
|
388
393
|
peerRuntime?: string
|
|
389
394
|
config?: { dependencies?: string[] }
|
|
395
|
+
// R5:entry.ts 声明的路由树(含 handle.category/order/title),
|
|
396
|
+
// buildBundle 据此派生 manifest.routes(单一真源,避免手工双写漂移)
|
|
397
|
+
routes?: RouteDefinitionLike[]
|
|
390
398
|
};
|
|
391
399
|
}
|
|
392
400
|
finally {
|
|
@@ -528,12 +536,22 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
|
|
|
528
536
|
* 再由模块构建写入 modules.json 与 modules/。清场防旧哈希资产无限累积
|
|
529
537
|
* (shell 每次重构建生成新哈希文件名);devServer 热重建路径不合并——
|
|
530
538
|
* dev 的 / 与 /assets/* 直接服务 shell dist,合并产物无人消费。
|
|
539
|
+
*
|
|
540
|
+
* siteBase 非空时(站点挂载在 `/oj` 这类子路径)改写 index.html 的站内绝对
|
|
541
|
+
* URL 并注入 `window.__OJ_BASE__`(见 site-base.ts);为空则产物与既有行为
|
|
542
|
+
* 逐字节一致。
|
|
531
543
|
*/
|
|
532
|
-
function mergeShellSite(distDir: string): void {
|
|
544
|
+
function mergeShellSite(distDir: string, siteBase: string): void {
|
|
533
545
|
const shellDist = resolveShellDist();
|
|
534
546
|
fs.rmSync(distDir, { recursive: true, force: true });
|
|
535
547
|
fs.mkdirSync(path.dirname(distDir), { recursive: true });
|
|
536
548
|
fs.cpSync(shellDist, distDir, { recursive: true });
|
|
549
|
+
|
|
550
|
+
const indexPath = path.join(distDir, "index.html");
|
|
551
|
+
if (normalizeSiteBase(siteBase) !== "/") {
|
|
552
|
+
fs.writeFileSync(indexPath, rewriteIndexHtml(fs.readFileSync(indexPath, "utf-8"), siteBase));
|
|
553
|
+
console.log(`[ojm] 站点 index.html 已按 siteBase=${normalizeSiteBase(siteBase)} 重写(子路径部署)`);
|
|
554
|
+
}
|
|
537
555
|
console.log(`[ojm] 已合并宿主站点 → ${distDir}`);
|
|
538
556
|
}
|
|
539
557
|
|
|
@@ -557,7 +575,7 @@ export async function buildModules(
|
|
|
557
575
|
const baseUrl = config.baseUrl ?? "";
|
|
558
576
|
|
|
559
577
|
if (opts.mergeSite)
|
|
560
|
-
mergeShellSite(outDir);
|
|
578
|
+
mergeShellSite(outDir, config.siteBase ?? "");
|
|
561
579
|
|
|
562
580
|
// P4.5 / C4 / D12:构建前先过版本矩阵门禁,版本漂移直接拒绝
|
|
563
581
|
checkSharedVersions(projectRoot, resolveShellDist());
|
package/src/bundle.ts
CHANGED
|
@@ -10,8 +10,11 @@ import fs from "node:fs";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
|
|
12
12
|
import { parse } from "yaml";
|
|
13
|
+
import { readModuleDefinition } from "./build";
|
|
13
14
|
import { createDeterministicTgz } from "./deterministic-tar";
|
|
15
|
+
import { resolveLayout } from "./layout";
|
|
14
16
|
import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
|
|
17
|
+
import { deriveManifestRoutes, reconcileManifestRoutes } from "./manifest-routes-derive";
|
|
15
18
|
|
|
16
19
|
export interface BundleBuildResult {
|
|
17
20
|
tgz: string
|
|
@@ -37,11 +40,34 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
|
|
|
37
40
|
throw new Error(`[ojm] 方案包构建失败:${parsed.error}`);
|
|
38
41
|
}
|
|
39
42
|
const m = parsed.manifest;
|
|
43
|
+
const name = m.name as string;
|
|
44
|
+
|
|
45
|
+
// R5:manifest.routes 由 entry.ts 路由 handle 自动派生(单一真源),覆盖手工书写。
|
|
46
|
+
// 仅当 entry.ts 声明了 category 路由才覆盖;否则保留 manifest 既有 routes(未迁移方案兼容)。
|
|
47
|
+
// entry 读不出来(如第三依赖未装)时软降级:沿用既有 routes,不阻断打包。
|
|
48
|
+
const layout = resolveLayout(projectRoot);
|
|
49
|
+
const entryFile = path.join(layout.modulesSrc, name, "entry.ts");
|
|
50
|
+
if (fs.existsSync(entryFile)) {
|
|
51
|
+
try {
|
|
52
|
+
const def = await readModuleDefinition(entryFile, projectRoot);
|
|
53
|
+
const derived = deriveManifestRoutes(def);
|
|
54
|
+
const { routes, warnings } = reconcileManifestRoutes(
|
|
55
|
+
Array.isArray(m.routes) ? m.routes : [],
|
|
56
|
+
derived,
|
|
57
|
+
);
|
|
58
|
+
for (const w of warnings)
|
|
59
|
+
console.warn(w);
|
|
60
|
+
m.routes = routes;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
console.warn(`[ojm] ⚠️ 无法从 entry.ts 派生 routes(${e instanceof Error ? e.message : String(e)}),沿用 manifest.yaml 既有 routes`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
40
67
|
const violations = validateContract(m);
|
|
41
68
|
if (violations.length > 0) {
|
|
42
69
|
throw new Error(`[ojm] 方案包构建失败:manifest.yaml 契约校验未通过(${violations.length} 项违规,全量列出):\n${violations.map(x => `- ${x}`).join("\n")}`);
|
|
43
70
|
}
|
|
44
|
-
const name = m.name as string;
|
|
45
71
|
const version = m.version as string;
|
|
46
72
|
|
|
47
73
|
// 2) 文件面契约(§4.1 构建列专属执行点;manifest 内在规则已在 validateContract 全量通过)
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,19 @@ export interface ModuleConfigEntry {
|
|
|
12
12
|
export interface ModulesConfig {
|
|
13
13
|
/** 产物 URL 前缀,留空表示同源相对路径 */
|
|
14
14
|
baseUrl?: string
|
|
15
|
+
/**
|
|
16
|
+
* 站点部署子路径(如 `"/oj"`),留空 = 挂在域名根。
|
|
17
|
+
*
|
|
18
|
+
* 站点被 oj 挂到 `mounts: [{ prefix: /oj, web: dist, spa: true }]` 这类非根
|
|
19
|
+
* 前缀时**必须**配它:`ojm build` 会把全站 index.html 的站内绝对 URL
|
|
20
|
+
* (importmap / `<script src>` / `<link href>`)改写为 `/oj/...`,并注入
|
|
21
|
+
* `window.__OJ_BASE__` 供路由 basename 与模块资源解析取用。
|
|
22
|
+
* 不配则浏览器去域名根取 `/assets/*` → 404 白屏,深链刷新也匹配不到路由。
|
|
23
|
+
*
|
|
24
|
+
* 与 `baseUrl` 的区别:`baseUrl` 是**模块产物**的 URL 前缀(可指向 CDN
|
|
25
|
+
* 绝对域),`siteBase` 是**站点自身**的部署位置;两者正交,可同时使用。
|
|
26
|
+
*/
|
|
27
|
+
siteBase?: string
|
|
15
28
|
modules: ModuleConfigEntry[]
|
|
16
29
|
/**
|
|
17
30
|
* 站点 HTML 变换模块(可选):相对工程根的路径(`./html-transform.ts`)
|
|
@@ -24,8 +37,9 @@ export interface ModulesConfig {
|
|
|
24
37
|
htmlTransform?: string
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl">> = {
|
|
40
|
+
const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl" | "siteBase">> = {
|
|
28
41
|
baseUrl: "",
|
|
42
|
+
siteBase: "",
|
|
29
43
|
};
|
|
30
44
|
|
|
31
45
|
/**
|
|
@@ -34,7 +48,7 @@ const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl">> = {
|
|
|
34
48
|
* 用 tsx 真实 import,而不是正则解析——这样配置文件里可以写注释、
|
|
35
49
|
* 用变量、做条件判断,且类型错误会直接暴露(B10 的正则方案做不到)。
|
|
36
50
|
*/
|
|
37
|
-
export async function loadModulesConfig(projectRoot: string): Promise<ModulesConfig & { baseUrl: string }> {
|
|
51
|
+
export async function loadModulesConfig(projectRoot: string): Promise<ModulesConfig & { baseUrl: string, siteBase: string }> {
|
|
38
52
|
const configPath = path.join(projectRoot, "web.config.ts");
|
|
39
53
|
|
|
40
54
|
let mod: { default?: ModulesConfig };
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { devServer } from "./dev";
|
|
|
8
8
|
import { mergeManifests, printInfo } from "./info";
|
|
9
9
|
import { initProject } from "./init";
|
|
10
10
|
import { runPreview } from "./preview";
|
|
11
|
-
import { usageText } from "./usage";
|
|
11
|
+
import { getCommandHelp, resolveHelpRequest, usageText } from "./usage";
|
|
12
12
|
import { vendorCommand } from "./vendor";
|
|
13
13
|
import { readCliVersion } from "./versions";
|
|
14
14
|
|
|
@@ -25,6 +25,23 @@ function usage(): never {
|
|
|
25
25
|
async function main() {
|
|
26
26
|
const projectRoot = process.cwd();
|
|
27
27
|
|
|
28
|
+
// 帮助请求:`ojm <cmd> --help` / `ojm help <cmd>` / 顶层 `ojm --help`
|
|
29
|
+
// 必须在 switch 之前拦截,否则会真正执行子命令(如 `ojm market setup --help` 会去建 CDN)。
|
|
30
|
+
const helpReq = resolveHelpRequest();
|
|
31
|
+
if (helpReq !== null) {
|
|
32
|
+
if (!helpReq.cmd) {
|
|
33
|
+
console.log(usageText());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const text = getCommandHelp(helpReq.cmd, helpReq.sub);
|
|
37
|
+
if (text) {
|
|
38
|
+
console.log(text);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.error(`[ojm] 未知命令:${helpReq.cmd}${helpReq.sub ? ` ${helpReq.sub}` : ""}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
// 顶层 --version / -V:打印 cli 包版本(与 `ojm info` 的 cli 版本同源)
|
|
29
46
|
if (command === "--version" || command === "-V") {
|
|
30
47
|
console.log(readCliVersion());
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// R5:manifest.yaml `routes` 由 entry.ts 路由 handle 自动派生(单一真源)。
|
|
2
|
+
//
|
|
3
|
+
// 背景:0.2.15 时期 manifest.routes 由作者手工书写,且 runtime 分组还依赖
|
|
4
|
+
// `sys_solution_route` 表行(dev 下该表空 → 不分组,见上游 wiki parity 方案 R1–R6)。
|
|
5
|
+
// 0.2.16 起:category/order/title 的真源是 entry.ts 路由 handle,manifest.routes
|
|
6
|
+
// 改为 build 期从 handle 派生,杜绝「entry.ts 改了 handle 但 manifest.yaml 没同步」的漂移。
|
|
7
|
+
//
|
|
8
|
+
// 派生口径(仅取顶层一级菜单路由;children 是动态段/子页,不进 manifest routes):
|
|
9
|
+
// - handle.category ∈ {main,admin,hidden} 才纳入(无 category 的路由不进菜单,跳过)
|
|
10
|
+
// - path ← route.path
|
|
11
|
+
// - category ← handle.category
|
|
12
|
+
// - order ← handle.order(缺省不写,交 C2-17 契约校验报「必填整数」)
|
|
13
|
+
// - title_key ← handle.title(main/admin 必填且须匹配 <name>:menu.*,契约校验)
|
|
14
|
+
//
|
|
15
|
+
// 兼容:entry.ts 未声明任何 category 路由时(迁移前的旧方案),调用方保留 manifest
|
|
16
|
+
// 既有 routes,不强制覆盖——避免未迁移方案构建即坏。
|
|
17
|
+
|
|
18
|
+
/** entry.ts 路由树节点(派生只读字段,宽松结构,避免 import runtime 类型形成环) */
|
|
19
|
+
export interface RouteDefinitionLike {
|
|
20
|
+
path?: string
|
|
21
|
+
handle?: {
|
|
22
|
+
category?: "main" | "admin" | "hidden"
|
|
23
|
+
order?: number
|
|
24
|
+
title?: string
|
|
25
|
+
[key: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
children?: RouteDefinitionLike[]
|
|
28
|
+
[key: string]: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 派生出的 manifest route 契约项(字段口径见 C2-17) */
|
|
32
|
+
export interface DerivedRoute {
|
|
33
|
+
path: string
|
|
34
|
+
category: "main" | "admin" | "hidden"
|
|
35
|
+
order?: number
|
|
36
|
+
title_key?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 模块定义(只需 name + routes 子集,宽松结构) */
|
|
40
|
+
export interface ModuleDefinitionLike {
|
|
41
|
+
name: string
|
|
42
|
+
routes?: RouteDefinitionLike[]
|
|
43
|
+
[key: string]: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 从 entry.ts 模块定义派生 manifest routes(仅顶层一级菜单路由)。
|
|
48
|
+
* 不抛异常:handle 缺字段时照常产出,交由 C2-17 契约校验报出具体违规项。
|
|
49
|
+
*/
|
|
50
|
+
export function deriveManifestRoutes(definition: ModuleDefinitionLike): DerivedRoute[] {
|
|
51
|
+
const routes = Array.isArray(definition.routes) ? definition.routes : [];
|
|
52
|
+
const out: DerivedRoute[] = [];
|
|
53
|
+
for (const r of routes) {
|
|
54
|
+
if (r === null || typeof r !== "object")
|
|
55
|
+
continue;
|
|
56
|
+
const handle = (typeof r.handle === "object" && r.handle !== null) ? r.handle : undefined;
|
|
57
|
+
const category = handle?.category;
|
|
58
|
+
if (category !== "main" && category !== "admin" && category !== "hidden")
|
|
59
|
+
continue;
|
|
60
|
+
if (typeof r.path !== "string" || r.path.length === 0)
|
|
61
|
+
continue;
|
|
62
|
+
const route: DerivedRoute = { path: r.path, category };
|
|
63
|
+
if (typeof handle?.order === "number")
|
|
64
|
+
route.order = handle.order;
|
|
65
|
+
if (typeof handle?.title === "string")
|
|
66
|
+
route.title_key = handle.title;
|
|
67
|
+
out.push(route);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 归并既有 manifest routes 与 entry.ts 派生 routes,返回最终采用项 + 警告。
|
|
74
|
+
*
|
|
75
|
+
* 规则(单一真源 = entry.ts):
|
|
76
|
+
* - derived 为空(entry.ts 未声明任何 category 路由)→ 沿用 existing,不覆盖(向后兼容)
|
|
77
|
+
* - derived 非空 → 以 derived 为准;若 existing 非空且与 derived 在某 path 上漂移
|
|
78
|
+
* (category/order/title_key 任一不一致)→ 追加警告(不阻断,R5 选择项「自动生成」而非「双写+校验」)
|
|
79
|
+
*/
|
|
80
|
+
export function reconcileManifestRoutes(
|
|
81
|
+
existing: unknown[],
|
|
82
|
+
derived: DerivedRoute[],
|
|
83
|
+
): { routes: DerivedRoute[], warnings: string[] } {
|
|
84
|
+
const warnings: string[] = [];
|
|
85
|
+
if (derived.length === 0) {
|
|
86
|
+
// 未迁移方案:保留 manifest 既有 routes,等作者后续在 entry.ts 登记 category
|
|
87
|
+
return { routes: normalizeExisting(existing), warnings };
|
|
88
|
+
}
|
|
89
|
+
const existingByPath = new Map<string, DerivedRoute>();
|
|
90
|
+
for (const r of normalizeExisting(existing)) {
|
|
91
|
+
existingByPath.set(r.path, r);
|
|
92
|
+
}
|
|
93
|
+
const drift: DerivedRoute[] = [];
|
|
94
|
+
for (const d of derived) {
|
|
95
|
+
const e = existingByPath.get(d.path);
|
|
96
|
+
if (e === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
if (e.category !== d.category || e.order !== d.order || e.title_key !== d.title_key) {
|
|
99
|
+
drift.push(d);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (existing.length > 0 && drift.length > 0) {
|
|
103
|
+
warnings.push(
|
|
104
|
+
`[ojm] manifest.yaml 既有 routes 与 entry.ts 派生结果不一致,已采用 entry.ts 为准(R5 单一真源,避免手工双写漂移):\n${
|
|
105
|
+
drift.map(d => `- ${d.path}(派生 category=${d.category} order=${d.order ?? "?"} title_key=${d.title_key ?? "?"})`).join("\n")}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { routes: derived, warnings };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeExisting(existing: unknown[]): DerivedRoute[] {
|
|
112
|
+
const out: DerivedRoute[] = [];
|
|
113
|
+
for (const r of existing) {
|
|
114
|
+
if (r === null || typeof r !== "object")
|
|
115
|
+
continue;
|
|
116
|
+
const o = r as Record<string, unknown>;
|
|
117
|
+
if (typeof o.path !== "string")
|
|
118
|
+
continue;
|
|
119
|
+
const cat = o.category;
|
|
120
|
+
if (cat !== "main" && cat !== "admin" && cat !== "hidden")
|
|
121
|
+
continue;
|
|
122
|
+
const route: DerivedRoute = { path: o.path, category: cat };
|
|
123
|
+
if (typeof o.order === "number")
|
|
124
|
+
route.order = o.order;
|
|
125
|
+
if (typeof o.title_key === "string")
|
|
126
|
+
route.title_key = o.title_key;
|
|
127
|
+
out.push(route);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
package/src/preview-config.ts
CHANGED
|
@@ -30,6 +30,12 @@ export const PREVIEW_CONFIG_NAME = ".ojm-preview.yaml";
|
|
|
30
30
|
export interface DerivePreviewConfigOptions {
|
|
31
31
|
/** 全站产物目录(layout.distDir),写入 web 挂载的绝对路径 */
|
|
32
32
|
siteDir: string
|
|
33
|
+
/**
|
|
34
|
+
* web 挂载前缀(默认 `"/"`)。配了 `siteBase` 的工程,产物里 URL 全是
|
|
35
|
+
* `/oj/...`,preview 必须把站点挂在同一前缀上才能验证子路径部署
|
|
36
|
+
* (否则资源全 404,预览白屏)。
|
|
37
|
+
*/
|
|
38
|
+
prefix?: string
|
|
33
39
|
/**
|
|
34
40
|
* release 形态的后端模块根(api/dist)。给了且原 config 声明了 platform.modules 时,
|
|
35
41
|
* 把 `platform.root` 一并改写到该目录——特权模块的受控根必须与 `--api-path` 同源,
|
|
@@ -78,9 +84,9 @@ function isMountEntry(m: unknown): m is Record<string, unknown> {
|
|
|
78
84
|
return m !== null && typeof m === "object" && !Array.isArray(m);
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
/**
|
|
82
|
-
function
|
|
83
|
-
return mounts.findIndex(m => isMountEntry(m) && m.prefix ===
|
|
87
|
+
/** 找指定 prefix 的 web 挂载条目下标;没有返回 -1 */
|
|
88
|
+
function findWebMount(mounts: unknown[], prefix: string): number {
|
|
89
|
+
return mounts.findIndex(m => isMountEntry(m) && m.prefix === prefix && "web" in m);
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
/**
|
|
@@ -116,13 +122,14 @@ export function derivePreviewConfigText(configText: string, opts: DerivePreviewC
|
|
|
116
122
|
const doc = parseConfigDoc(configText, configPath);
|
|
117
123
|
|
|
118
124
|
const mounts = [...readMounts(doc, configPath)];
|
|
119
|
-
const
|
|
125
|
+
const prefix = opts.prefix ?? "/";
|
|
126
|
+
const idx = findWebMount(mounts, prefix);
|
|
120
127
|
if (idx >= 0) {
|
|
121
128
|
// 保留用户自定义键(headers 等),只接管指向与回落
|
|
122
129
|
mounts[idx] = { ...(mounts[idx] as Record<string, unknown>), web: opts.siteDir, spa: true };
|
|
123
130
|
}
|
|
124
131
|
else {
|
|
125
|
-
mounts.push({ prefix
|
|
132
|
+
mounts.push({ prefix, web: opts.siteDir, spa: true });
|
|
126
133
|
}
|
|
127
134
|
doc.mounts = mounts;
|
|
128
135
|
|
package/src/preview.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* 一律传绝对路径:oj 命令行相对 CWD、config 内相对 config 目录,混用即歧义。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import type { ModulesConfig } from "./config";
|
|
20
21
|
import type { OjProcess } from "./oj";
|
|
21
22
|
import type { PreviewConfigFiles } from "./preview-config";
|
|
22
23
|
import { execFileSync } from "node:child_process";
|
|
@@ -28,6 +29,7 @@ import { resolveConfigPath, resolveLayout } from "./layout";
|
|
|
28
29
|
import { startOj } from "./oj";
|
|
29
30
|
import { readOjApiPrefix } from "./oj-config";
|
|
30
31
|
import { hasRootApiMount, parseMounts, writePreviewConfig } from "./preview-config";
|
|
32
|
+
import { normalizeSiteBase } from "./site-base";
|
|
31
33
|
import { resolveOjBin } from "./vendor";
|
|
32
34
|
|
|
33
35
|
export interface PreviewOptions {
|
|
@@ -40,7 +42,7 @@ export interface PreviewOptions {
|
|
|
40
42
|
/** 注入桩 oj 进程;默认 startOj */
|
|
41
43
|
ojStarter?: (configPath: string, base: string, apiPath: string, extraArgs: string[]) => OjProcess
|
|
42
44
|
/** 注入桩派生 config 落盘(测试);默认 writePreviewConfig */
|
|
43
|
-
configWriter?: (projectRoot: string, opts: { siteDir: string, port?: number, apiRoot?: string }) => PreviewConfigFiles
|
|
45
|
+
configWriter?: (projectRoot: string, opts: { siteDir: string, prefix?: string, port?: number, apiRoot?: string }) => PreviewConfigFiles
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export interface PreviewHandle {
|
|
@@ -71,7 +73,11 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
|
|
|
71
73
|
|
|
72
74
|
// DR-5:htmlTransform 存在性检测(只读 web.config.ts 的字段,不 import 变换模块——
|
|
73
75
|
// 单服务下 oj 直出 HTML,钩子不生效;执行用户代码只会白付副作用)
|
|
74
|
-
await warnHtmlTransformUnsupported(projectRoot);
|
|
76
|
+
const moduleConfig = await warnHtmlTransformUnsupported(projectRoot);
|
|
77
|
+
|
|
78
|
+
// 站点部署前缀:web.config.ts 的 siteBase("{siteBase}" → 挂载 prefix "/oj")。
|
|
79
|
+
// 产物里的 URL 已按 siteBase 重写,preview 必须挂在同一前缀上,否则白屏。
|
|
80
|
+
const sitePrefix = mountPrefixOf(moduleConfig?.siteBase);
|
|
75
81
|
|
|
76
82
|
const execOj = opts.execOj ?? ((args: string[]) => execFileSync(ojBin, args, { stdio: "inherit" }));
|
|
77
83
|
|
|
@@ -81,12 +87,13 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
|
|
|
81
87
|
|
|
82
88
|
// 派生 config 在 migrate 成功后落盘:失败即退路径零残留
|
|
83
89
|
const base = readOjApiPrefix(configPath);
|
|
84
|
-
const writer = opts.configWriter ?? ((root: string, o: { siteDir: string, port?: number, apiRoot?: string }) => writePreviewConfig(root, o));
|
|
90
|
+
const writer = opts.configWriter ?? ((root: string, o: { siteDir: string, prefix?: string, port?: number, apiRoot?: string }) => writePreviewConfig(root, o));
|
|
85
91
|
// apiRoot 交给派生器:release 下 platform.root 必须指向 api/dist(与 --api-path 同源)
|
|
86
|
-
const preview = writer(projectRoot, { siteDir, port: opts.port, apiRoot: apiDist });
|
|
92
|
+
const preview = writer(projectRoot, { siteDir, prefix: sitePrefix, port: opts.port, apiRoot: apiDist });
|
|
87
93
|
|
|
88
|
-
// / api 挂载与注入的
|
|
89
|
-
|
|
94
|
+
// / api 挂载与注入的 web 挂载配对时 oj 不开 SPA 回落(oj 语义,见 devkit 手册 §13)。
|
|
95
|
+
// 仅在站点挂根时成立:web 挂到 /oj 时两者不配对,深链回落正常。
|
|
96
|
+
if (sitePrefix === "/" && hasRootApiMount(parseMounts(readFileSync(configPath, "utf-8"), configPath)))
|
|
90
97
|
console.warn("[ojm] config 的 api 挂载位于 /:与 web 挂载配对时 oj 不做 SPA 深链回落(已知限制),深链接刷新将 404。");
|
|
91
98
|
|
|
92
99
|
const starter = opts.ojStarter ?? ((cfg: string, b: string, apiPath: string, extraArgs: string[]) =>
|
|
@@ -111,7 +118,7 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
|
|
|
111
118
|
throw error;
|
|
112
119
|
}
|
|
113
120
|
console.log(`[ojm] oj 已就绪(release/js,单服务:${base} API + 静态 SPA 同进程):http://127.0.0.1:${oj.port}`);
|
|
114
|
-
console.log(`\n[ojm] preview 已启动:http://localhost:${oj.port}`);
|
|
121
|
+
console.log(`\n[ojm] preview 已启动:http://localhost:${oj.port}${sitePrefix === "/" ? "" : sitePrefix}`);
|
|
115
122
|
console.log(`[ojm] 静态由 oj web 挂载直出(spa 深链回落);派生 config ${path.basename(preview.configPath)} 为本进程临时产物,退出自动清理`);
|
|
116
123
|
|
|
117
124
|
// 退出回收:派生 config 必须随 oj 一起消亡(test/integration 断言其不存在)
|
|
@@ -150,10 +157,11 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
|
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
/**
|
|
153
|
-
* web.config.ts 配了 htmlTransform → WARN
|
|
154
|
-
*
|
|
160
|
+
* web.config.ts 配了 htmlTransform → WARN;返回已加载的配置(preview 还要读
|
|
161
|
+
* siteBase 决定挂载前缀)。加载失败(缺文件/坏配置)静默跳过:能走到 preview
|
|
162
|
+
* 的工程必然过 ojm build,config 问题会在那里先炸。
|
|
155
163
|
*/
|
|
156
|
-
async function warnHtmlTransformUnsupported(projectRoot: string): Promise<
|
|
164
|
+
async function warnHtmlTransformUnsupported(projectRoot: string): Promise<ModulesConfig | undefined> {
|
|
157
165
|
try {
|
|
158
166
|
const config = await loadModulesConfig(projectRoot);
|
|
159
167
|
if (config.htmlTransform) {
|
|
@@ -163,8 +171,21 @@ async function warnHtmlTransformUnsupported(projectRoot: string): Promise<void>
|
|
|
163
171
|
+ "见 bin/devkit/api-manual.md「静态站点与 per-route meta」。",
|
|
164
172
|
);
|
|
165
173
|
}
|
|
174
|
+
return config;
|
|
166
175
|
}
|
|
167
176
|
catch {
|
|
168
177
|
// config 加载失败:不阻塞 preview(build 已先行校验过)
|
|
178
|
+
return undefined;
|
|
169
179
|
}
|
|
170
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `siteBase`(`"/oj"` / `"/oj/"` / 空)→ oj 挂载 prefix(`"/oj"` / `"/"`)。
|
|
184
|
+
*
|
|
185
|
+
* 两者只差尾斜杠:`siteBase` 是 URL 拼接口径(恒带尾斜杠),oj 的 mount
|
|
186
|
+
* prefix 是路径口径(不带尾斜杠,见模板 config 的 `/api`)。
|
|
187
|
+
*/
|
|
188
|
+
function mountPrefixOf(siteBase: string | undefined): string {
|
|
189
|
+
const normalized = normalizeSiteBase(siteBase);
|
|
190
|
+
return normalized === "/" ? "/" : normalized.slice(0, -1);
|
|
191
|
+
}
|