@leejung/hvigor-nav-router-plugin 1.0.0

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 ADDED
@@ -0,0 +1,259 @@
1
+ # @leejung/hvigor-nav-router-plugin
2
+
3
+ `@leejung/nav-router` 的 hvigor 构建插件。
4
+
5
+ 它负责在构建阶段自动扫描页面和弹窗入口,生成 `route_map.json`,并把模块的 `module.json5` 自动补齐 `routerMap` 配置,避免继续手工维护路由表。
6
+
7
+ ## 解决的问题
8
+
9
+ 从 `HMRouter` 迁移到 `Navigation + route_map.json` 后,项目会多出两类重复劳动:
10
+
11
+ 1. 每个模块都要维护自己的 `src/main/resources/base/profile/route_map.json`
12
+ 2. 每个模块都要在 `module.json5` 里补 `routerMap: "$profile:route_map"`
13
+
14
+ 这个插件把这两件事放进 hvigor 构建流程里自动完成。
15
+
16
+ ## 当前仓库的推荐约定
17
+
18
+ 当前仓库的主约定是:
19
+
20
+ 1. 路由名统一定义在 `features/appcommon/src/main/ets/constants/PageConstants.ets`
21
+ 2. 页面文件只保留 `@Builder export function XxxBuilder()`
22
+ 3. 不再在页面文件里额外维护路由装饰器或独立常量
23
+
24
+ 示例:
25
+
26
+ ```typescript
27
+ // features/appcommon/src/main/ets/constants/PageConstants.ets
28
+ export class PageConstants {
29
+ public static readonly Home = 'page://HomePage'
30
+ public static readonly StopServerNoticeDialog = 'dialog://StopServerNoticeDialog'
31
+ }
32
+ ```
33
+
34
+ ```typescript
35
+ // features/home/src/main/ets/pages/HomePage.ets
36
+ @Builder
37
+ export function HomePageBuilder() {
38
+ HomePage()
39
+ }
40
+ ```
41
+
42
+ 插件会优先按 `Builder` 名去中央 `PageConstants` 中查找路由名。
43
+
44
+ ## 插件做了什么
45
+
46
+ 每次构建时,插件会:
47
+
48
+ 1. 扫描模块下的 `src/main/ets/pages` 和 `src/main/ets/dialogs`
49
+ 2. 找出带 `@Builder export function XxxBuilder()` 的 `.ets` 文件
50
+ 3. 解析对应路由名
51
+ 4. 生成或更新 `src/main/resources/base/profile/route_map.json`
52
+ 5. 向 `module.json5` 注入 `"routerMap": "$profile:route_map"`
53
+
54
+ ## 路由名解析优先级
55
+
56
+ 主路径是中央 `PageConstants`,但为了兼容迁移过程,扫描器还保留了兜底能力。
57
+
58
+ 优先级从高到低:
59
+
60
+ 1. `@NavRoute({ url: ... })` 装饰器
61
+ 2. `// @NavRoute({ url: '...' })` 注释
62
+ 3. `export const NAV_ROUTE_NAME = ...`
63
+ 4. 中央 `PageConstants` 映射
64
+ 5. 按目录和文件名兜底推导
65
+
66
+ 文件名兜底规则:
67
+
68
+ - `src/main/ets/pages/HomePage.ets` -> `page://HomePage`
69
+ - `src/main/ets/dialogs/StopServerNotice.ets` -> `dialog://StopServerNotice`
70
+
71
+ ## 目录要求
72
+
73
+ 默认扫描目录:
74
+
75
+ ```text
76
+ src/main/ets/pages
77
+ src/main/ets/dialogs
78
+ ```
79
+
80
+ 默认输出文件:
81
+
82
+ ```text
83
+ src/main/resources/base/profile/route_map.json
84
+ ```
85
+
86
+ 页面文件必须满足这个条件才会被识别成路由入口:
87
+
88
+ ```typescript
89
+ @Builder
90
+ export function XxxBuilder() {
91
+ XxxPage()
92
+ }
93
+ ```
94
+
95
+ 如果没有 `@Builder export function XxxBuilder()`,插件会直接跳过该文件。
96
+
97
+ ## 在项目里的接入方式
98
+
99
+ 当前仓库不是通过 npm 包名引用,而是直接在 `hvigorfile.ts` 中引用本地源码:
100
+
101
+ HAP 模块:
102
+
103
+ ```typescript
104
+ import { hapTasks } from '@ohos/hvigor-ohos-plugin';
105
+ import { navRouterHapPlugin } from '../libs/hvigor-nav-router-plugin/src/index';
106
+
107
+ export default {
108
+ system: hapTasks,
109
+ plugins: [navRouterHapPlugin()]
110
+ }
111
+ ```
112
+
113
+ HAR 模块:
114
+
115
+ ```typescript
116
+ import { harTasks } from '@ohos/hvigor-ohos-plugin';
117
+ import { navRouterHarPlugin } from '../../libs/hvigor-nav-router-plugin/src/index';
118
+
119
+ export default {
120
+ system: harTasks,
121
+ plugins: [navRouterHarPlugin()]
122
+ }
123
+ ```
124
+
125
+ 当前仓库里的接入点包括:
126
+
127
+ - `entry/hvigorfile.ts`
128
+ - `features/appcommon/hvigorfile.ts`
129
+ - `features/home/hvigorfile.ts`
130
+ - `features/login/hvigorfile.ts`
131
+ - `features/search/hvigorfile.ts`
132
+ - `features/cartype/hvigorfile.ts`
133
+ - `features/trip/hvigorfile.ts`
134
+ - `features/mine/hvigorfile.ts`
135
+
136
+ ## 插件 API
137
+
138
+ ### `navRouterHapPlugin(config?)`
139
+
140
+ 给 HAP 模块使用。
141
+
142
+ ### `navRouterHarPlugin(config?)`
143
+
144
+ 给 HAR 模块使用。
145
+
146
+ ### `PluginConfig`
147
+
148
+ ```typescript
149
+ interface PluginConfig {
150
+ scanDirs?: string[]
151
+ routeMapPath?: string
152
+ }
153
+ ```
154
+
155
+ 字段说明:
156
+
157
+ - `scanDirs`: 自定义扫描目录,默认是 `pages + dialogs`
158
+ - `routeMapPath`: 自定义输出文件路径,默认是 `src/main/resources/base/profile/route_map.json`
159
+
160
+ 示例:
161
+
162
+ ```typescript
163
+ navRouterHarPlugin({
164
+ scanDirs: [
165
+ 'src/main/ets/pages',
166
+ 'src/main/ets/dialogs',
167
+ 'src/main/ets/sheets'
168
+ ],
169
+ routeMapPath: 'src/main/resources/base/profile/route_map.json'
170
+ })
171
+ ```
172
+
173
+ ## 生成结果示例
174
+
175
+ ```json
176
+ {
177
+ "routerMap": [
178
+ {
179
+ "name": "dialog://StopServerNoticeDialog",
180
+ "pageSourceFile": "src/main/ets/dialogs/StopServerNotice.ets",
181
+ "buildFunction": "StopServerNoticeDialogBuilder"
182
+ },
183
+ {
184
+ "name": "page://HomePage",
185
+ "pageSourceFile": "src/main/ets/pages/HomePage.ets",
186
+ "buildFunction": "HomePageBuilder"
187
+ }
188
+ ]
189
+ }
190
+ ```
191
+
192
+ 同时模块的 `module.json5` 会被补成:
193
+
194
+ ```json5
195
+ {
196
+ "module": {
197
+ "name": "home",
198
+ "type": "har",
199
+ "routerMap": "$profile:route_map"
200
+ }
201
+ }
202
+ ```
203
+
204
+ ## 与 HMRouter 插件的区别
205
+
206
+ | 项目 | HMRouter 插件 | 本插件 |
207
+ |---|---|---|
208
+ | 路由来源 | 运行时黑盒注册 | 构建期静态生成 |
209
+ | 路由表 | 框架内部维护 | `route_map.json` 可直接查看 |
210
+ | 调试方式 | 主要靠运行时排查 | 可直接核对生成产物 |
211
+ | 项目规范 | 常见是页面内分散声明 | 推荐中央 `PageConstants` |
212
+
213
+ ## 调试与排障
214
+
215
+ ### 1. 页面没进 `route_map.json`
216
+
217
+ 优先检查:
218
+
219
+ 1. 文件是否在 `pages/` 或 `dialogs/` 目录下
220
+ 2. 是否存在顶层 `@Builder export function XxxBuilder()`
221
+ 3. `PageConstants` 是否有对应路由常量
222
+ 4. Builder 名和路由名是否能一一对应
223
+
224
+ ### 2. 改了插件源码但构建还在跑旧逻辑
225
+
226
+ `src/index.ts` 已经在每次取插件实例前主动清理:
227
+
228
+ - `./plugin`
229
+ - `./scanner`
230
+ - `./ast-utils`
231
+
232
+ 对应的 `require.cache`。
233
+
234
+ 这样 hvigor daemon 即使复用进程,也会重新加载磁盘上的插件源码。
235
+
236
+ ### 3. 改了 `PageConstants` 但生成结果没更新
237
+
238
+ 扫描器在每次 `scanRouteAnnotations(moduleDir)` 开头都会清理该模块的中央路由缓存,避免 daemon 增量构建拿到过期映射。
239
+
240
+ ### 4. 想强制确认产物
241
+
242
+ 直接检查各模块生成的:
243
+
244
+ ```text
245
+ src/main/resources/base/profile/route_map.json
246
+ ```
247
+
248
+ ## 最小接入清单
249
+
250
+ 给一个新模块接入时,只要做这几步:
251
+
252
+ 1. 在模块 `hvigorfile.ts` 注册插件
253
+ 2. 在 `PageConstants` 里补路由常量
254
+ 3. 在页面文件里提供 `@Builder export function XxxBuilder()`
255
+ 4. 构建一次,确认生成了 `route_map.json`
256
+
257
+ ## 相关文档
258
+
259
+ - [nav-router README](../nav-router/README.md)
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@leejung/hvigor-nav-router-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Hvigor build plugin for @leejung/nav-router - auto-generates route_map.json from @NavRoute annotations",
5
+ "main": "src/index.ts",
6
+ "keywords": [
7
+ "harmonyos",
8
+ "hvigor",
9
+ "router",
10
+ "navigation",
11
+ "plugin",
12
+ "nav-router"
13
+ ],
14
+ "author": "leejung",
15
+ "license": "Apache-2.0",
16
+ "dependencies": {
17
+ "ts-morph": "^23.0.0"
18
+ },
19
+ "peerDependencies": {
20
+ "@ohos/hvigor": ">=4.0.0",
21
+ "@ohos/hvigor-ohos-plugin": ">=4.0.0"
22
+ }
23
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * @leejung/hvigor-nav-router-plugin — AST 工具
3
+ *
4
+ * 基于 ts-morph 提供常量解析、import 追踪等能力。
5
+ * 参考 @hadss/hmrouter-plugin 的 TsAstUtil 实现。
6
+ */
7
+
8
+ import { Project, SourceFile, SyntaxKind, Node } from 'ts-morph';
9
+ import * as path from 'path';
10
+ import * as fs from 'fs';
11
+
12
+ /** 全局共享的 ts-morph Project 实例(避免重复创建) */
13
+ let sharedProject: Project | undefined;
14
+
15
+ /** 获取或创建共享的 ts-morph Project */
16
+ export function getProject(): Project {
17
+ if (!sharedProject) {
18
+ sharedProject = new Project({
19
+ compilerOptions: {
20
+ allowJs: true,
21
+ noEmit: true,
22
+ experimentalDecorators: true,
23
+ },
24
+ skipAddingFilesFromTsConfig: true,
25
+ skipFileDependencyResolution: true,
26
+ });
27
+ }
28
+ return sharedProject;
29
+ }
30
+
31
+ /** 重置共享 Project(构建完成后清理) */
32
+ export function resetProject(): void {
33
+ sharedProject = undefined;
34
+ }
35
+
36
+ /** 将路径分隔符统一为正斜杠 */
37
+ export function normalizeSlashes(filePath: string): string {
38
+ return filePath.replace(/\\/g, '/');
39
+ }
40
+
41
+ /**
42
+ * import 映射表条目
43
+ * key: 导入的名称(如 'PageConstants')
44
+ * value: { moduleSpecifier, isDefault }
45
+ */
46
+ export interface ImportEntry {
47
+ moduleSpecifier: string;
48
+ isDefault: boolean;
49
+ }
50
+
51
+ /** 构建文件的 import 映射表 */
52
+ export function buildImportMap(sourceFile: SourceFile): Map<string, ImportEntry> {
53
+ const importMap = new Map<string, ImportEntry>();
54
+ const importDecls = sourceFile.getImportDeclarations();
55
+ for (const decl of importDecls) {
56
+ const moduleSpecifier = decl.getModuleSpecifierValue();
57
+ const defaultImport = decl.getDefaultImport();
58
+ if (defaultImport) {
59
+ importMap.set(defaultImport.getText(), { moduleSpecifier, isDefault: true });
60
+ }
61
+ const namedImports = decl.getNamedImports();
62
+ for (const named of namedImports) {
63
+ const name = named.getAliasNode()?.getText() ?? named.getName();
64
+ importMap.set(name, { moduleSpecifier, isDefault: false });
65
+ }
66
+ }
67
+ return importMap;
68
+ }
69
+
70
+ /**
71
+ * 从源文件中解析常量值(字符串字面量)
72
+ *
73
+ * 支持两种形式:
74
+ * 1. 顶级 const: `export const FOO = 'xxx'`
75
+ * 2. 类的静态属性: `export class Foo { static Bar = 'xxx' }`
76
+ */
77
+ export function parseConstantValue(
78
+ sourceFile: SourceFile,
79
+ variableName: string,
80
+ propertyName?: string
81
+ ): string | undefined {
82
+ if (propertyName) {
83
+ // 解析类静态属性: PageConstants.Home
84
+ const classDecl = sourceFile.getClass(variableName);
85
+ if (!classDecl) {
86
+ return undefined;
87
+ }
88
+ const prop = classDecl.getStaticProperty(propertyName);
89
+ if (!prop || !Node.isPropertyDeclaration(prop)) {
90
+ return undefined;
91
+ }
92
+ const initializer = prop.getInitializer();
93
+ if (!initializer || !Node.isStringLiteral(initializer)) {
94
+ return undefined;
95
+ }
96
+ return initializer.getLiteralValue();
97
+ }
98
+ // 解析顶级常量: export const FOO = 'xxx'
99
+ const varDecl = sourceFile.getVariableDeclaration(variableName);
100
+ if (!varDecl) {
101
+ return undefined;
102
+ }
103
+ const initializer = varDecl.getInitializer();
104
+ if (!initializer || !Node.isStringLiteral(initializer)) {
105
+ return undefined;
106
+ }
107
+ return initializer.getLiteralValue();
108
+ }
109
+
110
+ /** 已解析的常量值缓存: Map<filePath + '::' + key, value> */
111
+ const constantCache = new Map<string, string>();
112
+
113
+ /** 清除常量缓存 */
114
+ export function clearConstantCache(): void {
115
+ constantCache.clear();
116
+ }
117
+
118
+ /**
119
+ * 解析跨文件常量引用
120
+ *
121
+ * @param importEntry 从 importMap 中取得的导入条目
122
+ * @param variableName 变量名(如 'PageConstants')
123
+ * @param propertyName 属性名(如 'Home'),对顶级常量为 undefined
124
+ * @param currentFileDir 当前文件所在目录
125
+ * @param moduleDir 当前模块根目录
126
+ */
127
+ export function resolveConstantAcrossFiles(
128
+ importEntry: ImportEntry,
129
+ variableName: string,
130
+ propertyName: string | undefined,
131
+ currentFileDir: string,
132
+ moduleDir: string
133
+ ): string | undefined {
134
+ const cacheKey = `${importEntry.moduleSpecifier}::${variableName}${propertyName ? '.' + propertyName : ''}`;
135
+ const cached = constantCache.get(cacheKey);
136
+ if (cached !== undefined) {
137
+ return cached;
138
+ }
139
+
140
+ const resolvedPath = resolveModuleSpecifier(importEntry.moduleSpecifier, currentFileDir, moduleDir);
141
+ if (!resolvedPath) {
142
+ return undefined;
143
+ }
144
+
145
+ const project = getProject();
146
+ let sf = project.getSourceFile(resolvedPath);
147
+ if (!sf) {
148
+ if (!fs.existsSync(resolvedPath)) {
149
+ return undefined;
150
+ }
151
+ sf = project.addSourceFileAtPath(resolvedPath);
152
+ }
153
+
154
+ const value = parseConstantValue(sf, variableName, propertyName);
155
+ if (value !== undefined) {
156
+ constantCache.set(cacheKey, value);
157
+ }
158
+ return value;
159
+ }
160
+
161
+ /**
162
+ * 解析 module specifier 到实际文件路径
163
+ *
164
+ * 1. 相对路径 (./xxx, ../xxx) → 直接解析
165
+ * 2. 模块名 (appcommon, @life/zcommon) → 通过 oh_modules 查找
166
+ */
167
+ function resolveModuleSpecifier(specifier: string, currentFileDir: string, moduleDir: string): string | undefined {
168
+ if (specifier.startsWith('.')) {
169
+ // 相对路径
170
+ return resolveLocalFile(path.resolve(currentFileDir, specifier));
171
+ }
172
+
173
+ // 模块引用 — 在 oh_modules 中查找
174
+ // 查找顺序: moduleDir/oh_modules → 逐级向上
175
+ const ohModulePaths = [
176
+ path.join(moduleDir, 'oh_modules', specifier),
177
+ ];
178
+
179
+ for (const ohModulePath of ohModulePaths) {
180
+ if (!fs.existsSync(ohModulePath)) {
181
+ continue;
182
+ }
183
+
184
+ // 尝试读取模块的 Index.ets 或 oh-package.json5 中的 main
185
+ const indexFile = resolveLocalFile(path.join(ohModulePath, 'Index'));
186
+ if (indexFile) {
187
+ return indexFile;
188
+ }
189
+
190
+ // 尝试 src/main/ets 目录下的 Index
191
+ const srcIndex = resolveLocalFile(path.join(ohModulePath, 'src', 'main', 'ets', 'Index'));
192
+ if (srcIndex) {
193
+ return srcIndex;
194
+ }
195
+ }
196
+
197
+ return undefined;
198
+ }
199
+
200
+ /** 尝试解析文件路径(支持省略 .ets/.ts 后缀) */
201
+ function resolveLocalFile(basePath: string): string | undefined {
202
+ const extensions = ['', '.ets', '.ts', '.js'];
203
+ for (const ext of extensions) {
204
+ const fullPath = basePath + ext;
205
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
206
+ return fullPath;
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ /**
213
+ * 从桶导出文件(Index.ets)追踪实际导出源
214
+ *
215
+ * 如果 Index.ets 包含 `export { PageConstants } from './constants/PageConstants'`,
216
+ * 则追踪到 `./constants/PageConstants.ets` 并在其中解析常量。
217
+ */
218
+ export function resolveReExport(
219
+ indexFile: SourceFile,
220
+ exportName: string,
221
+ indexFileDir: string
222
+ ): SourceFile | undefined {
223
+ const project = getProject();
224
+ const exportDecls = indexFile.getExportDeclarations();
225
+ for (const exportDecl of exportDecls) {
226
+ const specifier = exportDecl.getModuleSpecifierValue();
227
+ if (!specifier) {
228
+ continue;
229
+ }
230
+ const resolvedPath = resolveLocalFile(path.resolve(indexFileDir, specifier));
231
+ if (!resolvedPath) {
232
+ continue;
233
+ }
234
+
235
+ let sf = project.getSourceFile(resolvedPath);
236
+ if (!sf) {
237
+ if (!fs.existsSync(resolvedPath)) {
238
+ continue;
239
+ }
240
+ sf = project.addSourceFileAtPath(resolvedPath);
241
+ }
242
+
243
+ const namedExports = exportDecl.getNamedExports();
244
+ if (namedExports.length === 0) {
245
+ if (sf.getClass(exportName) || sf.getVariableDeclaration(exportName)) {
246
+ return sf;
247
+ }
248
+ const nestedSource = resolveReExport(sf, exportName, path.dirname(resolvedPath));
249
+ if (nestedSource) {
250
+ return nestedSource;
251
+ }
252
+ continue;
253
+ }
254
+
255
+ for (const named of namedExports) {
256
+ const name = named.getAliasNode()?.getText() ?? named.getName();
257
+ if (name === exportName) {
258
+ return sf;
259
+ }
260
+ }
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ /**
266
+ * 完整的跨文件常量解析(含桶导出追踪)
267
+ */
268
+ export function resolveConstantFull(
269
+ importEntry: ImportEntry,
270
+ variableName: string,
271
+ propertyName: string | undefined,
272
+ currentFileDir: string,
273
+ moduleDir: string
274
+ ): string | undefined {
275
+ const cacheKey = `${importEntry.moduleSpecifier}::${variableName}${propertyName ? '.' + propertyName : ''}`;
276
+ const cached = constantCache.get(cacheKey);
277
+ if (cached !== undefined) {
278
+ return cached;
279
+ }
280
+
281
+ const resolvedPath = resolveModuleSpecifier(importEntry.moduleSpecifier, currentFileDir, moduleDir);
282
+ if (!resolvedPath) {
283
+ return undefined;
284
+ }
285
+
286
+ const project = getProject();
287
+ let sf = project.getSourceFile(resolvedPath);
288
+ if (!sf) {
289
+ if (!fs.existsSync(resolvedPath)) {
290
+ return undefined;
291
+ }
292
+ sf = project.addSourceFileAtPath(resolvedPath);
293
+ }
294
+
295
+ // 先尝试直接在入口文件解析
296
+ let value = parseConstantValue(sf, variableName, propertyName);
297
+ if (value !== undefined) {
298
+ constantCache.set(cacheKey, value);
299
+ return value;
300
+ }
301
+
302
+ // 尝试追踪 re-export
303
+ const reExportSource = resolveReExport(sf, variableName, path.dirname(resolvedPath));
304
+ if (reExportSource) {
305
+ value = parseConstantValue(reExportSource, variableName, propertyName);
306
+ if (value !== undefined) {
307
+ constantCache.set(cacheKey, value);
308
+ return value;
309
+ }
310
+ }
311
+
312
+ return undefined;
313
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @leejung/hvigor-nav-router-plugin
3
+ *
4
+ * Hvigor 构建插件,为 @leejung/nav-router 自动生成 route_map.json。
5
+ *
6
+ * Hvigor worker 可能会复用 require 缓存,导致本地插件源码修改后仍执行旧逻辑。
7
+ * 这里在每次取插件实例前主动清理本插件模块缓存,确保总是使用磁盘上的最新源码。
8
+ */
9
+
10
+ import type { HvigorPlugin } from '@ohos/hvigor';
11
+ import type { PluginConfig, RouteMapEntry, RouteMapFile } from './types';
12
+
13
+ interface PluginModule {
14
+ navRouterHapPlugin(config?: PluginConfig): HvigorPlugin;
15
+ navRouterHarPlugin(config?: PluginConfig): HvigorPlugin;
16
+ }
17
+
18
+ interface RuntimeRequire {
19
+ (id: string): unknown;
20
+ cache?: Record<string, unknown>;
21
+ resolve(id: string): string;
22
+ }
23
+
24
+ declare const require: RuntimeRequire;
25
+
26
+ function loadPluginModule(): PluginModule {
27
+ const moduleIds = ['./plugin', './scanner', './ast-utils'];
28
+ for (const moduleId of moduleIds) {
29
+ const resolved = require.resolve(moduleId);
30
+ if (require.cache && Object.prototype.hasOwnProperty.call(require.cache, resolved)) {
31
+ delete require.cache[resolved];
32
+ }
33
+ }
34
+ return require('./plugin') as PluginModule;
35
+ }
36
+
37
+ export function navRouterHapPlugin(config?: PluginConfig): HvigorPlugin {
38
+ return loadPluginModule().navRouterHapPlugin(config);
39
+ }
40
+
41
+ export function navRouterHarPlugin(config?: PluginConfig): HvigorPlugin {
42
+ return loadPluginModule().navRouterHarPlugin(config);
43
+ }
44
+
45
+ export type { PluginConfig, RouteMapEntry, RouteMapFile };
package/src/plugin.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @leejung/hvigor-nav-router-plugin — 核心插件逻辑
3
+ *
4
+ * 在 apply() 阶段(PreBuild 之前)完成:
5
+ * 1. 扫描 @Builder export function 注解,生成 route_map_<module>.json
6
+ * 2. 自动向 module.json5 注入 routerMap 字段
7
+ *
8
+ * 为什么在 apply() 而非 registerTask() 中扫描:
9
+ * module.routerMap = "$profile:xxx" 在 PreBuild 阶段会硬校验文件存在性,
10
+ * 与 HMRouter 使用 metadata.resource(软引用,不校验)不同。
11
+ * 在 apply() 中完成扫描+写入,PreBuild 时文件已就绪,无需种子文件。
12
+ *
13
+ * 为什么每个模块用独立文件名(route_map_<module>.json):
14
+ * HAR 资源合并到 entry HAP 时,同名文件会产生 "defined repeatedly" 警告。
15
+ * 独立文件名避免跨模块命名冲突。
16
+ */
17
+
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { HvigorNode, HvigorPlugin } from '@ohos/hvigor';
21
+ import { OhosPluginId, OhosHapContext, OhosHarContext } from '@ohos/hvigor-ohos-plugin';
22
+ import { scanRouteAnnotations } from './scanner';
23
+ import { PluginConfig, RouteMapEntry, RouteMapFile } from './types';
24
+
25
+ /** 生成模块专属的 route_map 文件路径 */
26
+ function getRouteMapPath(moduleDir: string, moduleName: string, config?: PluginConfig): string {
27
+ if (config?.routeMapPath) {
28
+ return path.join(moduleDir, config.routeMapPath);
29
+ }
30
+ const fileName = `route_map_${moduleName}.json`;
31
+ return path.join(moduleDir, 'src', 'main', 'resources', 'base', 'profile', fileName);
32
+ }
33
+
34
+ /** 将路由条目写入 route_map 文件,内容未变化时跳过写入 */
35
+ function writeRouteMap(outputPath: string, entries: RouteMapEntry[]): void {
36
+ const routeMapFile: RouteMapFile = { routerMap: entries };
37
+ const nextContent = JSON.stringify(routeMapFile, null, 2) + '\n';
38
+
39
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
40
+
41
+ const currentContent = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
42
+ if (currentContent !== nextContent) {
43
+ fs.writeFileSync(outputPath, nextContent, 'utf8');
44
+ console.info(`[NavRouterPlugin] 已同步 ${entries.length} 条路由 -> ${outputPath}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * 创建 hvigor 插件实例。
50
+ *
51
+ * @param ohosPluginId OhosPluginId.OHOS_HAP_PLUGIN 或 OhosPluginId.OHOS_HAR_PLUGIN
52
+ * @param config 插件配置
53
+ */
54
+ function createNavRouterPlugin(ohosPluginId: string, config?: PluginConfig): HvigorPlugin {
55
+ return {
56
+ pluginId: '@leejung/hvigor-nav-router-plugin',
57
+
58
+ apply(node: HvigorNode): void {
59
+ const context = node.getContext(ohosPluginId) as OhosHapContext | OhosHarContext;
60
+ if (!context) {
61
+ console.warn(`[NavRouterPlugin] 无法获取模块上下文 (${ohosPluginId}),跳过 ${node.getNodeName()}`);
62
+ return;
63
+ }
64
+
65
+ const moduleName = node.getNodeName();
66
+ const moduleDir = path.resolve(node.getNodePath());
67
+ const routeMapPath = getRouteMapPath(moduleDir, moduleName, config);
68
+ const profileRef = `$profile:route_map_${moduleName}`;
69
+
70
+ // apply 阶段直接完成扫描+写入(PreBuild 之前)
71
+ const entries = scanRouteAnnotations(moduleDir, config);
72
+ writeRouteMap(routeMapPath, entries);
73
+
74
+ // 向 module.json5 注入 routerMap 引用(幂等操作)
75
+ context.targets((_target) => {
76
+ const moduleJsonOpt = context.getModuleJsonOpt();
77
+ if (moduleJsonOpt?.module) {
78
+ if (moduleJsonOpt.module['routerMap'] !== profileRef) {
79
+ moduleJsonOpt.module['routerMap'] = profileRef;
80
+ context.setModuleJsonOpt(moduleJsonOpt);
81
+ console.info(`[NavRouterPlugin] 已注入 module.json5 routerMap = "${profileRef}" (${moduleName})`);
82
+ }
83
+ }
84
+ });
85
+ },
86
+ };
87
+ }
88
+
89
+ /** 供 HAP 模块(entry)的 hvigorfile.ts 使用 */
90
+ export function navRouterHapPlugin(config?: PluginConfig): HvigorPlugin {
91
+ return createNavRouterPlugin(OhosPluginId.OHOS_HAP_PLUGIN, config);
92
+ }
93
+
94
+ /** 供 HAR 模块(features/*)的 hvigorfile.ts 使用 */
95
+ export function navRouterHarPlugin(config?: PluginConfig): HvigorPlugin {
96
+ return createNavRouterPlugin(OhosPluginId.OHOS_HAR_PLUGIN, config);
97
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,479 @@
1
+ /**
2
+ * @leejung/hvigor-nav-router-plugin — AST 路由扫描器
3
+ *
4
+ * 基于 ts-morph 扫描 .ets 源文件中的路由声明,
5
+ * 提取路由信息并解析常量引用,生成 RouteMapEntry 列表。
6
+ *
7
+ * 当前仓库的主方案是:
8
+ *
9
+ * 1. 路由名由 `PageConstants` 统一导出
10
+ * 2. 页面文件只保留 `@Builder export function XxxBuilder()`
11
+ * 3. 插件优先按 Builder 名在中央常量表中找路由,找不到再按目录规则兜底
12
+ *
13
+ * 保留对 `@NavRoute({ url: ... })` / `NAV_ROUTE_NAME` 的解析能力,仅作为兼容兜底。
14
+ * ArkTS 当前不允许在 `@Builder` 函数上挂自定义装饰器,因此业务代码不使用该写法。
15
+ *
16
+ * 路由名获取优先级:
17
+ * 1. `@NavRoute({ url: ... })` 装饰器(保留解析能力,支持字符串字面量和常量引用)
18
+ * 2. `// @NavRoute({ url: '...' })` 注释(向后兼容)
19
+ * 3. `export const NAV_ROUTE_NAME = ...` 常量(兼容旧写法)
20
+ * 4. `PageConstants` 中央常量映射
21
+ * 5. 文件路径推导(pages/ → page://,dialogs/ → dialog://)
22
+ *
23
+ * 参考 @hadss/hmrouter-plugin 的 HMRouterAnalyzer 实现。
24
+ */
25
+
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+ import {
29
+ SyntaxKind,
30
+ Node,
31
+ SourceFile,
32
+ Decorator,
33
+ ObjectLiteralExpression,
34
+ PropertyAssignment,
35
+ } from 'ts-morph';
36
+ import { PluginConfig, RouteMapEntry } from './types';
37
+ import {
38
+ getProject,
39
+ normalizeSlashes,
40
+ buildImportMap,
41
+ parseConstantValue,
42
+ resolveConstantFull,
43
+ ImportEntry,
44
+ } from './ast-utils';
45
+
46
+ const centralRouteCache = new Map<string, Map<string, string>>();
47
+
48
+ /** 递归遍历目录,收集所有 .ets 文件 */
49
+ function walkEtsFiles(dirPath: string): string[] {
50
+ if (!fs.existsSync(dirPath)) {
51
+ return [];
52
+ }
53
+ const result: string[] = [];
54
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
55
+ for (const entry of entries) {
56
+ const fullPath = path.join(dirPath, entry.name);
57
+ if (entry.isDirectory()) {
58
+ result.push(...walkEtsFiles(fullPath));
59
+ } else if (entry.isFile() && entry.name.endsWith('.ets')) {
60
+ result.push(fullPath);
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ /** 从文件路径和 Builder 名推导默认路由名 */
67
+ function buildDefaultRouteName(relativeSourceFile: string, buildFunction: string): string {
68
+ const normalized = normalizeSlashes(relativeSourceFile);
69
+ const builderBaseName = buildFunction.endsWith('Builder')
70
+ ? buildFunction.slice(0, -'Builder'.length)
71
+ : buildFunction;
72
+ const baseName = builderBaseName || path.basename(normalized, '.ets');
73
+ const prefix = normalized.includes('/dialogs/') ? 'dialog://' : 'page://';
74
+ return prefix + baseName;
75
+ }
76
+
77
+ function buildRouteBaseName(buildFunction: string): string {
78
+ return buildFunction.endsWith('Builder')
79
+ ? buildFunction.slice(0, -'Builder'.length)
80
+ : buildFunction;
81
+ }
82
+
83
+ function getPageConstantsCandidates(moduleDir: string): string[] {
84
+ const candidates = [
85
+ path.join(moduleDir, 'src', 'main', 'ets', 'constants', 'PageConstants.ets'),
86
+ path.resolve(moduleDir, '..', 'appcommon', 'src', 'main', 'ets', 'constants', 'PageConstants.ets'),
87
+ path.resolve(moduleDir, '..', '..', 'features', 'appcommon', 'src', 'main', 'ets', 'constants', 'PageConstants.ets'),
88
+ path.join(moduleDir, 'oh_modules', 'appcommon', 'src', 'main', 'ets', 'constants', 'PageConstants.ets'),
89
+ ];
90
+ return Array.from(new Set(candidates));
91
+ }
92
+
93
+ function loadCentralRouteMap(moduleDir: string): Map<string, string> {
94
+ const cached = centralRouteCache.get(moduleDir);
95
+ if (cached) {
96
+ return cached;
97
+ }
98
+
99
+ const routeMap = new Map<string, string>();
100
+ const project = getProject();
101
+ const candidates = getPageConstantsCandidates(moduleDir);
102
+ for (const candidate of candidates) {
103
+ if (!fs.existsSync(candidate)) {
104
+ continue;
105
+ }
106
+
107
+ let sourceFile = project.getSourceFile(candidate);
108
+ if (!sourceFile) {
109
+ sourceFile = project.addSourceFileAtPath(candidate);
110
+ }
111
+
112
+ const pageConstantsClass = sourceFile.getClass('PageConstants');
113
+ if (!pageConstantsClass) {
114
+ continue;
115
+ }
116
+
117
+ const staticProperties = pageConstantsClass.getStaticProperties();
118
+ for (const property of staticProperties) {
119
+ if (!Node.isPropertyDeclaration(property)) {
120
+ continue;
121
+ }
122
+ const propertyName = property.getName();
123
+ const initializer = property.getInitializer();
124
+ if (!initializer || !Node.isStringLiteral(initializer)) {
125
+ continue;
126
+ }
127
+ const routeName = initializer.getLiteralValue();
128
+ const match = routeName.match(/^[a-z]+:\/\/(.+)$/);
129
+ if (!match) {
130
+ continue;
131
+ }
132
+ const routeBaseName = match[1];
133
+ if (propertyName && !routeMap.has(propertyName)) {
134
+ routeMap.set(propertyName, routeName);
135
+ }
136
+ if (!routeMap.has(routeBaseName)) {
137
+ routeMap.set(routeBaseName, routeName);
138
+ }
139
+ }
140
+ }
141
+
142
+ centralRouteCache.set(moduleDir, routeMap);
143
+ return routeMap;
144
+ }
145
+
146
+ function resolveCentralRouteName(buildFunction: string, moduleDir: string): string | undefined {
147
+ const routeBaseName = buildRouteBaseName(buildFunction);
148
+ if (!routeBaseName) {
149
+ return undefined;
150
+ }
151
+ return loadCentralRouteMap(moduleDir).get(routeBaseName);
152
+ }
153
+
154
+ /** 检查路由表是否存在重复,发现重复时抛出错误 */
155
+ function ensureNoDuplicate(entries: RouteMapEntry[], key: keyof RouteMapEntry, moduleDir: string): void {
156
+ const seen = new Map<string, RouteMapEntry>();
157
+ for (const entry of entries) {
158
+ const value = entry[key];
159
+ if (!value) {
160
+ continue;
161
+ }
162
+ const previous = seen.get(value);
163
+ if (previous) {
164
+ throw new Error(
165
+ `[NavRouterPlugin] 发现重复的 ${key} "${value}" (模块: ${moduleDir})\n` +
166
+ ` - ${previous.pageSourceFile}\n` +
167
+ ` - ${entry.pageSourceFile}`
168
+ );
169
+ }
170
+ seen.set(value, entry);
171
+ }
172
+ }
173
+
174
+ // ---- 正则兜底(用于 // @NavRoute 注释和 NAV_ROUTE_NAME) ----
175
+
176
+ /** 匹配 @Builder + export function XxxBuilder( */
177
+ const BUILDER_FUNCTION_RE = /@Builder[\s\r\n]*export function\s+([A-Za-z0-9_]*Builder)\s*\(/m;
178
+
179
+ /** 匹配 // @NavRoute({ url: '...' }) 注释(向后兼容,仅匹配字符串字面量) */
180
+ const NAV_ROUTE_COMMENT_RE = /\/\/\s*@NavRoute\s*\(\s*\{\s*url\s*:\s*['"]([^'"]+)['"]/m;
181
+
182
+ /** 匹配 export const NAV_ROUTE_NAME = '...'(向后兼容) */
183
+ const ROUTE_NAME_CONST_RE = /export const NAV_ROUTE_NAME\s*=\s*['"]([^'"]+)['"]/m;
184
+ /** 匹配 export const NAV_ROUTE_NAME = xxx; 并提取完整右值表达式 */
185
+ const ROUTE_NAME_EXPR_RE = /export const NAV_ROUTE_NAME\s*=\s*([^;\r\n]+)/m;
186
+
187
+
188
+ // ---- AST 扫描核心逻辑 ----
189
+
190
+ /**
191
+ * 在 MissingDeclaration 节点中查找 @NavRoute 装饰器
192
+ *
193
+ * ArkTS 的 @Builder export function 在 ts-morph 中被解析为 MissingDeclaration,
194
+ * @NavRoute 装饰器绑定在 @Builder 函数上:
195
+ *
196
+ * @NavRoute({ url: PageConstants.Home })
197
+ * @Builder
198
+ * export function HomePageBuilder() { ... }
199
+ *
200
+ * 两个装饰器在同一个 MissingDeclaration 节点上,可通过 getDecorators() 获取。
201
+ */
202
+ function findNavRouteDecorator(missingDecl: Node): Decorator | undefined {
203
+ const decorableNode = missingDecl as Node & {
204
+ getDecorators?: () => Decorator[];
205
+ };
206
+ if (typeof decorableNode.getDecorators !== 'function') {
207
+ return undefined;
208
+ }
209
+ const decorators = decorableNode.getDecorators();
210
+ for (const decorator of decorators) {
211
+ if (decorator.getName() === 'NavRoute') {
212
+ return decorator;
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * 从 @NavRoute 装饰器中提取 url 值
220
+ *
221
+ * 支持的形式:
222
+ * - @NavRoute({ url: 'page://HomePage' }) → 字符串字面量
223
+ * - @NavRoute({ url: PageConstants.Home }) → 类静态属性引用
224
+ * - @NavRoute({ url: HOME_URL }) → 顶级常量引用
225
+ */
226
+ function extractUrlFromDecorator(
227
+ decorator: Decorator,
228
+ sourceFile: SourceFile,
229
+ importMap: Map<string, ImportEntry>,
230
+ moduleDir: string
231
+ ): string | undefined {
232
+ const args = decorator.getArguments();
233
+ if (args.length === 0) {
234
+ return undefined;
235
+ }
236
+
237
+ const firstArg = args[0];
238
+ if (!Node.isObjectLiteralExpression(firstArg)) {
239
+ return undefined;
240
+ }
241
+
242
+ const urlProp = (firstArg as ObjectLiteralExpression).getProperty('url');
243
+ if (!urlProp || !Node.isPropertyAssignment(urlProp)) {
244
+ return undefined;
245
+ }
246
+
247
+ const initializer = (urlProp as PropertyAssignment).getInitializer();
248
+ if (!initializer) {
249
+ return undefined;
250
+ }
251
+
252
+ // Case 1: 字符串字面量
253
+ if (Node.isStringLiteral(initializer)) {
254
+ return initializer.getLiteralValue();
255
+ }
256
+
257
+ // Case 2: 属性访问 (PageConstants.Home)
258
+ if (Node.isPropertyAccessExpression(initializer)) {
259
+ const objectName = initializer.getExpression().getText();
260
+ const propName = initializer.getName();
261
+ return resolveIdentifierValue(objectName, propName, sourceFile, importMap, moduleDir);
262
+ }
263
+
264
+ // Case 3: 标识符 (HOME_URL)
265
+ if (Node.isIdentifier(initializer)) {
266
+ const varName = initializer.getText();
267
+ return resolveIdentifierValue(varName, undefined, sourceFile, importMap, moduleDir);
268
+ }
269
+
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * 解析标识符/属性访问引用的实际字符串值
275
+ */
276
+ function resolveIdentifierValue(
277
+ variableName: string,
278
+ propertyName: string | undefined,
279
+ sourceFile: SourceFile,
280
+ importMap: Map<string, ImportEntry>,
281
+ moduleDir: string
282
+ ): string | undefined {
283
+ // 先尝试在当前文件中解析
284
+ const localValue = parseConstantValue(sourceFile, variableName, propertyName);
285
+ if (localValue !== undefined) {
286
+ return localValue;
287
+ }
288
+
289
+ // 通过 import 链跨文件解析
290
+ const importEntry = importMap.get(variableName);
291
+ if (!importEntry) {
292
+ return undefined;
293
+ }
294
+
295
+ const currentFileDir = path.dirname(sourceFile.getFilePath());
296
+ return resolveConstantFull(importEntry, variableName, propertyName, currentFileDir, moduleDir);
297
+ }
298
+
299
+ /**
300
+ * 从源码文本中的 NAV_ROUTE_NAME 右值表达式解析路由名
301
+ *
302
+ * 支持:
303
+ * - 'page://HomePage'
304
+ * - PageConstants.Home
305
+ * - HOME_URL
306
+ */
307
+ function resolveRouteExpression(
308
+ expressionText: string,
309
+ sourceFile: SourceFile,
310
+ importMap: Map<string, ImportEntry>,
311
+ moduleDir: string
312
+ ): string | undefined {
313
+ const trimmed = expressionText.trim();
314
+ const stringMatch = trimmed.match(/^['"]([^'"]+)['"]$/);
315
+ if (stringMatch) {
316
+ return stringMatch[1];
317
+ }
318
+
319
+ const propertyMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
320
+ if (propertyMatch) {
321
+ return resolveIdentifierValue(propertyMatch[1], propertyMatch[2], sourceFile, importMap, moduleDir);
322
+ }
323
+
324
+ const identifierMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)$/);
325
+ if (identifierMatch) {
326
+ return resolveIdentifierValue(identifierMatch[1], undefined, sourceFile, importMap, moduleDir);
327
+ }
328
+
329
+ return undefined;
330
+ }
331
+
332
+ /**
333
+ * 从源文件中提取 @Builder export function XxxBuilder 的名称
334
+ */
335
+ function findBuilderFunction(sourceFile: SourceFile, relativeSourceFile: string): string | undefined {
336
+ // ts-morph 对 ArkTS 的 @Builder 装饰器识别有限,使用正则更可靠
337
+ const content = sourceFile.getFullText();
338
+ const matches = Array.from(content.matchAll(new RegExp(BUILDER_FUNCTION_RE.source, 'g')));
339
+ if (matches.length === 0) {
340
+ return undefined;
341
+ }
342
+
343
+ const expectedBuilderName = `${path.basename(relativeSourceFile, '.ets')}Builder`;
344
+ const exactMatch = matches.find((match) => match[1] === expectedBuilderName);
345
+ return exactMatch?.[1] ?? matches[0][1];
346
+ }
347
+
348
+ /**
349
+ * 扫描单个 .ets 文件,提取路由信息
350
+ */
351
+ function scanSingleFile(
352
+ etsFilePath: string,
353
+ moduleDir: string
354
+ ): RouteMapEntry | undefined {
355
+ const project = getProject();
356
+
357
+ // 读取文件内容
358
+ const content = fs.readFileSync(etsFilePath, 'utf8');
359
+
360
+ // 必须有 @Builder export function XxxBuilder 才是路由页面
361
+ if (!content.match(BUILDER_FUNCTION_RE)) {
362
+ return undefined;
363
+ }
364
+ const relativeSourceFile = normalizeSlashes(path.relative(moduleDir, etsFilePath));
365
+
366
+ // 添加到 ts-morph project
367
+ let sourceFile = project.getSourceFile(etsFilePath);
368
+ if (!sourceFile) {
369
+ sourceFile = project.addSourceFileAtPath(etsFilePath);
370
+ }
371
+
372
+ const buildFunction = findBuilderFunction(sourceFile, relativeSourceFile);
373
+ if (!buildFunction) {
374
+ return undefined;
375
+ }
376
+
377
+ // 构建 import 映射
378
+ const importMap = buildImportMap(sourceFile);
379
+
380
+ // 优先级 1: 查找 @NavRoute 装饰器(AST 方式)
381
+ let routeName: string | undefined;
382
+
383
+ const missingDecls = sourceFile.getChildrenOfKind(SyntaxKind.MissingDeclaration);
384
+ for (const missingDecl of missingDecls) {
385
+ const decorator = findNavRouteDecorator(missingDecl);
386
+ if (decorator) {
387
+ routeName = extractUrlFromDecorator(decorator, sourceFile, importMap, moduleDir);
388
+ break;
389
+ }
390
+ }
391
+
392
+ // 优先级 2: // @NavRoute 注释(向后兼容)
393
+ if (!routeName) {
394
+ const commentMatch = content.match(NAV_ROUTE_COMMENT_RE);
395
+ if (commentMatch) {
396
+ routeName = commentMatch[1];
397
+ }
398
+ }
399
+
400
+ // 优先级 3: export const NAV_ROUTE_NAME = ...(AST 解析,支持常量引用)
401
+ if (!routeName) {
402
+ const varDecl = sourceFile.getVariableDeclaration('NAV_ROUTE_NAME');
403
+ if (varDecl) {
404
+ const init = varDecl.getInitializer();
405
+ if (init) {
406
+ if (Node.isStringLiteral(init)) {
407
+ routeName = init.getLiteralValue();
408
+ } else if (Node.isPropertyAccessExpression(init)) {
409
+ const objName = init.getExpression().getText();
410
+ const propName = init.getName();
411
+ routeName = resolveIdentifierValue(objName, propName, sourceFile, importMap, moduleDir);
412
+ } else if (Node.isIdentifier(init)) {
413
+ routeName = resolveIdentifierValue(init.getText(), undefined, sourceFile, importMap, moduleDir);
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ // 优先级 3a: NAV_ROUTE_NAME 文本兜底(适配 ArkTS 场景下 ts-morph 取不到变量声明)
420
+ if (!routeName) {
421
+ const exprMatch = content.match(ROUTE_NAME_EXPR_RE);
422
+ if (exprMatch) {
423
+ routeName = resolveRouteExpression(exprMatch[1], sourceFile, importMap, moduleDir);
424
+ }
425
+ }
426
+
427
+ // 优先级 3b: NAV_ROUTE_NAME 正则兜底(仅字符串字面量)
428
+ if (!routeName) {
429
+ const constMatch = content.match(ROUTE_NAME_CONST_RE);
430
+ if (constMatch) {
431
+ routeName = constMatch[1];
432
+ }
433
+ }
434
+
435
+ // 优先级 4: 文件路径推导
436
+ if (!routeName) {
437
+ routeName = resolveCentralRouteName(buildFunction, moduleDir)
438
+ ?? buildDefaultRouteName(relativeSourceFile, buildFunction);
439
+ }
440
+
441
+ return { name: routeName, pageSourceFile: relativeSourceFile, buildFunction };
442
+ }
443
+
444
+
445
+ /**
446
+ * 扫描指定模块目录,返回排序后的路由表条目列表。
447
+ *
448
+ * @param moduleDir 模块根目录绝对路径
449
+ * @param config 插件配置
450
+ */
451
+ export function scanRouteAnnotations(moduleDir: string, config?: PluginConfig): RouteMapEntry[] {
452
+ centralRouteCache.delete(moduleDir);
453
+ const scanDirs = config?.scanDirs ?? [
454
+ path.join('src', 'main', 'ets', 'pages'),
455
+ path.join('src', 'main', 'ets', 'dialogs'),
456
+ ];
457
+
458
+ const entries: RouteMapEntry[] = [];
459
+
460
+ for (const scanDir of scanDirs) {
461
+ const absoluteScanDir = path.join(moduleDir, scanDir);
462
+ const etsFiles = walkEtsFiles(absoluteScanDir);
463
+
464
+ for (const etsFile of etsFiles) {
465
+ const entry = scanSingleFile(etsFile, moduleDir);
466
+ if (entry) {
467
+ entries.push(entry);
468
+ }
469
+ }
470
+ }
471
+
472
+ // 按路由名排序,保证输出稳定
473
+ entries.sort((a, b) => a.name.localeCompare(b.name));
474
+
475
+ ensureNoDuplicate(entries, 'name', moduleDir);
476
+ ensureNoDuplicate(entries, 'buildFunction', moduleDir);
477
+
478
+ return entries;
479
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @leejung/hvigor-nav-router-plugin — 类型定义
3
+ */
4
+
5
+ /** 一条路由表条目,对应 route_map.json 中 routerMap 数组的单个元素 */
6
+ export interface RouteMapEntry {
7
+ /** 路由名,如 'page://HomePage' 或 'dialog://StopServerNoticeDialog' */
8
+ name: string;
9
+ /** 源文件相对于模块根目录的路径,如 'src/main/ets/pages/HomePage.ets' */
10
+ pageSourceFile: string;
11
+ /** 页面入口 Builder 函数名,如 'HomePageBuilder' */
12
+ buildFunction: string;
13
+ }
14
+
15
+ /** route_map.json 文件的根结构 */
16
+ export interface RouteMapFile {
17
+ routerMap: RouteMapEntry[];
18
+ }
19
+
20
+ /** 插件配置项 */
21
+ export interface PluginConfig {
22
+ /**
23
+ * 扫描目录列表(相对于模块根目录)。
24
+ * 默认值:['src/main/ets/pages', 'src/main/ets/dialogs']
25
+ */
26
+ scanDirs?: string[];
27
+ /**
28
+ * route_map.json 输出路径(相对于模块根目录)。
29
+ * 默认值:'src/main/resources/base/profile/route_map.json'
30
+ */
31
+ routeMapPath?: string;
32
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "module": "commonjs",
5
+ "lib": ["ES2017"],
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "declaration": true,
11
+ "declarationDir": "dist",
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src/**/*.ts"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }