@uni-helper/vite-plugin-uni-pages 0.5.0 → 0.6.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.
Files changed (4) hide show
  1. package/README.md +550 -54
  2. package/dist/index.d.mts +621 -282
  3. package/dist/index.mjs +2690 -468
  4. package/package.json +13 -8
package/dist/index.mjs CHANGED
@@ -1,25 +1,458 @@
1
1
  import { createRequire } from "node:module";
2
+ import fs, { existsSync, mkdirSync, realpathSync } from "node:fs";
2
3
  import path, { dirname, extname, join, resolve } from "node:path";
3
4
  import process from "node:process";
4
5
  import chokidar from "chokidar";
5
6
  import MagicString from "magic-string";
6
7
  import { createLogger, normalizePath } from "vite";
8
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { pathToFileURL } from "node:url";
10
+ import { bundleRequire } from "bundle-require";
11
+ import { loadConfig } from "unconfig";
12
+ import { Buffer } from "node:buffer";
13
+ import babelGenerate from "@babel/generator";
14
+ import * as t from "@babel/types";
15
+ import { callExpression, identifier, removePropertiesDeep } from "@babel/types";
16
+ import { babelParse, isCallOf, walkAST } from "ast-kit";
17
+ import { transform } from "esbuild";
18
+ import Debug from "debug";
7
19
  import { platform } from "@uni-helper/uni-env";
8
20
  import { CommentArray, parse, stringify } from "comment-json";
9
- import Debug from "debug";
10
21
  import groupBy from "lodash.groupby";
11
- import { loadConfig } from "unconfig";
12
- import fs, { existsSync } from "node:fs";
13
- import { mkdir, readFile, writeFile } from "node:fs/promises";
14
22
  import lockfile from "proper-lockfile";
15
- import { globSync } from "tinyglobby";
23
+ import { escapePath, globSync, isDynamicPattern } from "tinyglobby";
24
+ import picomatch from "picomatch";
16
25
  import vm from "node:vm";
17
- import babelGenerate from "@babel/generator";
18
- import * as t from "@babel/types";
19
26
  import { parse as parse$1 } from "@vue/compiler-sfc";
20
- import { babelParse, isCallOf } from "ast-kit";
21
27
  import * as ts from "typescript";
22
28
  import writeFileAtomic from "write-file-atomic";
29
+ //#region src/config-source.ts
30
+ /** 判断导入表达式能否交给 esbuild 收集依赖,未匹配的目标由运行时助手解析 */
31
+ function isBundledSpecifier(node) {
32
+ if (!node) return false;
33
+ if (node.type === "StringLiteral") return true;
34
+ if (node.type === "TemplateLiteral") return node.expressions.length === 0 || /^\.\.?\//.test(node.quasis[0].value.cooked ?? "");
35
+ if (node.type === "BinaryExpression" && node.operator === "+") {
36
+ let first = node.left;
37
+ while (first.type === "BinaryExpression" && first.operator === "+") first = first.left;
38
+ return first.type === "StringLiteral" && /^\.\.?\//.test(first.value);
39
+ }
40
+ return false;
41
+ }
42
+ /**
43
+ * 将脚本转换为绑定原文件位置的 JavaScript
44
+ *
45
+ * @description 注入运行时解析助手;可打包的调用及用户声明的局部 require 保持原语义
46
+ * @param code 当前模块的源码文本
47
+ * @param filename 当前模块的原始绝对路径,作为该模块运行时解析的基准
48
+ * @param runtime 本轮生成的公共辅助模块路径,供转换后的代码导入
49
+ * @returns 转换后的 JavaScript,包含映射到原模块的内联源码映射
50
+ */
51
+ async function transformConfigSource(code, filename, runtime) {
52
+ let prefix = "__uni_pages_source";
53
+ while (code.includes(prefix)) prefix += "_";
54
+ const requireName = `${prefix}_require`;
55
+ const metaName = `${prefix}_meta`;
56
+ const importName = `${prefix}_import`;
57
+ const extension = path.extname(filename).slice(1);
58
+ const loader = extension === "jsx" || extension === "tsx" ? extension : /^(?:c|m)?js$/.test(extension) ? "js" : "ts";
59
+ const result = await transform(code, {
60
+ loader,
61
+ sourcefile: filename,
62
+ sourcemap: "external",
63
+ target: "node22",
64
+ define: {
65
+ "require": requireName,
66
+ "import.meta": metaName,
67
+ "__dirname": JSON.stringify(path.dirname(filename)),
68
+ "__filename": JSON.stringify(filename)
69
+ }
70
+ });
71
+ const ast = babelParse(result.code, "js", { allowReturnOutsideFunction: true });
72
+ walkAST(ast, { enter(node) {
73
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === requireName && isBundledSpecifier(node.arguments[0])) node.callee.name = "require";
74
+ if (node.type === "ImportExpression" && !isBundledSpecifier(node.source)) this.replace(callExpression(identifier(importName), [node.source, ...node.options ? [node.options] : []]));
75
+ } });
76
+ const url = JSON.stringify(pathToFileURL(filename).href);
77
+ const helpers = babelParse(`
78
+ import { createRequire as ${prefix}_createRequire, createMeta as ${prefix}_createMeta, importFrom as ${prefix}_importFrom } from ${JSON.stringify(runtime)};
79
+ const ${requireName} = ${prefix}_createRequire(${JSON.stringify(filename)});
80
+ const ${metaName} = ${prefix}_createMeta(${JSON.stringify(filename)}, ${url}, ${JSON.stringify(path.dirname(filename))});
81
+ const ${importName} = (specifier, options) => ${prefix}_importFrom(specifier, ${url}, options);
82
+ `, "js");
83
+ removePropertiesDeep(helpers);
84
+ ast.body.unshift(...helpers.body);
85
+ const output = babelGenerate(ast, {
86
+ sourceMaps: true,
87
+ sourceFileName: filename,
88
+ inputSourceMap: JSON.parse(result.map)
89
+ }, result.code);
90
+ return `${output.code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(output.map)).toString("base64")}`;
91
+ }
92
+ /**
93
+ * 生成配置执行所需的公共辅助模块源码
94
+ *
95
+ * @description 调用方将返回文本写入本轮 runtime.mjs;各助手按传入的源位置解析,动态目标沿用 Node 缓存
96
+ * @returns 可供配置打包使用的 ESM 源码
97
+ */
98
+ function configRuntimeSource() {
99
+ return `
100
+ export { createRequire } from 'node:module';
101
+ import { resolve } from ${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("import-meta-resolve")).href)};
102
+ export function createMeta(filename, url, dirname) {
103
+ return Object.assign(Object.create(null), { filename, url, dirname, main: false, resolve: specifier => resolve(\`\${specifier}\`, url) });
104
+ }
105
+ export async function importFrom(specifier, url, options) {
106
+ return import(resolve(\`\${specifier}\`, url), options);
107
+ }
108
+ `;
109
+ }
110
+ //#endregion
111
+ //#region src/logger.ts
112
+ /**
113
+ * 调试日志工具
114
+ * 基于 debug 包做分级日志输出
115
+ * 通过环境变量 DEBUG=vite-plugin-uni-pages:* 开启
116
+ */
117
+ const debug = {
118
+ /** HMR 相关日志 */
119
+ hmr: Debug("vite-plugin-uni-pages:hmr"),
120
+ /** 配置项相关日志 */
121
+ options: Debug("vite-plugin-uni-pages:options"),
122
+ /** 主包页面扫描日志 */
123
+ pages: Debug("vite-plugin-uni-pages:pages"),
124
+ /** 分包页面扫描日志 */
125
+ subPages: Debug("vite-plugin-uni-pages:subPages"),
126
+ /** 错误日志 */
127
+ error: Debug("vite-plugin-uni-pages:error"),
128
+ /** 缓存相关日志 */
129
+ cache: Debug("vite-plugin-uni-pages:cache"),
130
+ /** 声明文件生成日志 */
131
+ declaration: Debug("vite-plugin-uni-pages:declaration"),
132
+ /** definePage 宏解析日志 */
133
+ definePage: Debug("vite-plugin-uni-pages:definePage")
134
+ };
135
+ //#endregion
136
+ //#region src/config-loader.ts
137
+ const scriptExtensions = /\.[cm]?[jt]sx?$/;
138
+ /** 目录恢复监听接受的文件类型;已知配置入口与依赖不受此限制 */
139
+ const configFileExtensions = /\.(?:[cm]?[jt]sx?|json)$/;
140
+ /** 省略扩展名时 esbuild 会依次尝试的后缀,用于列举恢复候选文件 */
141
+ const candidateExtensions = [
142
+ "",
143
+ ".ts",
144
+ ".tsx",
145
+ ".js",
146
+ ".jsx",
147
+ ".mts",
148
+ ".cts",
149
+ ".mjs",
150
+ ".cjs",
151
+ ".json"
152
+ ];
153
+ /** TS 约定里 `./x.js` 指向的就是 `./x.ts`,恢复候选也得列上 */
154
+ const typescriptAliases = {
155
+ ".cjs": [".cts"],
156
+ ".js": [".ts", ".tsx"],
157
+ ".mjs": [".mts"]
158
+ };
159
+ function isNodeModulesPath(filepath) {
160
+ return normalizePath(filepath).split("/").includes("node_modules");
161
+ }
162
+ /** JSON 文件可能带 BOM 保存,JSON.parse 不接受它 */
163
+ function stripBom(code) {
164
+ return code.charCodeAt(0) === 65279 ? code.slice(1) : code;
165
+ }
166
+ /**
167
+ * 由失败的导入说明符推出可能出现的文件
168
+ *
169
+ * @description `./missing/title` 这种缺扩展名的写法,创建 `title.ts`
170
+ * 或 `title/index.ts` 都算恢复,所以两种形态都要列出来
171
+ */
172
+ function recoveryCandidatesFor(specifier, importerDirectory) {
173
+ const base = path.resolve(importerDirectory, specifier);
174
+ const candidates = candidateExtensions.map((extension) => `${base}${extension}`);
175
+ for (const extension of candidateExtensions.slice(1)) candidates.push(path.join(base, `index${extension}`));
176
+ for (const [extension, aliases] of Object.entries(typescriptAliases)) if (base.endsWith(extension)) for (const alias of aliases) candidates.push(`${base.slice(0, -extension.length)}${alias}`);
177
+ return candidates;
178
+ }
179
+ /** 将已存在和暂时缺失的文件统一到同一绝对路径 */
180
+ function normalizeConfigPath(filepath, root) {
181
+ const absolute = path.resolve(root, normalizePath(filepath));
182
+ let parent = absolute;
183
+ while (!existsSync(parent) && path.dirname(parent) !== parent) parent = path.dirname(parent);
184
+ try {
185
+ return normalizePath(path.join(realpathSync.native(parent), path.relative(parent, absolute)));
186
+ } catch {
187
+ return normalizePath(absolute);
188
+ }
189
+ }
190
+ /** 返回可用于监听缺失文件恢复的现存祖先目录 */
191
+ function configWatchDirectory(filepath) {
192
+ let directory = path.dirname(filepath);
193
+ while (!existsSync(directory) && path.dirname(directory) !== directory) directory = path.dirname(directory);
194
+ return normalizePath(directory);
195
+ }
196
+ /**
197
+ * 判断路径是否位于加载器专用产物目录内
198
+ *
199
+ * @param filepath 待判断的绝对路径,应已通过 {@link normalizeConfigPath} 归一化
200
+ * @param directory 专用产物目录的绝对路径;目录本身也算产物
201
+ * @returns 位于目录内时返回 true,相邻前缀或父路径返回 false
202
+ */
203
+ function isConfigLoaderArtifact(filepath, directory) {
204
+ const relative = path.relative(directory, filepath);
205
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
206
+ }
207
+ /**
208
+ * 页面配置加载器
209
+ *
210
+ * @description 按配置源顺序发现并加载首个有效配置,返回配置对象及依赖路径;加载失败时记录恢复路径并抛出错误
211
+ */
212
+ var PagesConfigLoader = class {
213
+ root;
214
+ /** 装载失败时仍需监听重建的入口与已尝试路径 */
215
+ recoveryPaths = /* @__PURE__ */ new Set();
216
+ /** 这次失败中具体缺哪个文件;优先按它判断,命中时不看目录 */
217
+ recoveryCandidates = /* @__PURE__ */ new Set();
218
+ /** 目录导入的恢复范围,覆盖 package.json 和自定义入口 */
219
+ recoveryDirectories = /* @__PURE__ */ new Set();
220
+ knownSources = /* @__PURE__ */ new Set();
221
+ /** 临时工作目录的物理路径,每轮加载创建独占子目录,输入索引与事件过滤排除此目录 */
222
+ artifactDirectory;
223
+ /** 最近一次成功加载的入口及其依赖,失败恢复以它为基准 */
224
+ lastSources = [];
225
+ lastDependencies = [];
226
+ /** 最近一次加载尝试的监听输入;失败时也保留可恢复路径 */
227
+ currentWatchInput = {
228
+ sources: [],
229
+ dependencies: [],
230
+ recoveryCandidates: [],
231
+ recoveryDirectories: []
232
+ };
233
+ constructor(root) {
234
+ this.root = root;
235
+ this.artifactDirectory = normalizeConfigPath(path.join(root, "node_modules/.cache/uni-pages"), root);
236
+ }
237
+ /** 监听输入是一份只读快照,调用方不需要理解加载器的恢复状态 */
238
+ get watchInput() {
239
+ return this.currentWatchInput;
240
+ }
241
+ /**
242
+ * 按来源顺序加载首个有效配置
243
+ *
244
+ * @description 内置 parser 使用转换后的文本;自定义 parser 接收原入口路径并自行读取
245
+ * @returns 配置对象、已加载入口及参与监听的依赖路径;没有有效配置时配置对象为空
246
+ * @throws 配置读取、转换、解析或执行失败时抛出错误
247
+ */
248
+ async load(sources) {
249
+ this.recoveryCandidates.clear();
250
+ this.recoveryDirectories.clear();
251
+ const dependencies = /* @__PURE__ */ new Set();
252
+ const attemptedPaths = /* @__PURE__ */ new Set();
253
+ const normalize = (filepath) => normalizeConfigPath(filepath, this.root);
254
+ const adapted = sources.map((source) => ({
255
+ ...source,
256
+ transform: void 0,
257
+ parser: async (filepath) => {
258
+ attemptedPaths.add(normalize(filepath));
259
+ if (typeof source.parser === "function") {
260
+ if (source.transform) await source.transform(await readFile(filepath, "utf8"), filepath);
261
+ return source.parser(filepath);
262
+ }
263
+ let code = await readFile(filepath, "utf8");
264
+ if (source.transform) code = await source.transform(code, filepath) || code;
265
+ const parser = source.parser || "auto";
266
+ if (parser === "json" || path.extname(filepath) === ".json") return JSON.parse(stripBom(code));
267
+ if (parser === "auto") try {
268
+ return JSON.parse(stripBom(code));
269
+ } catch {}
270
+ return this.loadScript(filepath, code, dependencies, attemptedPaths);
271
+ }
272
+ }));
273
+ try {
274
+ const result = await loadConfig({
275
+ cwd: this.root,
276
+ sources: adapted,
277
+ defaults: {}
278
+ });
279
+ this.recoveryPaths.clear();
280
+ this.recoveryCandidates.clear();
281
+ this.recoveryDirectories.clear();
282
+ for (const source of result.sources) this.knownSources.add(normalize(source));
283
+ for (const source of this.knownSources) if (!existsSync(source)) dependencies.add(source);
284
+ const sources = result.sources.map(normalize);
285
+ const dependencyPaths = [...dependencies];
286
+ this.lastSources = sources;
287
+ this.lastDependencies = dependencyPaths;
288
+ this.currentWatchInput = {
289
+ sources,
290
+ dependencies: dependencyPaths,
291
+ recoveryCandidates: [],
292
+ recoveryDirectories: []
293
+ };
294
+ return {
295
+ config: result.config,
296
+ sources,
297
+ dependencies: dependencyPaths
298
+ };
299
+ } catch (error) {
300
+ this.recoveryPaths.clear();
301
+ for (const filepath of attemptedPaths) this.recoveryPaths.add(filepath);
302
+ this.currentWatchInput = {
303
+ sources: [...this.lastSources],
304
+ dependencies: [.../* @__PURE__ */ new Set([...this.lastDependencies, ...this.recoveryPaths])],
305
+ recoveryCandidates: [...this.recoveryCandidates],
306
+ recoveryDirectories: [...this.recoveryDirectories]
307
+ };
308
+ throw error;
309
+ }
310
+ }
311
+ /**
312
+ * 打包并执行脚本配置,返回其导出的配置对象
313
+ *
314
+ * @param source 原配置入口路径,作为入口导入和运行时相对路径的解析基准
315
+ * @param code 经过可选 transform 处理的入口文本,依赖模块按各自路径读取
316
+ * @param dependencies 接收成功打包涉及的本地依赖路径
317
+ * @param attempted 接收本轮尝试读取的本地源路径,供执行失败后恢复监听
318
+ */
319
+ async loadScript(source, code, dependencies, attempted) {
320
+ const sourcePath = normalizeConfigPath(source, this.root);
321
+ const importDirectories = /* @__PURE__ */ new Set();
322
+ const recoveryCandidates = /* @__PURE__ */ new Set();
323
+ const root = this.root;
324
+ const cacheDirectory = this.artifactDirectory;
325
+ mkdirSync(cacheDirectory, { recursive: true });
326
+ const bundleDirectory = await mkdtemp(path.join(cacheDirectory, "load-"));
327
+ const runtime = normalizePath(path.join(bundleDirectory, "runtime.mjs"));
328
+ const entry = scriptExtensions.test(source) ? source : path.join(bundleDirectory, "entry.ts");
329
+ try {
330
+ await writeFile(runtime, configRuntimeSource());
331
+ const { mod, dependencies: inputs } = await bundleRequire({
332
+ filepath: entry,
333
+ cwd: this.root,
334
+ format: "esm",
335
+ tsconfig: false,
336
+ preserveTemporaryFile: true,
337
+ getOutputFile: () => path.join(bundleDirectory, "config.mjs"),
338
+ notExternal: [/.*/],
339
+ esbuildOptions: {
340
+ logLevel: "silent",
341
+ target: "node22",
342
+ conditions: [],
343
+ mainFields: ["main"],
344
+ resolveExtensions: [
345
+ ".ts",
346
+ ".tsx",
347
+ ".mts",
348
+ ".cts",
349
+ ".js",
350
+ ".jsx",
351
+ ".mjs",
352
+ ".cjs",
353
+ ".json"
354
+ ],
355
+ banner: { js: `import { createRequire as __uniPagesCreateRequire } from 'node:module'; const require = __uniPagesCreateRequire(import.meta.url);` },
356
+ plugins: [{
357
+ name: "uni-pages-config-inputs",
358
+ setup(build) {
359
+ build.onResolve({ filter: /.*/ }, async (args) => {
360
+ if (args.kind === "entry-point") return { path: path.resolve(sourcePath) };
361
+ if (args.pluginData?.resolvingPackage) return;
362
+ if (/^(?:node:|file:|data:)/.test(args.path)) return {
363
+ path: args.path,
364
+ external: true
365
+ };
366
+ if (args.path.startsWith(".") || path.isAbsolute(args.path)) {
367
+ const directory = normalizeConfigPath(path.resolve(args.resolveDir, args.path), root);
368
+ if (!isNodeModulesPath(directory) && !isConfigLoaderArtifact(directory, cacheDirectory)) {
369
+ importDirectories.add(directory);
370
+ for (const candidate of recoveryCandidatesFor(args.path, args.resolveDir)) recoveryCandidates.add(normalizeConfigPath(candidate, root));
371
+ }
372
+ return;
373
+ }
374
+ const result = await build.resolve(args.path, {
375
+ importer: args.importer,
376
+ resolveDir: args.resolveDir,
377
+ kind: args.kind,
378
+ namespace: args.namespace,
379
+ pluginData: { resolvingPackage: true }
380
+ });
381
+ if (result.errors.length) return {
382
+ errors: result.errors,
383
+ warnings: result.warnings
384
+ };
385
+ if (path.isAbsolute(result.path) && (!isNodeModulesPath(result.path) || /\.(?:[cm]?tsx?|json)$/.test(result.path))) return { path: result.path };
386
+ return {
387
+ path: !(args.kind === "require-call" || args.kind === "require-resolve") && path.isAbsolute(result.path) ? pathToFileURL(result.path).href : result.path,
388
+ external: true
389
+ };
390
+ });
391
+ build.onLoad({
392
+ filter: /.*/,
393
+ namespace: "file"
394
+ }, async (args) => {
395
+ const file = normalizeConfigPath(args.path, root);
396
+ if (file === runtime) return {
397
+ contents: await readFile(runtime, "utf8"),
398
+ loader: "js"
399
+ };
400
+ if (!isNodeModulesPath(file) && !isConfigLoaderArtifact(file, cacheDirectory)) attempted.add(file);
401
+ if (file !== sourcePath && !scriptExtensions.test(file)) return;
402
+ return {
403
+ contents: await transformConfigSource(file === sourcePath ? code : await readFile(args.path, "utf8"), path.resolve(file), runtime),
404
+ loader: "js"
405
+ };
406
+ });
407
+ }
408
+ }]
409
+ }
410
+ });
411
+ for (const input of inputs) {
412
+ const file = normalizeConfigPath(input, root);
413
+ if (!isNodeModulesPath(file) && !isConfigLoaderArtifact(file, cacheDirectory)) dependencies.add(file);
414
+ }
415
+ for (const directory of importDirectories) {
416
+ const manifest = normalizeConfigPath(path.join(directory, "package.json"), root);
417
+ if (existsSync(manifest) && !isNodeModulesPath(manifest) && !isConfigLoaderArtifact(manifest, cacheDirectory)) dependencies.add(manifest);
418
+ }
419
+ return unwrapConfig(mod.default ?? mod);
420
+ } catch (error) {
421
+ if (Array.isArray(error?.errors)) {
422
+ for (const candidate of recoveryCandidates) if (!existsSync(candidate)) this.recoveryCandidates.add(candidate);
423
+ for (const directory of importDirectories) this.recoveryDirectories.add(directory);
424
+ }
425
+ throw error;
426
+ } finally {
427
+ try {
428
+ await rm(bundleDirectory, {
429
+ recursive: true,
430
+ force: true,
431
+ maxRetries: 3,
432
+ retryDelay: 50
433
+ });
434
+ } catch (error) {
435
+ debug.error("Failed to remove config loader directory %s:", bundleDirectory, error);
436
+ }
437
+ }
438
+ }
439
+ };
440
+ function unwrapConfig(value) {
441
+ if (value === null || typeof value !== "object" || !("default" in value) || value.default === null || typeof value.default !== "object") return value;
442
+ const result = value.default;
443
+ for (const [key, item] of Object.entries(value)) {
444
+ if (key === "default" || key in result || item === result) continue;
445
+ try {
446
+ Object.defineProperty(result, key, {
447
+ configurable: true,
448
+ enumerable: true,
449
+ get: () => value[key]
450
+ });
451
+ } catch {}
452
+ }
453
+ return result;
454
+ }
455
+ //#endregion
23
456
  //#region src/constant.ts
24
457
  /** Vite 模块解析用的虚拟模块标识 */
25
458
  const MODULE_ID_VIRTUAL = "virtual:uni-pages";
@@ -34,6 +467,84 @@ const FILE_EXTENSIONS = [
34
467
  "uvue"
35
468
  ];
36
469
  //#endregion
470
+ //#region src/config-watch.ts
471
+ function isWithin(directory, file) {
472
+ const normalized = directory.replace(/\/$/, "");
473
+ return file === normalized || file.startsWith(`${normalized}/`);
474
+ }
475
+ /** 把一次加载的输入归一化为可注册、可匹配的监听计划 */
476
+ function buildConfigWatchPlan(input) {
477
+ const files = [...new Set([
478
+ ...input.sourcePaths,
479
+ ...input.dependencyPaths,
480
+ ...input.recoveryCandidates ?? []
481
+ ].map((file) => normalizeConfigPath(file, input.root)).filter((file) => !isConfigLoaderArtifact(file, input.artifactDirectory)))];
482
+ const directories = [...new Set((input.recoveryDirectories ?? []).map((dir) => normalizeConfigPath(dir, input.root)))];
483
+ return {
484
+ files,
485
+ directories,
486
+ paths: [.../* @__PURE__ */ new Set([
487
+ ...files,
488
+ ...files.map(configWatchDirectory),
489
+ ...directories.map((dir) => configWatchDirectory(path.join(dir, "_")))
490
+ ])]
491
+ };
492
+ }
493
+ /**
494
+ * 配置事件判定与监听集合的当前视图。
495
+ *
496
+ * 监听器只新增路径、从不退回(chokidar 对文件 unwatch 会拆掉父目录监听),
497
+ * 所以真正决定事件是否触发的始终是最近一次 `update` 的依赖集合;
498
+ * `expanded` 用来在依赖集合扩大后补跑一轮生成。
499
+ */
500
+ var ConfigWatchIndex = class {
501
+ root;
502
+ artifactDirectory;
503
+ plan = {
504
+ files: [],
505
+ directories: [],
506
+ paths: []
507
+ };
508
+ files = /* @__PURE__ */ new Set();
509
+ initialized = false;
510
+ expanded = false;
511
+ constructor(root, artifactDirectory) {
512
+ this.root = root;
513
+ this.artifactDirectory = artifactDirectory;
514
+ }
515
+ update(plan) {
516
+ const nextFiles = new Set(plan.files);
517
+ if (this.initialized && [...nextFiles].some((file) => !this.files.has(file))) this.expanded = true;
518
+ this.plan = plan;
519
+ this.files = nextFiles;
520
+ this.initialized = true;
521
+ }
522
+ /**
523
+ * 返回事件对应的归一化配置路径;不是当前配置输入时返回 undefined。
524
+ *
525
+ * 直接命中已知依赖时保留事件路径,避免为了识别符号链接别名而额外查
526
+ * 文件系统;未命中时才按真实路径归一化,覆盖别名和冗余路径段。
527
+ */
528
+ match(filepath, excluded) {
529
+ const eventPath = normalizePath(path.resolve(this.root, normalizePath(filepath)));
530
+ if (excluded.includes(eventPath) || isConfigLoaderArtifact(eventPath, this.artifactDirectory)) return;
531
+ const absolute = this.files.has(eventPath) ? eventPath : normalizeConfigPath(filepath, this.root);
532
+ if (excluded.includes(absolute) || isConfigLoaderArtifact(absolute, this.artifactDirectory)) return;
533
+ const mightBeConfig = configFileExtensions.test(path.basename(filepath));
534
+ return this.files.has(absolute) || mightBeConfig && this.plan.directories.some((directory) => isWithin(directory, absolute)) ? absolute : void 0;
535
+ }
536
+ /** 共享 watcher 已覆盖的根目录无需重复注册,也不能撤销 */
537
+ watchPaths(sharedRoot) {
538
+ return this.plan.paths.filter((file) => !sharedRoot || file !== sharedRoot && !isWithin(sharedRoot, file));
539
+ }
540
+ /** 依赖集合在上一次生成期间扩大时,消费一次补跑标记 */
541
+ consumeExpanded() {
542
+ const expanded = this.expanded;
543
+ this.expanded = false;
544
+ return expanded;
545
+ }
546
+ };
547
+ //#endregion
37
548
  //#region src/declaration.ts
38
549
  /**
39
550
  * 生成 TypeScript 声明文件内容
@@ -107,43 +618,118 @@ async function writeDeclaration(input, filepath) {
107
618
  if (code !== originalContent) await writeFile$1(filepath, code);
108
619
  }
109
620
  //#endregion
110
- //#region src/logger.ts
621
+ //#region src/page-rules.ts
622
+ const ruleCache = /* @__PURE__ */ new WeakMap();
111
623
  /**
112
- * 调试日志工具
113
- * 基于 debug 包做分级日志输出
114
- * 通过环境变量 DEBUG=vite-plugin-uni-pages:* 开启
624
+ * 编译以项目根为基准的规则,不查询目录是否存在
625
+ *
626
+ * @description 排除同时作用于路径自身和祖先;空模式忽略,否定排除报错,extglob 保留
115
627
  */
116
- const debug = {
117
- /** HMR 相关日志 */
118
- hmr: Debug("vite-plugin-uni-pages:hmr"),
119
- /** 配置项相关日志 */
120
- options: Debug("vite-plugin-uni-pages:options"),
121
- /** 主包页面扫描日志 */
122
- pages: Debug("vite-plugin-uni-pages:pages"),
123
- /** 子包页面扫描日志 */
124
- subPages: Debug("vite-plugin-uni-pages:subPages"),
125
- /** 错误日志 */
126
- error: Debug("vite-plugin-uni-pages:error"),
127
- /** 缓存相关日志 */
128
- cache: Debug("vite-plugin-uni-pages:cache"),
129
- /** 声明文件生成日志 */
130
- declaration: Debug("vite-plugin-uni-pages:declaration"),
131
- /** definePage 宏解析日志 */
132
- definePage: Debug("vite-plugin-uni-pages:definePage")
133
- };
628
+ function compilePageRules(root, patterns, exclude) {
629
+ const absoluteRoot = normalizePath(path.resolve(root));
630
+ const escapedRoot = escapePath(absoluteRoot);
631
+ const relative = (absolute) => normalizePath(path.relative(absoluteRoot, absolute)) || ".";
632
+ const matchOptions = {
633
+ dot: true,
634
+ posix: true
635
+ };
636
+ const compilePattern = (pattern) => {
637
+ const portable = process.platform === "win32" ? pattern.replace(/\\(?![()[\]{}!+@])/g, "/") : pattern;
638
+ const absolute = path.isAbsolute(portable);
639
+ let normalized = absolute ? path.posix.normalize(portable) : path.posix.relative(escapedRoot, path.posix.resolve(escapedRoot, portable)) || ".";
640
+ if (absolute && normalized !== normalizePath(path.parse(portable).root)) normalized = normalized.replace(/\/$/, "");
641
+ const matches = picomatch(normalized, matchOptions);
642
+ return {
643
+ pattern: normalized,
644
+ absolute,
645
+ matches: (candidate) => matches(absolute ? normalizePath(candidate) : relative(candidate))
646
+ };
647
+ };
648
+ const exclusions = exclude.filter((pattern) => pattern !== "").map((pattern) => {
649
+ if (picomatch.scan(pattern).negated) throw new Error(`[vite-plugin-uni-pages] Negative exclude pattern "${pattern}" cannot re-include pages. Remove it from exclude.`);
650
+ return compilePattern(pattern);
651
+ });
652
+ const directoryPatterns = patterns.filter((pattern) => pattern !== "" && !picomatch.scan(pattern).negated);
653
+ const directories = directoryPatterns.map(compilePattern);
654
+ const isExcluded = (absoluteRulePath, cache) => {
655
+ let current = path.resolve(absoluteRulePath);
656
+ const visited = cache ? [] : void 0;
657
+ let excluded = false;
658
+ while (true) {
659
+ const cached = cache?.get(current);
660
+ if (cached !== void 0) {
661
+ excluded = cached;
662
+ break;
663
+ }
664
+ visited?.push(current);
665
+ if (exclusions.some((rule) => rule.matches(current))) {
666
+ excluded = true;
667
+ break;
668
+ }
669
+ const parent = path.dirname(current);
670
+ if (parent === current) break;
671
+ current = parent;
672
+ }
673
+ if (cache && visited) for (const directory of visited) cache.set(directory, excluded);
674
+ return excluded;
675
+ };
676
+ return {
677
+ patterns: directoryPatterns,
678
+ traversalIgnores: exclusions.filter((rule) => !rule.absolute && !/[!()[\]{}\\+@|]/.test(rule.pattern)).map((rule) => rule.pattern),
679
+ isExcluded,
680
+ createExcludeFilter: () => {
681
+ const cache = /* @__PURE__ */ new Map();
682
+ return (absolute) => isExcluded(absolute, cache);
683
+ },
684
+ matchesDirectory: (absolute) => !isExcluded(absolute) && directories.some((rule) => rule.matches(absolute))
685
+ };
686
+ }
687
+ /** 复用当前声明的编译结果,原地修改路径规则或排除项后重新编译 */
688
+ function getPageRules(options) {
689
+ const patterns = [options.dir, ...options.subPackagePatterns.map((pattern) => typeof pattern === "string" ? pattern : pattern.dir)];
690
+ const fingerprint = JSON.stringify([
691
+ options.root,
692
+ patterns,
693
+ options.exclude
694
+ ]);
695
+ const cached = ruleCache.get(options);
696
+ if (cached?.fingerprint === fingerprint) return cached.rules;
697
+ const rules = compilePageRules(options.root, patterns, options.exclude);
698
+ ruleCache.set(options, {
699
+ fingerprint,
700
+ rules
701
+ });
702
+ return rules;
703
+ }
134
704
  //#endregion
135
705
  //#region src/files.ts
136
706
  /**
137
- * 解析给定上下文下可作为有效页面的文件列表。
707
+ * 判断是否为路径缺失或中间路径不是目录的文件系统错误
708
+ *
709
+ * @returns 错误码为 ENOENT 或 ENOTDIR 时返回 true,其他错误及空输入返回 false
710
+ */
711
+ function isMissingFsError(error) {
712
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
713
+ return error.code === "ENOENT" || error.code === "ENOTDIR";
714
+ }
715
+ /**
716
+ * 获取扫描目录中未被排除的页面文件
717
+ *
718
+ * @param directory - 绝对扫描目录,或相对项目根的目录
719
+ * @param options - 已解析的页面选项,排除规则以其中的项目根为基准
720
+ * @returns 相对扫描目录、使用 `/` 分隔的文件路径
138
721
  */
139
- function getPageFiles(path, options) {
140
- const { exclude } = options;
722
+ function getPageFiles(directory, options) {
723
+ const absolute = path.resolve(options.root, directory);
724
+ const rules = getPageRules(options);
725
+ if (rules.isExcluded(absolute)) return [];
141
726
  const ext = FILE_EXTENSIONS.length > 1 ? `{${FILE_EXTENSIONS.join(",")}}` : FILE_EXTENSIONS[0] || "";
142
- return globSync(`**/*.${ext}`, {
143
- ignore: exclude,
727
+ return globSync(`${escapePath(normalizePath(absolute))}/**/*.${ext}`, {
728
+ ignore: rules.traversalIgnores,
729
+ expandDirectories: false,
144
730
  onlyFiles: true,
145
- cwd: path
146
- });
731
+ cwd: options.root
732
+ }).map((file) => path.resolve(options.root, file)).filter((file) => !rules.isExcluded(file)).map((file) => normalizePath(path.relative(absolute, file)));
147
733
  }
148
734
  /**
149
735
  * 检查文件是否为目标页面文件
@@ -183,8 +769,15 @@ function checkPagesJsonFileSync(path) {
183
769
  try {
184
770
  fs.accessSync(path, fs.constants.F_OK);
185
771
  } catch {
186
- fs.writeFileSync(path, JSON.stringify({ pages: [{ path: "" }] }, null, 2), { encoding: "utf-8" });
187
- return;
772
+ try {
773
+ fs.writeFileSync(path, JSON.stringify({ pages: [{ path: "" }] }, null, 2), {
774
+ encoding: "utf-8",
775
+ flag: "wx"
776
+ });
777
+ return;
778
+ } catch (error) {
779
+ if (error.code !== "EEXIST") throw error;
780
+ }
188
781
  }
189
782
  if (!fs.statSync(path).isFile()) throw new Error(`[vite-plugin-uni-pages] ${path} exists but is not a regular file. Remove it manually if it is not needed; the plugin refuses to delete or overwrite it.`);
190
783
  try {
@@ -216,6 +809,7 @@ const lockQueues = /* @__PURE__ */ new Map();
216
809
  * @param task - 在锁内运行的异步任务;返回值原样返回给调用方
217
810
  * @param retry - 锁被别人拿着时的重试次数,默认 3
218
811
  * @returns `task` 的结果;一直拿不到锁时为 `undefined`
812
+ * @throws 权限不足、路径缺失等非竞争错误原样抛出,不重试
219
813
  */
220
814
  function withFileLock(path, task, retry = 3) {
221
815
  const current = (lockQueues.get(path) ?? Promise.resolve()).catch(() => {}).then(() => acquireAndRun(path, task, retry));
@@ -228,8 +822,9 @@ async function acquireAndRun(path, task, retry) {
228
822
  let release;
229
823
  try {
230
824
  release = await lockfile.lock(path, { realpath: false });
231
- } catch {
232
- await sleep(500);
825
+ } catch (error) {
826
+ if (error.code !== "ELOCKED") throw error;
827
+ if (attempt > 1) await sleep(500);
233
828
  continue;
234
829
  }
235
830
  try {
@@ -249,6 +844,348 @@ function sleep(ms) {
249
844
  return new Promise((resolve) => setTimeout(resolve, ms));
250
845
  }
251
846
  //#endregion
847
+ //#region src/page-sources.ts
848
+ /**
849
+ * 读取本轮页面文件的物理身份
850
+ *
851
+ * @returns 规范化的 native realpath;路径不存在或中间路径不是目录时为 undefined
852
+ * @throws 权限不足等其他文件系统错误原样抛出
853
+ */
854
+ function resolvePageIdentity(file) {
855
+ try {
856
+ return normalizePath(fs.realpathSync.native(file));
857
+ } catch (error) {
858
+ if (!isMissingFsError(error)) throw error;
859
+ }
860
+ }
861
+ /**
862
+ * 比较页面来源的代表优先级
863
+ *
864
+ * @description 显式来源优先,其余按声明和 glob 匹配顺序;相同优先级返回 0
865
+ */
866
+ function comparePageSources(a, b) {
867
+ return Number(b.explicitRoot) - Number(a.explicitRoot) || a.ruleIndex - b.ruleIndex || a.matchIndex - b.matchIndex;
868
+ }
869
+ function describeSource(source) {
870
+ const declaration = source.root === void 0 ? "dir" : `subPackages[${source.ruleIndex}]`;
871
+ const rule = source.rulePattern === void 0 ? "" : ` rule ${JSON.stringify(source.rulePattern)}`;
872
+ const owner = source.root === void 0 ? "main package" : `subpackage "${source.root}"`;
873
+ const rootSource = source.rootSource;
874
+ const rootOrigin = rootSource ? `, root from subPackages[${rootSource.ruleIndex}] "${rootSource.dir}" (${rootSource.explicitRoot ? "explicit" : "default"})` : "";
875
+ return `"${source.file}" (${declaration}${rule}, directory "${source.dir}", ${owner}${rootOrigin})`;
876
+ }
877
+ /** 关联记录仅保留来源信息,不携带其他 Page 实例 */
878
+ function toPageSource(candidate) {
879
+ return {
880
+ file: candidate.file,
881
+ identity: candidate.identity,
882
+ dir: candidate.dir,
883
+ root: candidate.root,
884
+ explicitRoot: candidate.explicitRoot,
885
+ ruleIndex: candidate.ruleIndex,
886
+ matchIndex: candidate.matchIndex,
887
+ rootSource: candidate.rootSource
888
+ };
889
+ }
890
+ function describeRelatedSource(source) {
891
+ const owner = source.root === void 0 ? "main package" : `subpackage "${source.root}"`;
892
+ return `"${source.file}" (directory "${source.dir}", ${owner})`;
893
+ }
894
+ function describeRelatedSources(source) {
895
+ if (source.relatedSources.length === 0) return "";
896
+ return `; related sources: ${source.relatedSources.map(describeRelatedSource).join(", ")}`;
897
+ }
898
+ /**
899
+ * 为每个物理页面选择一个有效代表来源
900
+ *
901
+ * @description 候选需先通过文件排除,且未被宏结果或平台规则跳过;有效分包优先于主包,
902
+ * 同一归属内按显式来源和声明顺序选择,返回顺序按文件身份首次出现的位置排列,
903
+ * 同身份的其他来源仅保留在代表记录中,用于本轮冲突诊断
904
+ * @param candidates - 本轮有效候选,数组不会被修改
905
+ * @throws 同一物理页面被不同分包 root 认领时抛出来源冲突
906
+ */
907
+ function selectPageSources(candidates) {
908
+ const groups = /* @__PURE__ */ new Map();
909
+ for (const candidate of candidates) {
910
+ const group = groups.get(candidate.identity) ?? [];
911
+ group.push(candidate);
912
+ groups.set(candidate.identity, group);
913
+ }
914
+ const selected = [];
915
+ for (const group of groups.values()) {
916
+ const subPages = group.filter((source) => source.root !== void 0);
917
+ const first = subPages[0];
918
+ const conflict = subPages.find((source) => source.root !== first?.root);
919
+ if (first && conflict) {
920
+ const explanation = first.ruleIndex >= 0 && first.ruleIndex === conflict.ruleIndex && first.matchIndex !== conflict.matchIndex ? " The same directory rule matched multiple directories containing this page. For one subpackage tree, declare its parent directory once; for multiple entries in the same subpackage, use a common root." : "";
921
+ throw new Error(`[vite-plugin-uni-pages] Conflicting subpackage ownership: ${describeSource(first)} and ${describeSource(conflict)} refer to the same page "${first.identity}".${explanation}`);
922
+ }
923
+ const representative = [...subPages.length ? subPages : group].sort(comparePageSources)[0];
924
+ const relatedSources = group.filter((source) => source !== representative).map(toPageSource);
925
+ selected.push({
926
+ ...representative,
927
+ relatedSources
928
+ });
929
+ }
930
+ return selected;
931
+ }
932
+ /**
933
+ * 获取允许格式或平台变体覆盖的物理文件族
934
+ *
935
+ * @description 格式变体共享去掉扩展名的真实路径;平台后缀只在主包且当前平台适用时处理
936
+ * @returns 支持的真实页面格式对应的文件族,其他格式不参与覆盖例外
937
+ */
938
+ function fileFamily(source, platform) {
939
+ const extension = path.extname(source.identity);
940
+ if (!FILE_EXTENSIONS.includes(extension.slice(1))) return;
941
+ const stem = source.identity.slice(0, -extension.length);
942
+ const filename = source.page.uri.slice(source.page.uri.lastIndexOf("/") + 1);
943
+ if (platform === void 0 || source.root !== void 0 || !filename.includes(".") || !source.page.uri.includes(platform)) return stem;
944
+ const slash = stem.lastIndexOf("/");
945
+ const dot = stem.indexOf(".", slash + 1);
946
+ return dot !== -1 && stem.slice(dot) === filename.slice(filename.indexOf(".")) ? stem.slice(0, dot) : stem;
947
+ }
948
+ /**
949
+ * 校验生成页面是否占用同一完整路由
950
+ *
951
+ * @description 比较时规范化分隔符、点段和末尾斜杠,不改写候选或输出路径
952
+ * @param candidates - 已完成物理身份归属决议的代表来源,需在配置覆盖和输出去重前传入
953
+ * @param platform - 启用主包平台后缀规则时的当前平台,省略时只允许格式变体覆盖
954
+ * @throws 不同生成来源占用同一路由且不属于同一归属的允许文件族时抛出冲突
955
+ */
956
+ function validateGeneratedRoutes(candidates, platform) {
957
+ const routes = /* @__PURE__ */ new Map();
958
+ for (const candidate of candidates) {
959
+ const route = path.posix.normalize(candidate.route.replace(/\\/g, "/")).replace(/\/$/, "");
960
+ const previous = routes.get(route);
961
+ if (previous && previous.identity !== candidate.identity) {
962
+ const previousFamily = fileFamily(previous, platform);
963
+ if (!(previous.root === candidate.root && previousFamily !== void 0 && previousFamily === fileFamily(candidate, platform))) throw new Error(`[vite-plugin-uni-pages] Conflicting generated route "${route}": ${describeSource(previous)}${describeRelatedSources(previous)} and ${describeSource(candidate)}${describeRelatedSources(candidate)}.`);
964
+ }
965
+ routes.set(route, candidate);
966
+ }
967
+ }
968
+ //#endregion
969
+ //#region src/directories.ts
970
+ /**
971
+ * 规范化页面目录选择器中的路径分隔符
972
+ *
973
+ * @description 在 Windows 上将反斜杠路径分隔符转换为 `/`,保留 `\[`、`\(` 等 glob 转义
974
+ *
975
+ * @param pattern 页面目录路径或 glob 表达式
976
+ * @returns Windows 返回规范化后的选择器,其他平台返回原字符串
977
+ *
978
+ * @example
979
+ * ```ts
980
+ * normalizePagePattern('src\\pages') // Windows: 'src/pages'
981
+ * ```
982
+ */
983
+ function normalizePagePattern(pattern) {
984
+ return process.platform === "win32" ? pattern.replace(/\\(?![()[\]{}!+@])/g, "/") : pattern;
985
+ }
986
+ /**
987
+ * 将目录选择器中的 glob 转义还原为字面字符
988
+ *
989
+ * @description 用于已经确定的固定路径部分,还原后的字符串可传给 `path.resolve` 等路径 API
990
+ *
991
+ * @param pattern 以 `/` 分隔、可包含 glob 转义的固定目录路径
992
+ * @returns 去除 glob 特殊字符前转义反斜杠的路径字符串
993
+ *
994
+ * @example
995
+ * ```ts
996
+ * unescapePagePattern('src/\\[demo\\]') // 'src/[demo]'
997
+ * ```
998
+ */
999
+ function unescapePagePattern(pattern) {
1000
+ return pattern.replace(/\\(?=[[\](){}!*+?@|])/g, "");
1001
+ }
1002
+ function findPageDirs(dir, root, rules) {
1003
+ if (dir === "" || picomatch.scan(dir).negated) return [];
1004
+ const pattern = path.posix.normalize(normalizePagePattern(dir));
1005
+ const scan = picomatch.scan(pattern);
1006
+ if (rules.isExcluded(path.resolve(root, unescapePagePattern(scan.base || ".")))) return [];
1007
+ return globSync(pattern, {
1008
+ ignore: rules.traversalIgnores,
1009
+ onlyDirectories: true,
1010
+ expandDirectories: false,
1011
+ dot: true,
1012
+ cwd: root
1013
+ }).map(normalizePath).filter((directory) => !rules.isExcluded(path.resolve(root, directory))).sort();
1014
+ }
1015
+ /**
1016
+ * 查找当前可扫描的页面目录
1017
+ *
1018
+ * @description 应用目录排除后,返回相对 root、按路径排序的匹配结果;空规则或否定规则返回空数组
1019
+ *
1020
+ * @param dir 页面目录 glob,可使用绝对路径
1021
+ * @param root 相对目录和相对 exclude 的解析基准
1022
+ * @param exclude 目录及页面排除规则
1023
+ */
1024
+ function resolvePageDirs(dir, root, exclude) {
1025
+ return findPageDirs(dir, root, compilePageRules(root, [dir], exclude));
1026
+ }
1027
+ function directoryIdentity(absolute) {
1028
+ try {
1029
+ if (fs.statSync(absolute, { throwIfNoEntry: false })?.isDirectory()) return normalizePath(fs.realpathSync.native(absolute));
1030
+ } catch (error) {
1031
+ if (!isMissingFsError(error)) throw error;
1032
+ }
1033
+ }
1034
+ function findCallbackDirectory(dir, root, rules) {
1035
+ if (dir === "") return [];
1036
+ const absolute = path.resolve(root, unescapePagePattern(path.posix.normalize(dir)));
1037
+ if (rules.isExcluded(absolute)) return [];
1038
+ try {
1039
+ if (fs.statSync(absolute, { throwIfNoEntry: false })?.isDirectory()) return [normalizePath(path.relative(root, absolute)) || "."];
1040
+ } catch (error) {
1041
+ if (!isMissingFsError(error)) throw error;
1042
+ }
1043
+ return [];
1044
+ }
1045
+ /** 展开分包声明,返回逐条来源记录和扫描目录投影 */
1046
+ function discoverSubPackageDirectories(patterns, root, outDir, exclude) {
1047
+ const rules = compilePageRules(root, [], exclude);
1048
+ const candidates = [];
1049
+ const groups = /* @__PURE__ */ new Map();
1050
+ for (const [ruleIndex, pattern] of patterns.entries()) {
1051
+ const dir = normalizePagePattern(typeof pattern === "string" ? pattern : pattern.dir);
1052
+ if (picomatch.scan(dir).negated) throw new Error("[vite-plugin-uni-pages] Use exclude for negative subpackage patterns.");
1053
+ const customRoot = typeof pattern === "string" ? void 0 : pattern.root;
1054
+ const glob = isDynamicPattern(dir);
1055
+ const dynamic = glob || typeof customRoot === "function";
1056
+ const dirs = glob ? findPageDirs(dir, root, rules) : typeof customRoot === "function" ? findCallbackDirectory(dir, root, rules) : [dir];
1057
+ for (const [matchIndex, matched] of dirs.entries()) {
1058
+ const resolved = dynamic ? matched : unescapePagePattern(matched);
1059
+ const absolute = normalizePath(path.resolve(root, resolved));
1060
+ if (rules.isExcluded(absolute)) continue;
1061
+ const relative = normalizePath(path.relative(root, absolute)) || ".";
1062
+ const resolvedRoot = typeof customRoot === "function" ? customRoot(relative) : customRoot;
1063
+ if (typeof customRoot === "function" && (typeof resolvedRoot !== "string" || !resolvedRoot || resolvedRoot === "." || path.posix.isAbsolute(resolvedRoot) || path.win32.isAbsolute(resolvedRoot) || resolvedRoot.replace(/\\/g, "/").split("/").includes(".."))) throw new Error(`[vite-plugin-uni-pages] Invalid subpackage root returned for "${relative}": expected a non-empty relative path without "..".`);
1064
+ const defaultRoot = normalizePath(path.relative(path.resolve(root, outDir), absolute));
1065
+ const identity = directoryIdentity(absolute);
1066
+ const candidate = {
1067
+ dir: dynamic ? relative : resolved,
1068
+ absolute,
1069
+ identity,
1070
+ root: resolvedRoot === void 0 ? defaultRoot : normalizePath(resolvedRoot),
1071
+ explicitRoot: resolvedRoot !== void 0,
1072
+ ruleIndex,
1073
+ matchIndex,
1074
+ rootSource: {
1075
+ dir: dynamic ? relative : resolved,
1076
+ ruleIndex,
1077
+ explicitRoot: resolvedRoot !== void 0
1078
+ },
1079
+ defaultRoot
1080
+ };
1081
+ candidates.push(candidate);
1082
+ const key = identity ?? absolute;
1083
+ const group = groups.get(key) ?? [];
1084
+ group.push(candidate);
1085
+ groups.set(key, group);
1086
+ }
1087
+ }
1088
+ for (const group of groups.values()) {
1089
+ const explicit = group.filter((candidate) => candidate.explicitRoot);
1090
+ const selected = explicit[0] ?? group[0];
1091
+ for (const candidate of explicit) if (candidate.root !== selected.root) throw new Error(`[vite-plugin-uni-pages] Conflicting subpackage roots: "${selected.dir}" (subPackages[${selected.ruleIndex}]) uses "${selected.root}", but "${candidate.dir}" (subPackages[${candidate.ruleIndex}]) uses "${candidate.root}".`);
1092
+ for (const candidate of group) {
1093
+ candidate.root = selected.root;
1094
+ candidate.rootSource = selected.rootSource;
1095
+ }
1096
+ }
1097
+ const directories = /* @__PURE__ */ new Map();
1098
+ for (const candidate of candidates) {
1099
+ const previous = directories.get(candidate.absolute);
1100
+ if (!previous || comparePageSources(candidate, previous) < 0) directories.set(candidate.absolute, candidate);
1101
+ }
1102
+ const roots = /* @__PURE__ */ new Map();
1103
+ for (const candidate of directories.values()) if (candidate.explicitRoot || candidate.root !== candidate.defaultRoot) roots.set(candidate.dir, candidate.root);
1104
+ return {
1105
+ sources: candidates.map(({ defaultRoot: _defaultRoot, ...source }) => source),
1106
+ projection: {
1107
+ subPackages: [...directories.values()].map((candidate) => candidate.dir),
1108
+ subPackageRootMap: roots
1109
+ }
1110
+ };
1111
+ }
1112
+ /**
1113
+ * 展开分包目录并确定各目录的最终 root
1114
+ *
1115
+ * @description 每次调用重新发现目录并求值回调;按声明顺序排列,glob 内按路径排序
1116
+ * 同一物理目录的显式 root 优先于默认值,词法别名分别保留,重复目录投影只登记一次
1117
+ *
1118
+ * @param patterns 原始分包目录声明
1119
+ * @param root 相对目录和相对 exclude 的解析基准
1120
+ * @param outDir 默认分包 root 的换算基准,相对 root 解析
1121
+ * @param exclude 目录及页面排除规则
1122
+ * @returns 目录列表及最终 root 映射
1123
+ * @throws 目录规则无效、root 回调失败、显式 root 冲突或非缺失类目录访问错误
1124
+ */
1125
+ function resolveSubPackageDirs(patterns, root, outDir, exclude) {
1126
+ return discoverSubPackageDirectories(patterns, root, outDir, exclude).projection;
1127
+ }
1128
+ /**
1129
+ * 读取当前分包目录来源
1130
+ *
1131
+ * @description 每次调用重新发现目录并求值回调;需要与扫描共用同一份结果时,
1132
+ * 使用 ScanPlan 而不是在扫描中途再次调用
1133
+ *
1134
+ * @returns 按声明及匹配顺序排列的来源,调用方应只读使用记录
1135
+ * @throws 目录规则、root 决议或目录访问错误
1136
+ */
1137
+ function getSubPackageDirectorySources(options) {
1138
+ return discoverSubPackageDirectories(options.subPackagePatterns, options.root, options.outDir, options.exclude).sources;
1139
+ }
1140
+ /**
1141
+ * 刷新扫描目录与分包 root 投影
1142
+ *
1143
+ * @description 仅用于兼容仍读取 `ResolvedOptions` 的调用方;内部扫描走
1144
+ * `createScanPlan()`,每次重新发现目录来源与物理身份,不保留上一轮快照
1145
+ *
1146
+ * @throws 目录规则、root 决议或目录访问失败时抛出,应中止本轮扫描
1147
+ */
1148
+ function refreshPageDirectories(options) {
1149
+ const { projection } = discoverSubPackageDirectories(options.subPackagePatterns, options.root, options.outDir, options.exclude);
1150
+ options.dirs = resolvePageDirs(options.dir, options.root, options.exclude);
1151
+ Object.assign(options, projection);
1152
+ }
1153
+ function contains$1(directory, entry) {
1154
+ return entry === directory || entry.startsWith(`${directory.replace(/\/$/, "")}/`);
1155
+ }
1156
+ /**
1157
+ * 判断目录事件是否可能改变扫描目录集合
1158
+ *
1159
+ * @description directory 相对 options.root 解析,允许目录已删除;关闭 mergePages 时返回 false
1160
+ */
1161
+ function isPageDirectoryEvent(directory, options) {
1162
+ if (!options.mergePages) return false;
1163
+ const absolute = normalizePath(path.resolve(options.root, directory));
1164
+ const rules = getPageRules(options);
1165
+ if (rules.isExcluded(absolute)) return false;
1166
+ if (rules.matchesDirectory(absolute)) return true;
1167
+ return [...options.dirs, ...options.subPackages].some((dir) => contains$1(absolute, normalizePath(path.resolve(options.root, dir))));
1168
+ }
1169
+ /**
1170
+ * 判断文件路径是否位于当前页面规则内
1171
+ *
1172
+ * @description file 相对 options.root 解析,按原始规则与 exclude 判断,不要求文件存在或目录已展开
1173
+ * 关闭 mergePages 时返回 false,文件扩展名由调用方校验
1174
+ */
1175
+ function isPageFileInDirectories(file, options) {
1176
+ if (!options.mergePages) return false;
1177
+ const absolute = normalizePath(path.resolve(options.root, file));
1178
+ const rules = getPageRules(options);
1179
+ if (rules.isExcluded(absolute)) return false;
1180
+ let directory = normalizePath(path.dirname(absolute));
1181
+ while (true) {
1182
+ if (rules.matchesDirectory(directory)) return true;
1183
+ const parent = normalizePath(path.dirname(directory));
1184
+ if (parent === directory) return false;
1185
+ directory = parent;
1186
+ }
1187
+ }
1188
+ //#endregion
252
1189
  //#region src/options.ts
253
1190
  /**
254
1191
  * 解析用户配置项
@@ -260,30 +1197,24 @@ function sleep(ms) {
260
1197
  */
261
1198
  function resolveOptions(userOptions, viteRoot = process.cwd()) {
262
1199
  const { dts = true, configSource = "pages.config", homePage = ["pages/index", "pages/index/index"], mergePages = true, dir = "src/pages", subPackages = [], outDir = "src", exclude = [
263
- "node_modules",
264
- ".git",
1200
+ "**/node_modules/**",
1201
+ "**/.git/**",
265
1202
  "**/__*__/**"
266
1203
  ], minify = false, insertFinalNewline = false, indent = 2, eol = "\n", debug = false, onBeforeLoadUserConfig = () => {}, onAfterLoadUserConfig = () => {}, onBeforeScanPages = () => {}, onAfterScanPages = () => {}, onBeforeMergePageMetaData = () => {}, onAfterMergePageMetaData = () => {}, onBeforeWriteFile = () => {}, onAfterWriteFile = () => {} } = userOptions;
267
- const root = viteRoot || normalizePath(process.env.VITE_ROOT_DIR || process.cwd());
1204
+ const root = normalizePath(resolve(viteRoot || process.env.VITE_ROOT_DIR || process.cwd()));
268
1205
  const resolvedDirs = resolvePageDirs(dir, root, exclude);
269
- const subPackageRootMap = /* @__PURE__ */ new Map();
270
- const resolvedSubDirs = [];
271
- for (const sub of subPackages) if (typeof sub === "string") resolvedSubDirs.push(normalizePath(sub));
272
- else {
273
- const dirPath = normalizePath(sub.dir);
274
- resolvedSubDirs.push(dirPath);
275
- subPackageRootMap.set(dirPath, sub.root);
276
- }
1206
+ const resolvedSubPackages = resolveSubPackageDirs(subPackages, root, outDir, exclude);
277
1207
  const resolvedHomePage = typeof homePage === "string" ? [homePage] : homePage;
278
1208
  const resolvedConfigSource = typeof configSource === "string" ? [{ files: configSource }] : configSource;
279
1209
  return {
280
- dts: !dts ? false : typeof dts === "string" ? dts : resolve(viteRoot, "uni-pages.d.ts"),
1210
+ dts: !dts ? false : typeof dts === "string" ? resolve(root, dts) : resolve(root, "uni-pages.d.ts"),
281
1211
  configSource: Array.isArray(resolvedConfigSource) ? resolvedConfigSource : [resolvedConfigSource],
282
1212
  homePage: resolvedHomePage,
283
1213
  mergePages,
1214
+ dir,
284
1215
  dirs: resolvedDirs,
285
- subPackages: resolvedSubDirs,
286
- subPackageRootMap,
1216
+ subPackagePatterns: subPackages,
1217
+ ...resolvedSubPackages,
287
1218
  outDir,
288
1219
  exclude,
289
1220
  root,
@@ -302,22 +1233,6 @@ function resolveOptions(userOptions, viteRoot = process.cwd()) {
302
1233
  onAfterWriteFile
303
1234
  };
304
1235
  }
305
- /**
306
- * 根据给定的 glob 模式解析页面目录
307
- * @param dir - 页面目录 glob 模式
308
- * @param root - 项目根目录
309
- * @param exclude - 需要排除的 glob 模式
310
- * @returns 匹配到的目录路径
311
- */
312
- function resolvePageDirs(dir, root, exclude) {
313
- return globSync(normalizePath(dir), {
314
- ignore: exclude,
315
- onlyDirectories: true,
316
- expandDirectories: false,
317
- dot: true,
318
- cwd: root
319
- });
320
- }
321
1236
  //#endregion
322
1237
  //#region src/condition.ts
323
1238
  /** 判断一个值是否为普通对象(非 null、非数组) */
@@ -694,97 +1609,84 @@ async function parseCode(options) {
694
1609
  //#endregion
695
1610
  //#region src/page.ts
696
1611
  /**
697
- * 表示一个 Vue 页面文件的 Page 类
1612
+ * 表示一个 Vue 页面文件的读取适配器
698
1613
  *
699
- * 职责:
700
- * 1. 读取页面文件内容
701
- * 2. 解析 definePage 宏定义的页面配置
702
- * 3. 提供 tabBar 配置信息
703
- * 4. 跟踪页面文件变更状态
1614
+ * 调用方只需要 `load()`:文件读取、宏求值、跳过状态、tabBar 补全和
1615
+ * 变更检测都在快照里返回。`getPageMeta` / `getTabBar` 保留为兼容入口。
704
1616
  */
705
1617
  var Page = class {
706
- /** 页面上下文实例 */
707
- ctx;
1618
+ environment;
708
1619
  /** 页面路径信息,包含相对路径与绝对路径 */
709
1620
  path;
710
1621
  /** 页面 URI,用于 pages.json 的 path 字段 */
711
1622
  uri;
712
- /** 页面是否发生变化,用于增量更新判断 */
713
- changed = true;
714
- /**
715
- * 页面是否通过 `definePage(null)` 或函数式宏在当前平台返回 null 而
716
- * 退出了 pages.json
717
- */
718
- skipped = false;
719
- /** 页面文件是否至少被读取过一次 */
1623
+ snapshot;
1624
+ /** 当前读取缓存是否有效 */
720
1625
  loaded = false;
721
- /** 页面配置的原始 JSON 字符串,用于变更检测 */
1626
+ /** 当前缓存是否已尝试读取;失败后等下次失效再重试 */
1627
+ readAttempted = false;
1628
+ /** 上一次成功读取的配置,用于变更检测 */
722
1629
  raw = "";
723
- /** 解析后的页面配置 */
724
- meta;
725
- /**
726
- * 创建页面实例
727
- * @param ctx - 页面上下文实例
728
- * @param path - 页面路径信息
729
- */
730
- constructor(ctx, path) {
731
- this.ctx = ctx;
1630
+ constructor(environment, path) {
1631
+ this.environment = environment;
732
1632
  this.path = path;
733
1633
  const ext = extname(path.relativePath);
734
1634
  this.uri = normalizePath(ext ? path.relativePath.slice(0, -ext.length) : path.relativePath);
735
1635
  }
1636
+ /** 页面是否通过 definePage(null) 退出 */
1637
+ get skipped() {
1638
+ return this.snapshot?.skipped ?? false;
1639
+ }
1640
+ /** 页面内容是否相对上次成功读取发生变化 */
1641
+ get changed() {
1642
+ return this.snapshot?.changed ?? true;
1643
+ }
736
1644
  /**
737
- * 获取页面配置
738
- * 解析 definePage 宏定义的配置并返回用于 pages.json 的配置
1645
+ * 读取页面并返回快照
739
1646
  *
740
- * @param forceUpdate - 是否强制更新,忽略缓存
741
- * @returns 页面配置对象
1647
+ * @param forceUpdate - 忽略缓存强制重读
742
1648
  */
743
- async getPageMeta(forceUpdate = false) {
744
- if (forceUpdate || !this.loaded) await this.read();
745
- const { path, tabBar: _, ...others } = this.meta || {};
746
- return {
747
- path: path ?? this.uri,
748
- ...others
749
- };
1649
+ async load(forceUpdate = false) {
1650
+ if (forceUpdate || !this.loaded && !this.readAttempted) await this.read();
1651
+ return this.snapshot ?? this.fallbackSnapshot();
750
1652
  }
751
1653
  /**
752
- * 获取页面 tabBar 配置
753
- * 从 definePage 宏中提取 tabBar 相关配置
1654
+ * 获取页面配置,保留给既有调用方
754
1655
  *
755
- * @param forceUpdate - 是否强制更新,忽略缓存
756
- * @returns tabBar 配置对象,页面未定义 tabBar 时返回 undefined
1656
+ * 返回副本:合并阶段会原地写 `type: 'home'`,不能让首页标记留在
1657
+ * 读取快照里,否则增量更新会继续把旧首页当成首页。
757
1658
  */
1659
+ async getPageMeta(forceUpdate = false) {
1660
+ return { ...(await this.load(forceUpdate)).meta };
1661
+ }
1662
+ /** 获取 tabBar 项,保留给既有调用方 */
758
1663
  async getTabBar(forceUpdate = false) {
759
- if (forceUpdate || !this.loaded) await this.read();
760
- if (this.skipped) return void 0;
761
- const { tabBar } = this.meta || {};
762
- if (tabBar === void 0) return;
763
- return {
764
- ...tabBar,
765
- pagePath: tabBar.pagePath || this.uri,
766
- index: tabBar.index ?? 0
767
- };
1664
+ return (await this.load(forceUpdate)).tabBar;
768
1665
  }
769
- /**
770
- * 确保页面文件至少被读取过一次,使 `skipped` 与缓存的配置反映
771
- * 当前文件内容
772
- */
1666
+ /** 加载尚未读取的页面配置,缓存失效前不重复尝试失败的读取 */
773
1667
  async ensureLoaded() {
774
- if (!this.loaded) await this.read();
1668
+ await this.load();
775
1669
  }
776
1670
  /**
777
- * 检查页面是否发生变化
778
- * @returns 页面是否发生变化
1671
+ * 使页面读取缓存失效
1672
+ *
1673
+ * @description 下次访问时重新读取,保留上次成功快照作为读取失败时的回退
779
1674
  */
1675
+ invalidate() {
1676
+ this.loaded = false;
1677
+ this.readAttempted = false;
1678
+ }
1679
+ /** 检查页面是否发生变化 */
780
1680
  hasChanged() {
781
1681
  return this.changed;
782
1682
  }
783
1683
  /**
784
1684
  * 读取页面文件并解析配置
785
- * 从 Vue SFC 中提取 definePage 宏定义的配置
1685
+ *
1686
+ * @description 成功后替换快照;失败时保留上次成功快照,只记录警告
786
1687
  */
787
1688
  async read() {
1689
+ this.readAttempted = true;
788
1690
  let meta;
789
1691
  let skipped = false;
790
1692
  try {
@@ -793,22 +1695,46 @@ var Page = class {
793
1695
  else meta = result;
794
1696
  } catch (err) {
795
1697
  debug.error(err);
796
- this.ctx.logger?.warn(err?.message ?? String(err), { timestamp: true });
1698
+ (this.environment.getLogger?.())?.warn(err?.message ?? String(err), { timestamp: true });
797
1699
  return;
798
1700
  }
799
1701
  let raw = "";
800
1702
  try {
801
1703
  raw = JSON.stringify(meta) ?? "";
802
1704
  } catch {}
803
- this.changed = this.raw !== raw || this.skipped !== skipped;
804
- this.loaded = true;
805
- this.meta = meta;
1705
+ const changed = this.raw !== raw || this.skipped !== skipped;
806
1706
  this.raw = raw;
807
- this.skipped = skipped;
1707
+ this.loaded = true;
1708
+ this.snapshot = this.createSnapshot(meta, skipped, changed);
1709
+ }
1710
+ createSnapshot(meta, skipped, changed) {
1711
+ const { path, tabBar, ...others } = meta ?? {};
1712
+ return {
1713
+ meta: {
1714
+ path: path ?? this.uri,
1715
+ ...others
1716
+ },
1717
+ tabBar: !skipped && tabBar ? {
1718
+ ...tabBar,
1719
+ pagePath: tabBar.pagePath || this.uri,
1720
+ index: tabBar.index ?? 0
1721
+ } : void 0,
1722
+ skipped,
1723
+ changed
1724
+ };
1725
+ }
1726
+ /** 读取失败且从未成功时的默认快照,保持页面仍以 uri 参与生成 */
1727
+ fallbackSnapshot() {
1728
+ return {
1729
+ meta: { path: this.uri },
1730
+ tabBar: void 0,
1731
+ skipped: false,
1732
+ changed: true
1733
+ };
808
1734
  }
809
1735
  async readPageMetaFromFile() {
810
1736
  try {
811
- const meta = await evaluateDefinePage(await fs.promises.readFile(this.path.absolutePath, { encoding: "utf-8" }), this.path.absolutePath, this.ctx.platform);
1737
+ const meta = await evaluateDefinePage(await fs.promises.readFile(this.path.absolutePath, { encoding: "utf-8" }), this.path.absolutePath, this.environment.platform);
812
1738
  return meta === void 0 ? { type: "page" } : meta;
813
1739
  } catch (err) {
814
1740
  throw new Error(`Read page meta fail in ${this.path.relativePath}\n${err.message}`);
@@ -816,6 +1742,619 @@ var Page = class {
816
1742
  }
817
1743
  };
818
1744
  //#endregion
1745
+ //#region src/page-watch-filter.ts
1746
+ /**
1747
+ * 编译 Vite 的 ignored 规则,匹配单个页面来源的词法路径
1748
+ *
1749
+ * @description 返回 true 表示忽略;否定模式优先于其他匹配,目录祖先由调用方检查
1750
+ * @param ignored - Vite 的字符串、正则或函数过滤规则
1751
+ * @param root - 待检查路径的解析基准
1752
+ * @param cwd - watcher 为相对字符串规则指定的基准;未指定时与 chokidar 一致使用 process.cwd()
1753
+ */
1754
+ function createPageWatchFilter(ignored, root, cwd = process.cwd()) {
1755
+ const base = cwd || process.cwd();
1756
+ const normalized = (ignored === void 0 ? [] : Array.isArray(ignored) ? ignored : [ignored]).map((matcher) => {
1757
+ if (typeof matcher !== "string") return matcher;
1758
+ const negative = matcher.startsWith("!");
1759
+ const pattern = negative ? matcher.slice(1) : matcher;
1760
+ const resolved = path.isAbsolute(pattern) || pattern.startsWith("**/") || pattern.startsWith("**\\") ? normalizePath(pattern) : normalizePath(path.resolve(path.join(base, pattern)));
1761
+ return negative ? `!${resolved}` : resolved;
1762
+ });
1763
+ const matchers = [...normalized, ...normalized.filter((matcher) => typeof matcher === "string" && !matcher.startsWith("!") && !picomatch.scan(matcher).isGlob).map((matcher) => `${matcher}/**`)];
1764
+ const negative = matchers.filter((matcher) => typeof matcher === "string" && matcher.startsWith("!")).map((matcher) => picomatch(matcher.slice(1), { dot: true }));
1765
+ const positive = matchers.filter((matcher) => typeof matcher !== "string" || !matcher.startsWith("!")).map((matcher) => {
1766
+ if (typeof matcher === "function") return matcher;
1767
+ if (matcher instanceof RegExp) return (file) => matcher.test(file);
1768
+ const matches = picomatch(matcher, { dot: true });
1769
+ return (file) => file === matcher || matches(file);
1770
+ });
1771
+ return (file, stats) => {
1772
+ const absolute = normalizePath(path.resolve(root, file));
1773
+ return !negative.some((matches) => matches(absolute)) && positive.some((matches) => matches(absolute, stats));
1774
+ };
1775
+ }
1776
+ //#endregion
1777
+ //#region src/page-watch-sources.ts
1778
+ /** 单轮来源发现最多登记的链接来源数,避免别名层叠造成指数级遍历和订阅膨胀 */
1779
+ const PAGE_WATCH_SOURCE_LIMIT = 1024;
1780
+ /** 目标不可访问与缺失一样按暂不可用处理,权限恢复后再由后续刷新重新发现 */
1781
+ function isUnavailableSourceError(error) {
1782
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
1783
+ const code = error.code;
1784
+ return code === "EACCES" || code === "EPERM";
1785
+ }
1786
+ /**
1787
+ * 将监听目标统一到真实路径
1788
+ *
1789
+ * @description 与事件 identity 使用同一基准,避免目标拼写差异(大小写、Unicode
1790
+ * 规范化)导致映射失败;尚未创建的部分保留在最近存在祖先的真实路径下
1791
+ */
1792
+ function canonicalTargetPath(target) {
1793
+ let current = target;
1794
+ const missing = [];
1795
+ while (true) try {
1796
+ const real = fs.realpathSync.native(current);
1797
+ return normalizePath(missing.length ? path.join(real, ...missing) : real);
1798
+ } catch (error) {
1799
+ if (!isMissingFsError(error)) return normalizePath(target);
1800
+ const parent = path.dirname(current);
1801
+ if (parent === current) return normalizePath(target);
1802
+ missing.unshift(path.basename(current));
1803
+ current = parent;
1804
+ }
1805
+ }
1806
+ /** 逐段解析链接,保留悬空目标及恢复所需的链接入口 */
1807
+ function resolveWatchTarget(source) {
1808
+ let remaining = path.resolve(source);
1809
+ const links = /* @__PURE__ */ new Set();
1810
+ const visited = /* @__PURE__ */ new Set();
1811
+ while (!visited.has(remaining) && visited.size < 40) {
1812
+ visited.add(remaining);
1813
+ const root = path.parse(remaining).root;
1814
+ const parts = remaining.slice(root.length).split(path.sep).filter(Boolean);
1815
+ let current = root;
1816
+ let redirected = false;
1817
+ let stat;
1818
+ for (const [index, part] of parts.entries()) {
1819
+ current = path.join(current, part);
1820
+ try {
1821
+ stat = fs.lstatSync(current, { bigint: true });
1822
+ if (stat.isSymbolicLink()) {
1823
+ links.add(normalizePath(current));
1824
+ let linkTarget;
1825
+ try {
1826
+ linkTarget = fs.readlinkSync(current);
1827
+ } catch (error) {
1828
+ if (!isMissingFsError(error) && !isUnavailableSourceError(error)) throw error;
1829
+ return {
1830
+ target: canonicalTargetPath(path.join(current, ...parts.slice(index + 1))),
1831
+ exists: false,
1832
+ links: [...links]
1833
+ };
1834
+ }
1835
+ remaining = path.resolve(path.dirname(current), linkTarget, ...parts.slice(index + 1));
1836
+ redirected = true;
1837
+ break;
1838
+ }
1839
+ } catch (error) {
1840
+ if (!isMissingFsError(error) && !isUnavailableSourceError(error)) throw error;
1841
+ return {
1842
+ target: canonicalTargetPath(path.join(current, ...parts.slice(index + 1))),
1843
+ exists: false,
1844
+ links: [...links]
1845
+ };
1846
+ }
1847
+ }
1848
+ if (!redirected) {
1849
+ stat ??= fs.lstatSync(root, { bigint: true });
1850
+ return {
1851
+ target: canonicalTargetPath(current),
1852
+ exists: true,
1853
+ links: [...links],
1854
+ kind: stat.isDirectory() ? "directory" : "file",
1855
+ identity: `${stat.dev}:${stat.ino}`
1856
+ };
1857
+ }
1858
+ }
1859
+ return {
1860
+ target: canonicalTargetPath(remaining),
1861
+ exists: false,
1862
+ links: [...links]
1863
+ };
1864
+ }
1865
+ /**
1866
+ * 发现声明目录及其嵌套链接的监听依赖
1867
+ *
1868
+ * @description 按词法入口应用过滤;同目标的别名分别保留,只有当前祖先链中的循环停止展开
1869
+ * 链接来源超过 {@link PAGE_WATCH_SOURCE_LIMIT} 时停止继续展开,页面扫描与生成不受影响
1870
+ */
1871
+ function collectPageWatchSources(roots, excluded) {
1872
+ const directories = /* @__PURE__ */ new Map();
1873
+ const files = /* @__PURE__ */ new Map();
1874
+ const links = /* @__PURE__ */ new Set();
1875
+ const entries = /* @__PURE__ */ new Map();
1876
+ let discovered = 0;
1877
+ let truncated = false;
1878
+ const visit = (source, target, ancestors) => {
1879
+ if (truncated || ancestors.has(target)) return;
1880
+ const branch = new Set(ancestors).add(target);
1881
+ let children = entries.get(target);
1882
+ if (!children) {
1883
+ try {
1884
+ children = fs.readdirSync(target, { withFileTypes: true });
1885
+ } catch (error) {
1886
+ if (!isMissingFsError(error) && !isUnavailableSourceError(error)) throw error;
1887
+ return;
1888
+ }
1889
+ entries.set(target, children);
1890
+ }
1891
+ for (const child of children) {
1892
+ if (truncated) return;
1893
+ if (!child.isDirectory() && !child.isSymbolicLink()) continue;
1894
+ const lexical = normalizePath(path.join(source, child.name));
1895
+ if (excluded(lexical)) continue;
1896
+ if (child.isSymbolicLink()) {
1897
+ if (discovered >= 1024) {
1898
+ truncated = true;
1899
+ return;
1900
+ }
1901
+ const resolved = resolveWatchTarget(lexical);
1902
+ const file = resolved.kind === "file" || !resolved.exists && isTargetFile(lexical);
1903
+ if (file && !isTargetFile(lexical)) continue;
1904
+ discovered++;
1905
+ for (const link of resolved.links) links.add(link);
1906
+ if (file) files.set(lexical, resolved);
1907
+ else {
1908
+ directories.set(lexical, resolved);
1909
+ if (resolved.exists) visit(lexical, resolved.target, branch);
1910
+ }
1911
+ } else visit(lexical, normalizePath(path.join(target, child.name)), branch);
1912
+ }
1913
+ };
1914
+ for (const root of roots) {
1915
+ if (truncated) break;
1916
+ if (excluded(root)) continue;
1917
+ const resolved = resolveWatchTarget(root);
1918
+ directories.set(root, resolved);
1919
+ for (const link of resolved.links) links.add(link);
1920
+ if (resolved.kind === "directory") visit(root, resolved.target, /* @__PURE__ */ new Set());
1921
+ }
1922
+ return {
1923
+ directories,
1924
+ files,
1925
+ links,
1926
+ truncated
1927
+ };
1928
+ }
1929
+ //#endregion
1930
+ //#region src/scan-plan.ts
1931
+ /** 发现当前声明对应的扫描计划;目录、身份与回调都在这里刷新 */
1932
+ function createScanPlan(options) {
1933
+ const { sources, projection } = discoverSubPackageDirectories(options.subPackagePatterns, options.root, options.outDir, options.exclude);
1934
+ return {
1935
+ root: options.root,
1936
+ basePath: normalizePath(path.join(options.root, options.outDir)),
1937
+ mainDirs: resolvePageDirs(options.dir, options.root, options.exclude).map((dir) => dir.replace(/\/$/, "")),
1938
+ subPackageDirs: projection.subPackages,
1939
+ subPackageSources: sources,
1940
+ subPackageRootMap: new Map(projection.subPackageRootMap),
1941
+ rules: getPageRules(options)
1942
+ };
1943
+ }
1944
+ /**
1945
+ * 把计划同步回 ResolvedOptions。
1946
+ *
1947
+ * `dirs`、`subPackages` 与 `subPackageRootMap` 是给既有调用方和测试看的
1948
+ * 兼容投影;内部扫描与监听应读取 ScanPlan,而不是这些字段。
1949
+ */
1950
+ function applyScanPlan(options, plan) {
1951
+ options.dirs = [...plan.mainDirs];
1952
+ options.subPackages = [...plan.subPackageDirs];
1953
+ options.subPackageRootMap = new Map(plan.subPackageRootMap);
1954
+ }
1955
+ //#endregion
1956
+ //#region src/page-watch.ts
1957
+ function contains(directory, file) {
1958
+ return file === directory || file.startsWith(`${directory.replace(/\/$/, "")}/`);
1959
+ }
1960
+ function isDirectory(file) {
1961
+ try {
1962
+ return fs.statSync(file, { throwIfNoEntry: false })?.isDirectory() ?? false;
1963
+ } catch (error) {
1964
+ if (!isMissingFsError(error) && !isUnavailableSourceError(error) && error.code !== "ELOOP") throw error;
1965
+ return false;
1966
+ }
1967
+ }
1968
+ /**
1969
+ * 一轮监听刷新内复用的 Vite ignored 判定
1970
+ *
1971
+ * 监听检查沿候选路径逐级询问 ignored,同一批页面共享的祖先目录会在
1972
+ * 大量候选之间重复出现。每个路径在一轮内只求值一次,stat 次数从
1973
+ * “候选数 × 深度”降到“路径数”。来源结构或权限变化由下一次 refresh
1974
+ * 的 clear() 重新计算。
1975
+ */
1976
+ var SourceIgnoreIndex = class {
1977
+ ignored;
1978
+ verdicts = /* @__PURE__ */ new Map();
1979
+ constructor(ignored) {
1980
+ this.ignored = ignored;
1981
+ }
1982
+ /** 丢弃本轮结论;新的来源结构、忽略规则或权限要重新询问 */
1983
+ clear() {
1984
+ this.verdicts.clear();
1985
+ }
1986
+ /** 判定路径是否被忽略;祖先结论会被后代复用 */
1987
+ matches(file, stats) {
1988
+ const cached = this.verdicts.get(file);
1989
+ if (cached !== void 0) return cached;
1990
+ let entry = stats;
1991
+ if (!entry) try {
1992
+ entry = fs.statSync(file, { throwIfNoEntry: false });
1993
+ } catch (error) {
1994
+ if (isUnavailableSourceError(error)) {
1995
+ this.verdicts.set(file, true);
1996
+ return true;
1997
+ }
1998
+ if (!isMissingFsError(error) && error.code !== "ELOOP") throw error;
1999
+ }
2000
+ if (this.ignored(file, entry)) {
2001
+ this.verdicts.set(file, true);
2002
+ return true;
2003
+ }
2004
+ const parent = normalizePath(path.dirname(file));
2005
+ const inherited = parent === file ? false : this.matches(parent);
2006
+ this.verdicts.set(file, inherited);
2007
+ return inherited;
2008
+ }
2009
+ };
2010
+ /** 当前词法页面来源、物理内容订阅及非递归入口观察 */
2011
+ var PageSourceWatch = class {
2012
+ options;
2013
+ onFile;
2014
+ onStructure;
2015
+ onError;
2016
+ onWarning;
2017
+ rules;
2018
+ roots = /* @__PURE__ */ new Set();
2019
+ links = /* @__PURE__ */ new Set();
2020
+ directories = /* @__PURE__ */ new Map();
2021
+ files = /* @__PURE__ */ new Map();
2022
+ contentKey = "";
2023
+ contentRules;
2024
+ recoveryWatchers = /* @__PURE__ */ new Map();
2025
+ closing = /* @__PURE__ */ new Set();
2026
+ sourceRefreshQueued = false;
2027
+ started = false;
2028
+ warnedSourceLimit = false;
2029
+ usePolling = false;
2030
+ interval = 100;
2031
+ awaitWriteFinish;
2032
+ atomic;
2033
+ ignored;
2034
+ /** 当前 ignored 规则的判定缓存,随刷新清空 */
2035
+ sourceIgnored;
2036
+ watcher;
2037
+ constructor(options, onFile, onStructure, onError, onWarning = () => {}) {
2038
+ this.options = options;
2039
+ this.onFile = onFile;
2040
+ this.onStructure = onStructure;
2041
+ this.onError = onError;
2042
+ this.onWarning = onWarning;
2043
+ this.rules = getPageRules(options);
2044
+ }
2045
+ /** 内容监听是否已建立;启动失败时调用方可用其他事件源重试 */
2046
+ get running() {
2047
+ return this.started;
2048
+ }
2049
+ start(options = {}, ignored, plan) {
2050
+ if (this.started) return;
2051
+ const polling = process.env.CHOKIDAR_USEPOLLING?.toLowerCase();
2052
+ this.usePolling = polling === void 0 ? !!options.usePolling : !!polling && polling !== "false" && polling !== "0";
2053
+ const interval = process.env.CHOKIDAR_INTERVAL ? Number.parseInt(process.env.CHOKIDAR_INTERVAL, 10) : options.interval ?? 100;
2054
+ this.interval = Number.isFinite(interval) && interval > 0 ? interval : 100;
2055
+ this.awaitWriteFinish = options.awaitWriteFinish;
2056
+ this.atomic = options.atomic;
2057
+ this.ignored = ignored === void 0 || Array.isArray(ignored) && ignored.length === 0 ? void 0 : createPageWatchFilter(ignored, this.options.root, options.cwd ?? process.cwd());
2058
+ this.sourceIgnored = this.ignored ? new SourceIgnoreIndex(this.ignored) : void 0;
2059
+ this.started = true;
2060
+ try {
2061
+ this.refresh(plan ?? createScanPlan(this.options));
2062
+ } catch (error) {
2063
+ this.started = false;
2064
+ throw error;
2065
+ }
2066
+ }
2067
+ ignoresSource(source, stats) {
2068
+ return this.sourceIgnored?.matches(source, stats) ?? false;
2069
+ }
2070
+ createContentWatcher() {
2071
+ const watcher = chokidar.watch([], {
2072
+ ignoreInitial: true,
2073
+ followSymlinks: false,
2074
+ usePolling: this.usePolling,
2075
+ interval: this.interval,
2076
+ ...this.awaitWriteFinish !== void 0 && { awaitWriteFinish: this.awaitWriteFinish },
2077
+ ...this.atomic !== void 0 && { atomic: this.atomic },
2078
+ ignored: (file, stats) => {
2079
+ const absolute = normalizePath(path.resolve(file));
2080
+ if (contains(normalizePath(path.resolve(this.options.root, this.options.outDir, "pages.json.lock")), absolute)) return true;
2081
+ for (const [source, { target }] of this.files) if (contains(absolute, target) && !this.rules.isExcluded(source) && !this.ignoresSource(source)) return false;
2082
+ for (const [source, { target }] of this.directories) {
2083
+ if (contains(absolute, target)) return false;
2084
+ if (contains(target, absolute)) {
2085
+ const lexical = normalizePath(path.join(source, path.relative(target, absolute)));
2086
+ if (!this.rules.isExcluded(lexical) && !this.ignoresSource(lexical, stats)) return false;
2087
+ }
2088
+ }
2089
+ return true;
2090
+ }
2091
+ });
2092
+ for (const event of [
2093
+ "add",
2094
+ "change",
2095
+ "unlink"
2096
+ ]) watcher.on(event, (file) => {
2097
+ if (!this.started || this.watcher !== watcher) return;
2098
+ if (this.links.has(normalizePath(path.resolve(file)))) this.queueSourceRefresh();
2099
+ else this.onFile(event, file);
2100
+ });
2101
+ watcher.on("raw", (event, file, raw) => {
2102
+ if (!this.started || this.watcher !== watcher) return;
2103
+ const details = raw ?? {};
2104
+ if (details.curr?.isDirectory()) this.queueSourceRefresh();
2105
+ else if (event === "rename" && details.watchedPath) {
2106
+ if (!file) {
2107
+ this.queueSourceRefresh();
2108
+ return;
2109
+ }
2110
+ const entry = normalizePath(path.resolve(details.watchedPath, file));
2111
+ try {
2112
+ if (this.links.has(entry) || fs.lstatSync(entry, { throwIfNoEntry: false })?.isSymbolicLink()) this.queueSourceRefresh();
2113
+ } catch (error) {
2114
+ if (!isMissingFsError(error)) this.onError(error);
2115
+ }
2116
+ }
2117
+ });
2118
+ const onStructure = () => {
2119
+ if (this.started && this.watcher === watcher) this.onStructure();
2120
+ };
2121
+ watcher.on("addDir", onStructure);
2122
+ watcher.on("unlinkDir", onStructure);
2123
+ watcher.on("ready", onStructure);
2124
+ watcher.on("error", (error) => this.onError(error));
2125
+ return watcher;
2126
+ }
2127
+ /** 来源声明或扫描结果变化后重新解析当前目标 */
2128
+ refresh(plan) {
2129
+ if (!this.started) return;
2130
+ this.sourceIgnored?.clear();
2131
+ const rules = plan.rules;
2132
+ const currentDirectories = /* @__PURE__ */ new Set([
2133
+ ...this.roots,
2134
+ ...plan.mainDirs.map((dir) => normalizePath(path.resolve(this.options.root, dir))),
2135
+ ...plan.subPackageDirs.map((dir) => normalizePath(path.resolve(this.options.root, dir)))
2136
+ ]);
2137
+ for (const pattern of rules.patterns) {
2138
+ const portable = normalizePagePattern(pattern);
2139
+ if (!picomatch.scan(portable).isGlob) currentDirectories.add(normalizePath(path.resolve(this.options.root, unescapePagePattern(portable))));
2140
+ }
2141
+ this.roots = new Set([...currentDirectories].filter((source) => this.options.mergePages && rules.matchesDirectory(source)));
2142
+ const isExcluded = rules.createExcludeFilter();
2143
+ const sources = collectPageWatchSources(this.roots, (source) => isExcluded(source) || this.ignoresSource(source));
2144
+ this.rules = rules;
2145
+ this.directories = sources.directories;
2146
+ this.files = sources.files;
2147
+ this.links = sources.links;
2148
+ this.reconcile();
2149
+ if (sources.truncated) {
2150
+ if (!this.warnedSourceLimit) {
2151
+ this.warnedSourceLimit = true;
2152
+ this.onWarning(`Page watch discovery reached the ${PAGE_WATCH_SOURCE_LIMIT} linked-source safety limit and stopped expanding this round. Pages are still generated, but sources beyond the limit are not watched while it is reached. Narrow the page roots or exclude unused links to restore full watch coverage.`);
2153
+ }
2154
+ } else this.warnedSourceLimit = false;
2155
+ }
2156
+ fileForEvent(file, identity) {
2157
+ const absolute = normalizePath(path.resolve(this.options.root, file));
2158
+ if (isTargetFile(absolute) && isPageFileInDirectories(absolute, this.options) && !this.ignoresSource(absolute)) return absolute;
2159
+ for (const [source, { target }] of this.files) if (target === identity && isPageFileInDirectories(source, this.options) && !this.ignoresSource(source) && resolveWatchTarget(source).target === target) return source;
2160
+ for (const [source, { target }] of this.directories) {
2161
+ if (!contains(target, identity) || resolveWatchTarget(source).target !== target) continue;
2162
+ const lexical = normalizePath(path.join(source, path.relative(target, identity)));
2163
+ if (isTargetFile(lexical) && isPageFileInDirectories(lexical, this.options) && !this.ignoresSource(lexical) && resolveWatchTarget(lexical).target === identity) return lexical;
2164
+ }
2165
+ }
2166
+ directoryForEvent(directory) {
2167
+ if (!this.options.mergePages) return false;
2168
+ const absolute = normalizePath(path.resolve(this.options.root, directory));
2169
+ if (!this.ignoresSource(absolute) && (getPageRules(this.options).matchesDirectory(absolute) || [...this.directories.keys()].some((source) => contains(absolute, source)))) return true;
2170
+ return [...this.directories, ...this.files].some(([source, { target }]) => contains(absolute, target) && (isPageDirectoryEvent(source, this.options) || isPageFileInDirectories(source, this.options)) && !this.ignoresSource(source) && resolveWatchTarget(source).target === target);
2171
+ }
2172
+ async close() {
2173
+ this.started = false;
2174
+ for (const { close } of this.recoveryWatchers.values()) close();
2175
+ this.recoveryWatchers.clear();
2176
+ this.contentKey = "";
2177
+ this.contentRules = void 0;
2178
+ this.sourceIgnored = void 0;
2179
+ this.warnedSourceLimit = false;
2180
+ const watcher = this.watcher;
2181
+ this.watcher = void 0;
2182
+ await Promise.all([watcher?.close(), ...this.closing]);
2183
+ }
2184
+ reconcile() {
2185
+ const directories = [...this.directories.values()].filter((source) => source.exists);
2186
+ const covered = (target) => directories.some((directory) => directory.target !== target && contains(directory.target, target));
2187
+ const wanted = /* @__PURE__ */ new Set();
2188
+ for (const target of [...directories, ...this.files.values()].filter((source) => source.exists)) if (!covered(target.target)) wanted.add(target.target);
2189
+ const key = this.sourceSignature();
2190
+ if (key !== this.contentKey || this.contentRules !== this.rules) {
2191
+ if (this.watcher) {
2192
+ const closing = this.watcher.close();
2193
+ this.closing.add(closing);
2194
+ closing.then(() => this.closing.delete(closing), (error) => {
2195
+ this.closing.delete(closing);
2196
+ this.onError(error);
2197
+ });
2198
+ this.watcher = void 0;
2199
+ }
2200
+ if (wanted.size) {
2201
+ this.watcher = this.createContentWatcher();
2202
+ this.watcher.add([...wanted]);
2203
+ }
2204
+ this.contentKey = key;
2205
+ this.contentRules = this.rules;
2206
+ }
2207
+ this.refreshEntrances();
2208
+ }
2209
+ refreshFromEntrance() {
2210
+ try {
2211
+ const previous = this.sourceSignature();
2212
+ this.refresh(createScanPlan(this.options));
2213
+ if (this.sourceSignature() !== previous) this.onStructure();
2214
+ } catch (error) {
2215
+ this.onError(error);
2216
+ }
2217
+ }
2218
+ queueSourceRefresh() {
2219
+ if (this.sourceRefreshQueued) return;
2220
+ this.sourceRefreshQueued = true;
2221
+ queueMicrotask(() => {
2222
+ this.sourceRefreshQueued = false;
2223
+ if (this.started) this.refreshFromEntrance();
2224
+ });
2225
+ }
2226
+ sourceSignature() {
2227
+ return JSON.stringify([...this.directories, ...this.files].map(([source, { target, exists, kind, identity }]) => [
2228
+ source,
2229
+ target,
2230
+ exists,
2231
+ kind,
2232
+ kind === "directory" ? identity : null
2233
+ ]).sort(([a], [b]) => String(a).localeCompare(String(b))));
2234
+ }
2235
+ refreshEntrances() {
2236
+ const wanted = /* @__PURE__ */ new Map();
2237
+ const observe = (directory, entry) => {
2238
+ const normalized = normalizePath(directory);
2239
+ if (!entry) {
2240
+ wanted.set(normalized, void 0);
2241
+ return;
2242
+ }
2243
+ if (wanted.has(normalized) && !wanted.get(normalized)) return;
2244
+ const entries = wanted.get(normalized) ?? /* @__PURE__ */ new Set();
2245
+ entries.add(entry);
2246
+ wanted.set(normalized, entries);
2247
+ };
2248
+ const entrance = (file) => {
2249
+ let child = file;
2250
+ let directory = path.dirname(child);
2251
+ while (!isDirectory(directory)) {
2252
+ child = directory;
2253
+ const parent = path.dirname(directory);
2254
+ if (parent === directory) return;
2255
+ directory = parent;
2256
+ }
2257
+ observe(directory, path.basename(child));
2258
+ };
2259
+ for (const [source, resolved] of [...this.directories, ...this.files]) {
2260
+ entrance(source);
2261
+ entrance(resolved.target);
2262
+ for (const link of resolved.links) entrance(link);
2263
+ }
2264
+ if (this.options.mergePages) {
2265
+ const rules = getPageRules(this.options);
2266
+ for (const pattern of rules.patterns) {
2267
+ const portable = normalizePagePattern(pattern);
2268
+ const normalized = path.posix.normalize(portable);
2269
+ const scan = picomatch.scan(normalized, { parts: true });
2270
+ if (!scan.isGlob) continue;
2271
+ const base = normalizePath(path.resolve(this.options.root, unescapePagePattern(scan.base || ".")));
2272
+ if (rules.isExcluded(base) || this.ignoresSource(base)) continue;
2273
+ entrance(base);
2274
+ if (isDirectory(base)) observe(base);
2275
+ const parts = scan.parts ?? [];
2276
+ for (let length = 1; length < parts.length; length++) {
2277
+ const prefix = parts.slice(0, length).join("/") || "/";
2278
+ if (!picomatch.scan(prefix).isGlob) continue;
2279
+ const directories = globSync(prefix, {
2280
+ cwd: this.options.root,
2281
+ onlyDirectories: true,
2282
+ expandDirectories: false,
2283
+ dot: true,
2284
+ ignore: rules.traversalIgnores
2285
+ });
2286
+ for (const directory of directories) {
2287
+ const absolute = normalizePath(path.resolve(this.options.root, directory));
2288
+ if (!rules.isExcluded(absolute) && !this.ignoresSource(absolute)) {
2289
+ entrance(absolute);
2290
+ observe(absolute);
2291
+ }
2292
+ }
2293
+ }
2294
+ }
2295
+ }
2296
+ for (const [directory, entry] of this.recoveryWatchers) if (!wanted.has(directory)) {
2297
+ entry.close();
2298
+ this.recoveryWatchers.delete(directory);
2299
+ }
2300
+ for (const [directory, entries] of wanted) try {
2301
+ const stat = fs.statSync(directory, { bigint: true });
2302
+ const identity = `${stat.dev}:${stat.ino}`;
2303
+ const existing = this.recoveryWatchers.get(directory);
2304
+ if (existing?.identity === identity) {
2305
+ existing.entries = entries;
2306
+ continue;
2307
+ }
2308
+ existing?.close();
2309
+ let entry;
2310
+ let watcher;
2311
+ let stop;
2312
+ if (this.usePolling) {
2313
+ const onChange = () => {
2314
+ if (this.recoveryWatchers.get(directory) === entry) this.queueSourceRefresh();
2315
+ };
2316
+ watcher = fs.watchFile(directory, {
2317
+ persistent: false,
2318
+ interval: this.interval
2319
+ }, onChange);
2320
+ stop = () => fs.unwatchFile(directory, onChange);
2321
+ } else {
2322
+ const nativeWatcher = fs.watch(directory, {
2323
+ recursive: false,
2324
+ persistent: false
2325
+ }, (event, filename) => {
2326
+ const observed = this.recoveryWatchers.get(directory);
2327
+ if (observed !== entry || event !== "rename") return;
2328
+ const name = filename?.toString();
2329
+ if (!name || !observed.entries || observed.entries.has(name) || name === path.basename(directory)) this.refreshFromEntrance();
2330
+ });
2331
+ watcher = nativeWatcher;
2332
+ stop = () => nativeWatcher.close();
2333
+ }
2334
+ const onError = (error) => {
2335
+ if (this.recoveryWatchers.get(directory) !== entry) return;
2336
+ entry.close();
2337
+ this.recoveryWatchers.delete(directory);
2338
+ if (!isMissingFsError(error)) this.onError(error);
2339
+ if (this.started) this.refreshFromEntrance();
2340
+ };
2341
+ watcher.on("error", onError);
2342
+ entry = {
2343
+ watcher,
2344
+ entries,
2345
+ identity,
2346
+ close: () => {
2347
+ watcher.off("error", onError);
2348
+ stop();
2349
+ }
2350
+ };
2351
+ this.recoveryWatchers.set(directory, entry);
2352
+ } catch (error) {
2353
+ if (!isMissingFsError(error) && !isUnavailableSourceError(error)) this.onError(error);
2354
+ }
2355
+ }
2356
+ };
2357
+ //#endregion
819
2358
  //#region src/platform-variants.ts
820
2359
  /**
821
2360
  * 把 `||` 分隔的平台列表拆开,去掉当前平台,剩下的排好序(规则 1)
@@ -830,7 +2369,7 @@ function platformsExcluding(platformList, currentPlatform) {
830
2369
  return platformList.split("||").map((p) => p.trim()).filter((p) => p && p !== currentPlatform).sort();
831
2370
  }
832
2371
  /**
833
- * 把本次运行的一个载荷并入变体列表(规则 2):
2372
+ * 把本轮的一个载荷并入变体列表(规则 2):
834
2373
  * - 内容相同的手写 #ifndef 变体优先:当前平台没被排除时,那条
835
2374
  * #ifndef 已经覆盖了它;当前平台正好被排除时,尊重手写的隐藏规
836
2375
  * 则。两种情况下再单独写一份都会产生重复路由或覆盖用户的条件
@@ -838,7 +2377,7 @@ function platformsExcluding(platformList, currentPlatform) {
838
2377
  * - 没有内容相同的变体:新建只属于当前平台的变体
839
2378
  *
840
2379
  * @param variants - 同一个键下已有的变体列表,原地修改
841
- * @param payload - 本次运行的载荷
2380
+ * @param payload - 本轮的载荷
842
2381
  * @param valueStr - 载荷的比较字符串
843
2382
  * @param currentPlatform - 当前平台标识(大写)
844
2383
  * @param compatible - 可选的额外相容判断。数组侧用它区分两个平台各自
@@ -866,7 +2405,7 @@ function upsertCurrentVariant(variants, payload, valueStr, currentPlatform, comp
866
2405
  *
867
2406
  * 当前平台的变体在收集时被排除、并入时又补到列表末尾,两个平台轮流
868
2407
  * 运行(dev:h5 和 dev:mp-weixin 交替写)时,同一个键的变体顺序就会
869
- * 来回翻转——条件编译的结果没变,但文件每次都有无意义的变动。
2408
+ * 来回翻转。条件编译的结果没变,但文件每次都有无意义的变动。
870
2409
  * platformStr 由排好序的平台列表拼成,手写 #ifndef 变体是空串、排最
871
2410
  * 前。排序是稳定的:并列时(同键两组平台列表相同,只出现在手写文件
872
2411
  * 里)保持收集顺序
@@ -875,6 +2414,61 @@ function sortVariants(variants) {
875
2414
  variants.sort((a, b) => a.platformStr < b.platformStr ? -1 : a.platformStr > b.platformStr ? 1 : 0);
876
2415
  }
877
2416
  /**
2417
+ * 决定变体列表的输出顺序(规则 6)
2418
+ *
2419
+ * 此前条目一律按磁盘上的旧顺序输出、配置里新增的条目追加到末尾:
2420
+ * 「哪个平台先构建」决定了文件长什么样,配置里的声明顺序会被丢掉
2421
+ * (比如某平台把一个新条目声明在 tabBar 第二位,生成的文件里却垫
2422
+ * 着底,该平台编译剥掉注释后实际顺序就是错的)。
2423
+ *
2424
+ * 排序分两种情况:
2425
+ * - 配置声明了全部既有键:这是全量超集,声明顺序是唯一已知的权威
2426
+ * 顺序,整个列表按配置顺序输出
2427
+ * - 配置没有覆盖全部键(其他平台有本平台不认识的条目,谁也不知道
2428
+ * 完整顺序):既有键保持磁盘序,只把新增的键插到它前面最近
2429
+ * 的、已在输出里的声明键之后;没有这样的前驱就放到最前。既有键
2430
+ * 之间的相对顺序永不变动,所以两端构建不会来回翻转
2431
+ *
2432
+ * 两个平台的配置都覆盖全部键但顺序不同时,共享文件跟随最后写入者。
2433
+ * 条目内容相同而合并成一条时没有按平台区分的副本,另一个正在运行
2434
+ * 的平台在下一次生成前可能读到对方顺序;并发开发时建议各平台保持
2435
+ * 一致的完整 tab 顺序。
2436
+ *
2437
+ * @param mergedMap - 键 → 变体列表的合并结果。迭代顺序一般是磁盘序,
2438
+ * 但被跳过的当前平台旧条目会追加到末尾,需要靠 options.diskOrder 修正
2439
+ * @param declaredKeys - 配置声明的键,按配置顺序,可含新增键;
2440
+ * 传 null 表示不启用配置序摆放,保持磁盘序
2441
+ * @param options - 磁盘上已有键的顺序,见 {@link OrderVariantListsOptions}
2442
+ * @returns 按输出顺序排列的变体列表(mergedMap 中列表的原引用)
2443
+ */
2444
+ function orderVariantLists(mergedMap, declaredKeys, options = {}) {
2445
+ const diskOrder = [...mergedMap.keys()];
2446
+ if (mergedMap.size === 0 || declaredKeys === null) return diskOrder.map((key) => mergedMap.get(key));
2447
+ const declared = [...new Set(declaredKeys)].filter((key) => mergedMap.has(key));
2448
+ const declaredSet = new Set(declared);
2449
+ if (diskOrder.every((key) => declaredSet.has(key))) return declared.map((key) => mergedMap.get(key));
2450
+ const ordered = (options.diskOrder ?? diskOrder).filter((key) => mergedMap.has(key));
2451
+ const placed = new Set(ordered);
2452
+ for (const [index, key] of declared.entries()) {
2453
+ if (placed.has(key)) continue;
2454
+ let anchor = -1;
2455
+ for (let i = index - 1; i >= 0; i--) {
2456
+ const candidate = ordered.indexOf(declared[i]);
2457
+ if (candidate !== -1) {
2458
+ anchor = candidate;
2459
+ break;
2460
+ }
2461
+ }
2462
+ ordered.splice(anchor + 1, 0, key);
2463
+ placed.add(key);
2464
+ }
2465
+ for (const key of diskOrder) if (!placed.has(key)) {
2466
+ ordered.push(key);
2467
+ placed.add(key);
2468
+ }
2469
+ return ordered.map((key) => mergedMap.get(key));
2470
+ }
2471
+ /**
878
2472
  * 收集所有变体记录过的平台(手写 #ifndef 不算,它没有正向列表),
879
2473
  * 这个集合同时也是「拥有至少一个变体」的平台集合
880
2474
  */
@@ -890,7 +2484,7 @@ function collectVariantPlatforms(mergedMap) {
890
2484
  * 没被包裹的条目在 uni-app 条件编译下所有平台都看得见,所以只有
891
2485
  * 覆盖了全部平台的变体才能"裸着"输出(规则 5)。
892
2486
  *
893
- * 当前平台一定在全集里,哪怕这次运行一个变体都没贡献——比如页面
2487
+ * 当前平台一定在全集里,哪怕这次运行一个变体都没贡献。比如页面
894
2488
  * 只在 H5 存在,而 MP-WEIXIN 运行把每个页面都 definePage(null) 掉了。
895
2489
  * 如果只从剩下的条目推全集,全集就只有 'H5',H5 专属条目看起来
896
2490
  * "覆盖了全集",会不加包裹地输出,泄漏进微信的构建产物。老版本按
@@ -906,7 +2500,7 @@ function collectVariantPlatforms(mergedMap) {
906
2500
  * 裹,不会泄漏。
907
2501
  *
908
2502
  * 只增不减的代价:彻底不用的平台会一直留在标记里。这无害,但别
909
- * 手工去改标记行——想清理就删掉整个生成的 pages.json,下次运行会
2503
+ * 手工去改标记行。想清理就删掉整个生成的 pages.json,下次运行会
910
2504
  * 从头生成。
911
2505
  */
912
2506
  function resolvePlatformUnion(mergedMap, currentPlatform, lastPlatforms) {
@@ -940,6 +2534,252 @@ function extractLastPlatforms(src, currentPlatform) {
940
2534
  return lastPlatforms;
941
2535
  }
942
2536
  //#endregion
2537
+ //#region src/pages-json-tokens.ts
2538
+ /** 构建用于序列化 pages.json 输出或数组元素包裹的行注释令牌 */
2539
+ function lineComment(value) {
2540
+ return {
2541
+ type: "LineComment",
2542
+ value,
2543
+ inline: false,
2544
+ loc: {
2545
+ start: {
2546
+ line: 0,
2547
+ column: 0
2548
+ },
2549
+ end: {
2550
+ line: 0,
2551
+ column: 0
2552
+ }
2553
+ }
2554
+ };
2555
+ }
2556
+ /**
2557
+ * 找到条目前面的条件编译开启指令。
2558
+ *
2559
+ * comment-json 会把包裹层的 #endif(输出在值的逗号后面)挂到下一个条目
2560
+ * 前面,所以只认 #ifdef/#ifndef 开头,绝不能把 #endif 当成开启符。
2561
+ */
2562
+ function findConditionalComment(beforeComments) {
2563
+ return beforeComments?.find((comment) => comment.type !== "BlankLine" && /^#(?:ifdef|ifndef)/.test(comment.value.trim()));
2564
+ }
2565
+ /**
2566
+ * 手写 #ifndef 块原样保留时清理包裹注释。
2567
+ *
2568
+ * 只保留开启这个块的那条指令和用户写的说明文字;前一个块收尾的 #endif
2569
+ * 和重复的结构指令都要丢掉,否则每次重跑都会多出游离的 #endif 或嵌套。
2570
+ */
2571
+ function sanitizePassthroughComments(beforeComments, opening) {
2572
+ const sanitized = [];
2573
+ let openingKept = false;
2574
+ for (const comment of beforeComments || []) {
2575
+ if (isGenerationMarker(comment)) continue;
2576
+ if (comment.type === "BlankLine") {
2577
+ sanitized.push(comment);
2578
+ continue;
2579
+ }
2580
+ if (/^#(?:ifdef|ifndef|endif)/.test(comment.value.trim())) {
2581
+ if (!openingKept && comment === opening) {
2582
+ sanitized.push(comment);
2583
+ openingKept = true;
2584
+ }
2585
+ continue;
2586
+ }
2587
+ sanitized.push(comment);
2588
+ }
2589
+ return sanitized;
2590
+ }
2591
+ //#endregion
2592
+ //#region src/pages-json-items.ts
2593
+ /**
2594
+ * 把条目转成字符串用于比较,比较前先拿掉内部的 `type` 标记。
2595
+ *
2596
+ * 从 pages.json 读回来的条目可能没有这个标记(手写条目从不带它),
2597
+ * 直接 JSON.stringify 会把同一个页面当成两个不同的条目,多平台运行时
2598
+ * 就会生成重复路由(见 https://github.com/uni-helper/vite-plugin-uni-pages/issues/283)。
2599
+ *
2600
+ * 键的顺序也参与比较(JSON.stringify 按写入顺序输出):同一页面在两个
2601
+ * 平台下键序不同时不会被合并,会各占一个 #ifdef 块留在文件里。代价只是
2602
+ * 多一份内容相同的变体,构建时每个平台仍只看自己那一条。
2603
+ */
2604
+ function stringifyForCompare(val) {
2605
+ if ("type" in val) {
2606
+ const { type: _type, ...rest } = val;
2607
+ return JSON.stringify(rest);
2608
+ }
2609
+ return JSON.stringify(val);
2610
+ }
2611
+ /**
2612
+ * 两个内容相同的条目,首页状态是否一致?
2613
+ *
2614
+ * `type` 标记不参与内容比较,没有标记的条目也能和扫描到的条目合并。
2615
+ * 但如果两边都明确带着标记、而且一个是 'home' 一个是 'page',说明这是
2616
+ * 两个平台各自声明的不同首页,必须留在各自的 #ifdef 块里。
2617
+ */
2618
+ function homeStatusCompatible(a, b) {
2619
+ const typeA = a.type;
2620
+ const typeB = b.type;
2621
+ return typeA === void 0 || typeB === void 0 || typeA === typeB;
2622
+ }
2623
+ /**
2624
+ * 合并多平台的配置条目(数组侧适配器)。
2625
+ *
2626
+ * 处理条件编译注释(#ifdef / #ifndef / #endif),把 pages.json 里
2627
+ * 已有的条目和本轮扫描的条目合并成一个数组;合并规则(同值叠平台、
2628
+ * 排除当前平台、排序、并集)用 platform-variants.ts 的共享核心。
2629
+ *
2630
+ * @param source - 现有条目数组(来自 pages.json)
2631
+ * @param currentPlatform - 当前平台标识(如 H5、MP-WEIXIN)
2632
+ * @param items - 新配置条目数组
2633
+ * @param uniqueKeyName - 用于识别条目唯一性的字段名(如 'path' 或 'pagePath')
2634
+ * @param options - 输出顺序选项,见 {@link MergePlatformItemsOptions}
2635
+ * @returns 带 #ifdef 块的合并条目,以及平台全集与拥有平台
2636
+ */
2637
+ function mergePlatformItems(source, currentPlatform, items, uniqueKeyName, options = {}) {
2638
+ const { orderByConfig = false } = options;
2639
+ const src = source || new CommentArray();
2640
+ currentPlatform = currentPlatform.toUpperCase();
2641
+ const lastPlatforms = extractLastPlatforms(src, currentPlatform);
2642
+ const mergedMap = /* @__PURE__ */ new Map();
2643
+ const sourceOrder = orderByConfig ? {
2644
+ seen: /* @__PURE__ */ new Set(),
2645
+ order: []
2646
+ } : void 0;
2647
+ const variantsOf = (uniqueKey) => {
2648
+ const list = mergedMap.get(uniqueKey) || [];
2649
+ mergedMap.set(uniqueKey, list);
2650
+ return list;
2651
+ };
2652
+ for (let i = 0; i < src.length; i++) {
2653
+ const item = src[i];
2654
+ const uniqueKey = item[uniqueKeyName];
2655
+ if (!uniqueKey) continue;
2656
+ if (sourceOrder && !sourceOrder.seen.has(uniqueKey)) {
2657
+ sourceOrder.seen.add(uniqueKey);
2658
+ sourceOrder.order.push(uniqueKey);
2659
+ }
2660
+ const beforeComments = src[Symbol.for(`before:${i}`)];
2661
+ const conditionalComment = findConditionalComment(beforeComments);
2662
+ if (conditionalComment?.value.trim().startsWith("#ifndef")) {
2663
+ variantsOf(uniqueKey).push({
2664
+ payload: item,
2665
+ valueStr: stringifyForCompare(item),
2666
+ platforms: null,
2667
+ platformStr: "",
2668
+ passthrough: {
2669
+ before: sanitizePassthroughComments(beforeComments, conditionalComment),
2670
+ after: [lineComment(" #endif")]
2671
+ }
2672
+ });
2673
+ continue;
2674
+ }
2675
+ let platforms = [...lastPlatforms];
2676
+ if (conditionalComment) {
2677
+ const match = conditionalComment.value.match(/#ifdef\s+(.+)/);
2678
+ if (match) platforms = platformsExcluding(match[1], currentPlatform);
2679
+ }
2680
+ if (platforms.length === 0) continue;
2681
+ variantsOf(uniqueKey).push({
2682
+ payload: item,
2683
+ valueStr: stringifyForCompare(item),
2684
+ platforms,
2685
+ platformStr: platforms.join(" || ")
2686
+ });
2687
+ }
2688
+ const orderOptions = sourceOrder ? { diskOrder: sourceOrder.order } : void 0;
2689
+ for (const item of items) {
2690
+ const uniqueKey = item[uniqueKeyName];
2691
+ if (!uniqueKey) continue;
2692
+ upsertCurrentVariant(variantsOf(uniqueKey), item, stringifyForCompare(item), currentPlatform, homeStatusCompatible);
2693
+ }
2694
+ const result = new CommentArray();
2695
+ for (const variants of mergedMap.values()) sortVariants(variants);
2696
+ const platformUnion = resolvePlatformUnion(mergedMap, currentPlatform, lastPlatforms);
2697
+ const platformUnionStr = platformUnion.join(" || ");
2698
+ const owningPlatforms = collectVariantPlatforms(mergedMap);
2699
+ result[Symbol.for("before:0")] = [lineComment(` ${GENERATION_MARKER_PREFIX} ${platformUnionStr}`)];
2700
+ const declaredKeys = orderByConfig ? items.map((item) => item[uniqueKeyName]).filter((key) => typeof key === "string" && key.length > 0) : null;
2701
+ for (const list of orderVariantLists(mergedMap, declaredKeys, orderOptions)) for (const { payload: item, platformStr, passthrough } of list) {
2702
+ result.push(item);
2703
+ if (passthrough) {
2704
+ result[Symbol.for(`before:${result.length - 1}`)] = [...result[Symbol.for(`before:${result.length - 1}`)] || [], ...passthrough.before];
2705
+ if (passthrough.after.length > 0) result[Symbol.for(`after:${result.length - 1}`)] = passthrough.after;
2706
+ continue;
2707
+ }
2708
+ if (platformStr !== platformUnionStr) {
2709
+ result[Symbol.for(`before:${result.length - 1}`)] = [...result[Symbol.for(`before:${result.length - 1}`)] || [], lineComment(` #ifdef ${platformStr}`)];
2710
+ result[Symbol.for(`after:${result.length - 1}`)] = [lineComment(" #endif")];
2711
+ }
2712
+ }
2713
+ return {
2714
+ items: result,
2715
+ platformUnion,
2716
+ owningPlatforms: [...owningPlatforms].sort()
2717
+ };
2718
+ }
2719
+ //#endregion
2720
+ //#region src/pages-json-structure.ts
2721
+ /**
2722
+ * 把所有首页条目移到最前面,注释跟着条目一起移动。
2723
+ *
2724
+ * 每个平台可以在自己的 #ifdef 块里声明自己的首页,没被包裹的条目
2725
+ * 所有平台都能看到。所以一个平台的首页必须排在它可见的所有条目
2726
+ * 之前:把所有首页放到所有非首页条目前面,组内顺序不变,一次满足
2727
+ * 所有平台。
2728
+ */
2729
+ function ensureHomePageFirst(pagesArray, homePath) {
2730
+ if (!pagesArray || pagesArray.length === 0) return;
2731
+ const isHome = pagesArray.map((page) => page.type === "home");
2732
+ if (homePath && !pagesArray.some((page) => page.path === homePath && page.type === "home")) pagesArray.forEach((page, index) => {
2733
+ if (page.path === homePath) isHome[index] = true;
2734
+ });
2735
+ const homeCount = isHome.filter(Boolean).length;
2736
+ if (homeCount === 0) return;
2737
+ if (isHome.slice(0, homeCount).every(Boolean)) return;
2738
+ const commentArray = pagesArray;
2739
+ const length = pagesArray.length;
2740
+ const beforeTokens = [];
2741
+ const afterTokens = [];
2742
+ for (let i = 0; i < length; i++) {
2743
+ beforeTokens.push(commentArray[Symbol.for(`before:${i}`)] || []);
2744
+ afterTokens.push(commentArray[Symbol.for(`after:${i}`)] || []);
2745
+ Reflect.deleteProperty(commentArray, Symbol.for(`before:${i}`));
2746
+ Reflect.deleteProperty(commentArray, Symbol.for(`after:${i}`));
2747
+ }
2748
+ const markerTokens = beforeTokens[0].filter(isGenerationMarker);
2749
+ beforeTokens[0] = beforeTokens[0].filter((token) => !isGenerationMarker(token));
2750
+ const order = [];
2751
+ for (let i = 0; i < length; i++) if (isHome[i]) order.push(i);
2752
+ for (let i = 0; i < length; i++) if (!isHome[i]) order.push(i);
2753
+ pagesArray.splice(0, length, ...order.map((i) => pagesArray[i]));
2754
+ for (let i = 0; i < length; i++) {
2755
+ const sourceIndex = order[i];
2756
+ const before = i === 0 ? [...markerTokens, ...beforeTokens[sourceIndex]] : beforeTokens[sourceIndex];
2757
+ if (before.length > 0) commentArray[Symbol.for(`before:${i}`)] = before;
2758
+ if (afterTokens[sourceIndex].length > 0) commentArray[Symbol.for(`after:${i}`)] = afterTokens[sourceIndex];
2759
+ }
2760
+ }
2761
+ /**
2762
+ * 删除本轮扫描不到、但确实由插件生成的分包。
2763
+ *
2764
+ * 不删的话,这个 root 会以空分包进入本平台构建产物(app.json 里留
2765
+ * 一个指向不存在目录的 pages 数组)。手写分包没有生成标记,原样保留。
2766
+ *
2767
+ * 倒序遍历;删除条目注释符号再 splice,让 comment-json 把 `#ifdef`
2768
+ * 块和用户注释一起带走。`after-value:i`(条目的 `}` 和 `,` 之间的
2769
+ * 用户注释)也必须删掉:splice 只挪注释、不删注释。
2770
+ */
2771
+ function removeStaleSubPackages(subPackagesArray, staleRoots) {
2772
+ for (let i = subPackagesArray.length - 1; i >= 0; i--) {
2773
+ const existing = subPackagesArray[i];
2774
+ if (!staleRoots.includes(existing.root)) continue;
2775
+ debug.subPages(`Removing converged sub-package root: ${existing.root}`);
2776
+ Reflect.deleteProperty(subPackagesArray, Symbol.for(`before:${i}`));
2777
+ Reflect.deleteProperty(subPackagesArray, Symbol.for(`after:${i}`));
2778
+ Reflect.deleteProperty(subPackagesArray, Symbol.for(`after-value:${i}`));
2779
+ subPackagesArray.splice(i, 1);
2780
+ }
2781
+ }
2782
+ //#endregion
943
2783
  //#region src/jsonc-scan.ts
944
2784
  /**
945
2785
  * 跳过空白和注释往前走。遇到行注释或块注释时,注释里的文字(去掉
@@ -1040,7 +2880,7 @@ function readJsonStringToken(content, start) {
1040
2880
  * tabBar 不是对象、或文件结构看不懂时返回 undefined。
1041
2881
  */
1042
2882
  function extractTabBarProps(content) {
1043
- let i = skipJsoncFiller(content, 0);
2883
+ let i = skipJsoncFiller(content, content.charCodeAt(0) === 65279 ? 1 : 0);
1044
2884
  if (content[i] !== "{") return void 0;
1045
2885
  i = skipJsoncFiller(content, i + 1);
1046
2886
  while (i < content.length) {
@@ -1139,11 +2979,12 @@ function mergeTabBarProps(existingContent, currentProps, contributesProps, curre
1139
2979
  };
1140
2980
  for (const [key, variants] of rawProps || []) {
1141
2981
  if (key === "list") continue;
2982
+ const keyVariants = variantsOf(key);
1142
2983
  for (const raw of variants) {
1143
2984
  const valueStr = JSON.stringify(raw.value);
1144
2985
  if (valueStr === void 0) continue;
1145
2986
  if (raw.condition?.startsWith("#ifndef")) {
1146
- variantsOf(key).push({
2987
+ keyVariants.push({
1147
2988
  payload: raw.value,
1148
2989
  valueStr,
1149
2990
  platforms: null,
@@ -1159,7 +3000,7 @@ function mergeTabBarProps(existingContent, currentProps, contributesProps, curre
1159
3000
  if (raw.condition?.startsWith("#ifdef")) platforms = platformsExcluding(raw.condition.slice(6).trim(), currentPlatform);
1160
3001
  else platforms = platformUnion.filter((p) => p !== currentPlatform);
1161
3002
  if (platforms.length === 0) continue;
1162
- variantsOf(key).push({
3003
+ keyVariants.push({
1163
3004
  payload: raw.value,
1164
3005
  valueStr,
1165
3006
  platforms,
@@ -1173,6 +3014,7 @@ function mergeTabBarProps(existingContent, currentProps, contributesProps, curre
1173
3014
  if (valueStr === void 0) continue;
1174
3015
  upsertCurrentVariant(variantsOf(key), value, valueStr, currentPlatform);
1175
3016
  }
3017
+ for (const [key, variants] of merged) if (variants.length === 0) merged.delete(key);
1176
3018
  for (const variants of merged.values()) sortVariants(variants);
1177
3019
  return merged;
1178
3020
  }
@@ -1194,13 +3036,19 @@ function renderTabBarPropLines(merged, platformUnion, format) {
1194
3036
  const unit = indentUnitOf(format);
1195
3037
  const unionStr = platformUnion.join(" || ");
1196
3038
  const lines = [];
3039
+ const pushComments = (comments) => {
3040
+ for (const comment of comments) for (const line of comment.split(/\r?\n/)) {
3041
+ const text = line.trim();
3042
+ if (text) lines.push(`${unit}${unit}// ${text}`);
3043
+ }
3044
+ };
1197
3045
  for (const [key, variants] of merged) for (const variant of variants) {
1198
3046
  const valueText = indentContinuation(stringify(variant.payload, null, format?.indent ?? 2), unit + unit);
1199
3047
  const propLine = `${unit}${unit}${JSON.stringify(key)}: ${valueText},`;
1200
3048
  if (variant.passthrough) {
1201
- for (const comment of variant.passthrough.before) if (comment) lines.push(`${unit}${unit}// ${comment}`);
3049
+ pushComments(variant.passthrough.before);
1202
3050
  lines.push(propLine);
1203
- for (const comment of variant.passthrough.after) if (comment) lines.push(`${unit}${unit}// ${comment}`);
3051
+ pushComments(variant.passthrough.after);
1204
3052
  continue;
1205
3053
  }
1206
3054
  if (variant.platformStr === unionStr) lines.push(propLine);
@@ -1253,7 +3101,7 @@ async function writePagesJson(jsonPath, data, options) {
1253
3101
  content
1254
3102
  };
1255
3103
  }
1256
- await writeFileAtomic(jsonPath, content);
3104
+ await retryOnEperm(() => writeFileAtomic(jsonPath, content));
1257
3105
  return {
1258
3106
  updated: true,
1259
3107
  content
@@ -1261,6 +3109,23 @@ async function writePagesJson(jsonPath, data, options) {
1261
3109
  });
1262
3110
  }
1263
3111
  /**
3112
+ * Retries an async operation up to 3 times on EPERM, waiting 50ms between
3113
+ * attempts. Other errors propagate immediately.
3114
+ *
3115
+ * Windows file rename (used by write-file-atomic) can fail with EPERM when
3116
+ * another process holds the destination open with a sharing mode that excludes
3117
+ * rename (e.g. chokidar's watcher). The lock clears in under a frame.
3118
+ */
3119
+ async function retryOnEperm(operation) {
3120
+ for (let attempt = 3; attempt > 0; attempt--) try {
3121
+ await operation();
3122
+ return;
3123
+ } catch (error) {
3124
+ if (error.code !== "EPERM" || attempt === 1) throw error;
3125
+ await new Promise((resolve) => setTimeout(resolve, 50));
3126
+ }
3127
+ }
3128
+ /**
1264
3129
  * 根据现有文件内容计算合并后的 pages.json 对象
1265
3130
  *
1266
3131
  * @returns pages.json 对象;美化输出模式下附带预渲染的 tabBar 主体
@@ -1274,6 +3139,7 @@ function mergeIntoPagesJson(existingContent, data, options) {
1274
3139
  } catch (error) {
1275
3140
  throw new Error(`[vite-plugin-uni-pages] Failed to parse the existing pages.json, please fix its syntax first: ${error?.message ?? error}`, { cause: error });
1276
3141
  }
3142
+ if (Object.prototype.toString.call(oldConfig) !== "[object Object]") throw new Error("[vite-plugin-uni-pages] The existing pages.json must contain a JSON object, please fix its syntax first");
1277
3143
  const { pages: oldPages, subPackages: oldSubPackages, tabBar: oldTabBar } = oldConfig;
1278
3144
  for (const [field, value] of Object.entries({
1279
3145
  pages: oldPages,
@@ -1298,15 +3164,7 @@ function mergeIntoPagesJson(existingContent, data, options) {
1298
3164
  newSubPackages.delete(existing.root);
1299
3165
  } else if (hasGenerationMarker(existing.pages)) staleRoots.push(existing.root);
1300
3166
  }
1301
- for (let i = subPackagesArray.length - 1; i >= 0; i--) {
1302
- const existing = subPackagesArray[i];
1303
- if (!staleRoots.includes(existing.root)) continue;
1304
- debug.subPages(`Removing converged sub-package root: ${existing.root}`);
1305
- Reflect.deleteProperty(subPackagesArray, Symbol.for(`before:${i}`));
1306
- Reflect.deleteProperty(subPackagesArray, Symbol.for(`after:${i}`));
1307
- Reflect.deleteProperty(subPackagesArray, Symbol.for(`after-value:${i}`));
1308
- subPackagesArray.splice(i, 1);
1309
- }
3167
+ removeStaleSubPackages(subPackagesArray, staleRoots);
1310
3168
  for (const [_, newSub] of newSubPackages) {
1311
3169
  const subPackage = {
1312
3170
  root: newSub.root,
@@ -1318,7 +3176,7 @@ function mergeIntoPagesJson(existingContent, data, options) {
1318
3176
  const { list, ...tabBarOthers } = data.tabBar || {};
1319
3177
  const contributesProps = !!(list && list.length);
1320
3178
  const oldTabBarObj = oldTabBar;
1321
- const tabBarMerge = mergePlatformItems(oldTabBarObj?.list, currentPlatform, list || [], "pagePath");
3179
+ const tabBarMerge = mergePlatformItems(oldTabBarObj?.list, currentPlatform, list || [], "pagePath", { orderByConfig: true });
1322
3180
  let tabBarBodyLines;
1323
3181
  if (tabBarMerge.items.length === 0) pageJson.tabBar = void 0;
1324
3182
  else {
@@ -1345,53 +3203,6 @@ function mergeIntoPagesJson(existingContent, data, options) {
1345
3203
  };
1346
3204
  }
1347
3205
  /**
1348
- * 把所有首页条目移到最前面,注释跟着条目一起移动
1349
- *
1350
- * 每个平台可以在自己的 #ifdef 块里声明自己的首页,没被包裹的条目
1351
- * 所有平台都能看到。所以一个平台的首页必须排在它可见的所有条目
1352
- * 之前。解决办法很简单:把所有平台的首页都放到所有非首页条目的
1353
- * 前面(各组内部顺序不变),一次满足所有平台。
1354
- *
1355
- * 只移动当前平台的首页是不够的:就算它已经在第 0 位,另一个平台的
1356
- * 首页可能还排在它自己可见的某个非首页条目后面。
1357
- *
1358
- * 判断哪些条目是首页:先看条目自带的 `type: 'home'` 标记(扫描出来
1359
- * 的条目都带),再看 homePath 路径兜底(手写条目不带标记)。标记在
1360
- * 合并时不会保留下来,所以每次运行都要重新判断一遍。
1361
- */
1362
- function ensureHomePageFirst(pagesArray, homePath) {
1363
- if (!pagesArray || pagesArray.length === 0) return;
1364
- const isHome = pagesArray.map((page) => page.type === "home");
1365
- if (homePath && !pagesArray.some((page) => page.path === homePath && page.type === "home")) pagesArray.forEach((page, index) => {
1366
- if (page.path === homePath) isHome[index] = true;
1367
- });
1368
- const homeCount = isHome.filter(Boolean).length;
1369
- if (homeCount === 0) return;
1370
- if (isHome.slice(0, homeCount).every(Boolean)) return;
1371
- const commentArray = pagesArray;
1372
- const length = pagesArray.length;
1373
- const beforeTokens = [];
1374
- const afterTokens = [];
1375
- for (let i = 0; i < length; i++) {
1376
- beforeTokens.push(commentArray[Symbol.for(`before:${i}`)] || []);
1377
- afterTokens.push(commentArray[Symbol.for(`after:${i}`)] || []);
1378
- Reflect.deleteProperty(commentArray, Symbol.for(`before:${i}`));
1379
- Reflect.deleteProperty(commentArray, Symbol.for(`after:${i}`));
1380
- }
1381
- const markerTokens = beforeTokens[0].filter(isGenerationMarker);
1382
- beforeTokens[0] = beforeTokens[0].filter((token) => !isGenerationMarker(token));
1383
- const order = [];
1384
- for (let i = 0; i < length; i++) if (isHome[i]) order.push(i);
1385
- for (let i = 0; i < length; i++) if (!isHome[i]) order.push(i);
1386
- pagesArray.splice(0, length, ...order.map((i) => pagesArray[i]));
1387
- for (let i = 0; i < length; i++) {
1388
- const sourceIndex = order[i];
1389
- const before = i === 0 ? [...markerTokens, ...beforeTokens[sourceIndex]] : beforeTokens[sourceIndex];
1390
- if (before.length > 0) commentArray[Symbol.for(`before:${i}`)] = before;
1391
- if (afterTokens[sourceIndex].length > 0) commentArray[Symbol.for(`after:${i}`)] = afterTokens[sourceIndex];
1392
- }
1393
- }
1394
- /**
1395
3206
  * 判断输出是不是带注释的多行格式(只有多行才放得下按平台区分的
1396
3207
  * `#ifdef` 块)。`indent: 0` 或空字符串会让 comment-json 输出成
1397
3208
  * 单行,这里和它的行为保持一致。
@@ -1401,7 +3212,7 @@ function isPrettyFormat(format) {
1401
3212
  }
1402
3213
  /**
1403
3214
  * 不碰文件系统,只做计算:把路由数据合并进现有 pages.json 文本
1404
- * (多平台 `#ifdef` 块、首页重排、生成标记、过期子包清理)并序列化
3215
+ * (多平台 `#ifdef` 块、首页重排、生成标记、过期分包清理)并序列化
1405
3216
  * 成字符串。文件锁、变更检测和原子写入都在 {@link writePagesJson}
1406
3217
  * 里;测试直接用字符串检查这个函数。
1407
3218
  *
@@ -1424,150 +3235,36 @@ function mergePagesJson(existingContent, data, options) {
1424
3235
  if (options.format?.insertFinalNewline) content += eol;
1425
3236
  return content;
1426
3237
  }
1427
- /** 构建用于序列化 pages.json 输出的行注释令牌 */
1428
- function lineComment(value) {
3238
+ //#endregion
3239
+ //#region src/pages-snapshot.ts
3240
+ function createPagesSnapshot(input) {
1429
3241
  return {
1430
- type: "LineComment",
1431
- value,
1432
- inline: false,
1433
- loc: {
1434
- start: {
1435
- line: 0,
1436
- column: 0
1437
- },
1438
- end: {
1439
- line: 0,
1440
- column: 0
1441
- }
1442
- }
3242
+ pages: [...input.pages],
3243
+ subPackages: [...input.subPackages],
3244
+ tabBar: input.tabBar ? {
3245
+ ...input.tabBar,
3246
+ list: input.tabBar.list ? [...input.tabBar.list] : void 0
3247
+ } : void 0,
3248
+ globConfig: input.globConfig,
3249
+ homePath: input.homePath
1443
3250
  };
1444
3251
  }
3252
+ //#endregion
3253
+ //#region src/context.ts
1445
3254
  /**
1446
- * 把条目转成字符串用于比较,比较前要先把内部的 `type` 标记
1447
- * ('home' | 'page')拿掉。
1448
- *
1449
- * 从 pages.json 读回来的条目可能没有这个标记(手写条目从不带它),
1450
- * 直接 JSON.stringify 会把同一个页面当成两个不同的条目,多平台
1451
- * 运行时就会生成重复的路由
1452
- * (见 https://github.com/uni-helper/vite-plugin-uni-pages/issues/283)。
1453
- *
1454
- * 键的顺序也参与比较(JSON.stringify 按写入顺序输出):同一页面在
1455
- * 两个平台下键序不同时不会被合并,会各占一个 #ifdef 块留在文件
1456
- * 里。代价只是文件里多一份内容相同的变体,构建时每个平台仍然只看
1457
- * 得见自己那一条、不会出现重复路由,不值得为它做键序归一化。
1458
- */
1459
- function stringifyForCompare(val) {
1460
- if ("type" in val) {
1461
- const { type: _type, ...rest } = val;
1462
- return JSON.stringify(rest);
1463
- }
1464
- return JSON.stringify(val);
1465
- }
1466
- /**
1467
- * 两个内容相同的条目,首页状态是否一致?
1468
- *
1469
- * `type` 标记不参与内容比较(见 stringifyForCompare),没有标记的
1470
- * 条目也能和扫描到的条目合并。但如果两边都明确带着标记、而且一个
1471
- * 是 'home' 一个是 'page',说明这是两个平台各自声明的不同首页,
1472
- * 必须作为两个条目待在各自的 #ifdef 块里,不能合并成一条——之前
1473
- * 的合并正是"切换首页后旧首页标记残留、某个平台的首页声明被悄悄
1474
- * 丢掉"这两个 bug 的根源。
1475
- */
1476
- function homeStatusCompatible(a, b) {
1477
- const typeA = a.type;
1478
- const typeB = b.type;
1479
- return typeA === void 0 || typeB === void 0 || typeA === typeB;
1480
- }
1481
- /**
1482
- * 合并多平台的配置条目(数组侧适配器)。
1483
- *
1484
- * 处理条件编译注释(#ifdef / #ifndef / #endif),把 pages.json 里
1485
- * 已有的条目和本次扫描的条目合并成一个数组;合并规则(同值叠平台、
1486
- * 排除当前平台、排序、并集)用 platform-variants.ts 的共享核心:
1487
- * - 内容相同的条目自动合并,平台标识叠在一起
1488
- * - 内容不同的条目各写各的 #ifdef 块
1489
- * - 手写的 `#ifndef` 块原样保留:否定条件翻译不成正向平台列表,
1490
- * 所以条目连同原始包裹一起输出,永不修改、也永不清理
3255
+ * 在 watcher 回调里查页面规则
1491
3256
  *
1492
- * @param source - 现有条目数组(来自 pages.json)
1493
- * @param currentPlatform - 当前平台标识(如 H5、MP-WEIXIN)
1494
- * @param items - 新配置条目数组
1495
- * @param uniqueKeyName - 用于识别条目唯一性的字段名(如 'path' 或 'pagePath')
1496
- * @returns 带 #ifdef 块的合并条目,以及平台全集与拥有平台
3257
+ * 规则会在声明变化后重新编译,编译失败属于配置错误;此时记日志并按
3258
+ * “不是页面事件”处理,异常不能穿过 watcher 回调,否则会崩在事件循环里。
1497
3259
  */
1498
- function mergePlatformItems(source, currentPlatform, items, uniqueKeyName) {
1499
- const src = source || new CommentArray();
1500
- currentPlatform = currentPlatform.toUpperCase();
1501
- const lastPlatforms = extractLastPlatforms(src, currentPlatform);
1502
- const mergedMap = /* @__PURE__ */ new Map();
1503
- const variantsOf = (uniqueKey) => {
1504
- const list = mergedMap.get(uniqueKey) || [];
1505
- mergedMap.set(uniqueKey, list);
1506
- return list;
1507
- };
1508
- for (let i = 0; i < src.length; i++) {
1509
- const item = src[i];
1510
- const uniqueKey = item[uniqueKeyName];
1511
- if (!uniqueKey) continue;
1512
- const beforeComments = src[Symbol.for(`before:${i}`)];
1513
- const conditionalComment = beforeComments?.find((c) => c.type !== "BlankLine" && /^#(?:ifdef|ifndef)/.test(c.value.trim()));
1514
- if (conditionalComment?.value.trim().startsWith("#ifndef")) {
1515
- variantsOf(uniqueKey).push({
1516
- payload: item,
1517
- valueStr: stringifyForCompare(item),
1518
- platforms: null,
1519
- platformStr: "",
1520
- passthrough: {
1521
- before: [...beforeComments || []].filter((c) => !isGenerationMarker(c)),
1522
- after: [lineComment(" #endif")]
1523
- }
1524
- });
1525
- continue;
1526
- }
1527
- let platforms = [...lastPlatforms];
1528
- if (conditionalComment) {
1529
- const match = conditionalComment.value.match(/#ifdef\s+(.+)/);
1530
- if (match) platforms = platformsExcluding(match[1], currentPlatform);
1531
- }
1532
- if (platforms.length === 0) continue;
1533
- variantsOf(uniqueKey).push({
1534
- payload: item,
1535
- valueStr: stringifyForCompare(item),
1536
- platforms,
1537
- platformStr: platforms.join(" || ")
1538
- });
1539
- }
1540
- for (const item of items) {
1541
- const uniqueKey = item[uniqueKeyName];
1542
- if (!uniqueKey) continue;
1543
- upsertCurrentVariant(variantsOf(uniqueKey), item, stringifyForCompare(item), currentPlatform, homeStatusCompatible);
1544
- }
1545
- const result = new CommentArray();
1546
- for (const variants of mergedMap.values()) sortVariants(variants);
1547
- const platformUnion = resolvePlatformUnion(mergedMap, currentPlatform, lastPlatforms);
1548
- const platformUnionStr = platformUnion.join(" || ");
1549
- const owningPlatforms = collectVariantPlatforms(mergedMap);
1550
- result[Symbol.for("before:0")] = [lineComment(` ${GENERATION_MARKER_PREFIX} ${platformUnionStr}`)];
1551
- for (const [_, list] of mergedMap) for (const { payload: item, platformStr, passthrough } of list) {
1552
- result.push(item);
1553
- if (passthrough) {
1554
- result[Symbol.for(`before:${result.length - 1}`)] = [...result[Symbol.for(`before:${result.length - 1}`)] || [], ...passthrough.before];
1555
- if (passthrough.after.length > 0) result[Symbol.for(`after:${result.length - 1}`)] = passthrough.after;
1556
- continue;
1557
- }
1558
- if (platformStr !== platformUnionStr) {
1559
- result[Symbol.for(`before:${result.length - 1}`)] = [...result[Symbol.for(`before:${result.length - 1}`)] || [], lineComment(` #ifdef ${platformStr}`)];
1560
- result[Symbol.for(`after:${result.length - 1}`)] = [lineComment(" #endif")];
1561
- }
3260
+ function queryPageRules(fallback, query) {
3261
+ try {
3262
+ return query();
3263
+ } catch (error) {
3264
+ debug.error("Failed to evaluate page rules, the event is ignored:", error);
3265
+ return fallback;
1562
3266
  }
1563
- return {
1564
- items: result,
1565
- platformUnion,
1566
- owningPlatforms: [...owningPlatforms].sort()
1567
- };
1568
3267
  }
1569
- //#endregion
1570
- //#region src/context.ts
1571
3268
  /**
1572
3269
  * 页面上下文:负责扫描页面、加载配置、合并页面信息、生成 pages.json
1573
3270
  *
@@ -1578,17 +3275,45 @@ function mergePlatformItems(source, currentPlatform, items, uniqueKeyName) {
1578
3275
  */
1579
3276
  var PageContext = class {
1580
3277
  _server;
3278
+ configLoader;
3279
+ watchers = /* @__PURE__ */ new Map();
3280
+ watchedConfigPaths = /* @__PURE__ */ new Map();
3281
+ /** 当前配置依赖与恢复范围;事件判定和路径展开都从这里取 */
3282
+ configWatch;
3283
+ /** 生成物(pages.json、d.ts)的绝对路径,只在首次事件时算一次 */
3284
+ generatedPathsCache;
3285
+ pendingUpdate = Promise.resolve();
3286
+ disposed = false;
3287
+ disposal;
3288
+ /** 本轮扫描的目录、分包来源与规则;扫描和监听共用 */
3289
+ scanPlan;
3290
+ /** 最近一次生成的完整快照;虚拟模块、路由与声明都读它 */
3291
+ generatedSnapshot;
3292
+ /** 随扫描更新的 Page 来源,用于复用检查和冲突诊断 */
3293
+ pageSources = /* @__PURE__ */ new WeakMap();
3294
+ /** 扫描候选独立于归属裁剪后的输出映射,保留非代表来源的内容缓存 */
3295
+ mainPageCandidates = /* @__PURE__ */ new Map();
3296
+ subPageCandidates = /* @__PURE__ */ new Map();
3297
+ pageWatcher;
3298
+ /** 待处理的页面更新:生成期间到达的事件合并到下一轮 */
3299
+ pendingPageUpdate;
3300
+ /** 当前事件更新循环;dispose 时等待它排空 */
3301
+ pendingPageFlush;
3302
+ /** 页面监听启动参数;初始化失败时保留,后续生成会重试 */
3303
+ pageWatcherStart;
3304
+ /** 当前配置本地依赖及加载失败后仍需监听的恢复路径 */
3305
+ pagesConfigDependencyPaths = [];
1581
3306
  /** 从用户配置文件(pages.config.ts)解析出的配置对象 */
1582
3307
  pagesGlobConfig;
1583
3308
  /** 用户配置文件的来源路径列表 */
1584
3309
  pagesConfigSourcePaths = [];
1585
- /** 主包页面映射,键为页面文件的绝对路径 */
3310
+ /** 主包页面映射,键为页面文件的词法绝对路径 */
1586
3311
  pages = /* @__PURE__ */ new Map();
1587
- /** 子包页面映射,键为子包根目录,值为该子包下的页面映射 */
3312
+ /** 分包页面映射,外层键为词法扫描目录,内层键为页面文件的词法绝对路径 */
1588
3313
  subPages = /* @__PURE__ */ new Map();
1589
3314
  /** 主包页面配置数组,用于生成 pages.json 的 pages 字段 */
1590
3315
  pageMetaData = [];
1591
- /** 子包页面配置数组,用于生成 pages.json 的 subPackages 字段 */
3316
+ /** 分包页面配置数组,用于生成 pages.json 的 subPackages 字段 */
1592
3317
  subPageMetaData = [];
1593
3318
  /**
1594
3319
  * 最近一次合并出的 tabBar(配置文件与 definePage 声明的合体)。
@@ -1609,7 +3334,7 @@ var PageContext = class {
1609
3334
  withUniPlatform = false;
1610
3335
  /** pages.json 中页面路径的解析基准路径 */
1611
3336
  get basePath() {
1612
- return resolveBasePath(this.options);
3337
+ return this.scanPlan?.basePath ?? resolveBasePath(this.options);
1613
3338
  }
1614
3339
  /**
1615
3340
  * 创建 PageContext 实例
@@ -1619,9 +3344,18 @@ var PageContext = class {
1619
3344
  */
1620
3345
  constructor(userOptions, viteRoot = process.cwd(), platform$1 = platform) {
1621
3346
  this.root = normalizePath(viteRoot);
3347
+ this.configLoader = new PagesConfigLoader(this.root);
3348
+ this.configWatch = new ConfigWatchIndex(this.root, this.configLoader.artifactDirectory);
1622
3349
  this.platform = platform$1;
1623
3350
  debug.options("root", this.root);
1624
3351
  this.options = resolveOptions(userOptions, this.root);
3352
+ this.pageWatcher = new PageSourceWatch(this.options, (event, file) => {
3353
+ const pageFile = queryPageRules(void 0, () => this.pageFileForEvent(file, normalizeConfigPath(file, this.root)));
3354
+ if (pageFile) {
3355
+ debug.pages(`File changed: ${pageFile}`);
3356
+ this.queuePageUpdate(event === "change" ? pageFile : void 0);
3357
+ }
3358
+ }, () => this.queuePageUpdate(), (error) => this.logUpdateFailure(error), (message) => this.logger?.warn(message, { timestamp: true }));
1625
3359
  const debugOption = this.options.debug;
1626
3360
  if (debugOption) {
1627
3361
  const prefix = "vite-plugin-uni-pages:";
@@ -1643,73 +3377,256 @@ var PageContext = class {
1643
3377
  * 使用 unconfig 加载配置,支持多种配置文件格式
1644
3378
  */
1645
3379
  async loadUserPagesConfig() {
1646
- const configSource = this.options.configSource;
1647
- const { config, sources } = await loadConfig({
1648
- cwd: this.root,
1649
- sources: configSource,
1650
- defaults: {}
1651
- });
1652
- this.pagesGlobConfig = config.default || config;
1653
- this.pagesConfigSourcePaths = sources.map(normalizePath);
1654
- debug.options(this.pagesGlobConfig);
3380
+ try {
3381
+ const { config, sources, dependencies } = await this.configLoader.load(this.options.configSource);
3382
+ this.pagesGlobConfig = config.default || config;
3383
+ this.pagesConfigSourcePaths = sources;
3384
+ this.pagesConfigDependencyPaths = dependencies;
3385
+ debug.options(this.pagesGlobConfig);
3386
+ } catch (error) {
3387
+ this.pagesConfigDependencyPaths = [...this.configLoader.watchInput.dependencies];
3388
+ throw error;
3389
+ } finally {
3390
+ this.refreshConfigWatchers();
3391
+ }
1655
3392
  }
1656
3393
  /**
1657
- * 按顺序跑完扫描与合并:先扫描主包和子包的页面,再合并它们的
3394
+ * 按顺序跑完扫描与合并:先扫描主包和分包的页面,再合并它们的
1658
3395
  * 信息。步骤顺序只维护在这里,调用方和测试都不用关心。
1659
3396
  */
1660
3397
  async scanAndMerge() {
3398
+ this.generatedSnapshot = void 0;
3399
+ this.refreshScanPlan();
1661
3400
  await this.scanPages();
1662
3401
  await this.scanSubPages();
3402
+ this.startPageWatcher();
3403
+ this.pageWatcher.refresh(this.requireScanPlan());
1663
3404
  await this.mergePageMetaData();
1664
3405
  await this.mergeSubPageMetaData();
1665
3406
  }
3407
+ /** 刷新本轮扫描计划,并同步兼容投影供既有调用方读取 */
3408
+ refreshScanPlan() {
3409
+ this.scanPlan = createScanPlan(this.options);
3410
+ applyScanPlan(this.options, this.scanPlan);
3411
+ }
3412
+ /** 扫描与监听阶段读取本轮计划;计划在 refreshScanPlan 之后必定存在 */
3413
+ requireScanPlan() {
3414
+ if (!this.scanPlan) throw new Error("[vite-plugin-uni-pages] Scan plan is not ready");
3415
+ return this.scanPlan;
3416
+ }
1666
3417
  /**
1667
3418
  * 设置 Vite 开发服务器,用于 HMR 与文件监听
1668
3419
  * @param server - Vite 开发服务器实例
1669
3420
  */
1670
3421
  setupViteServer(server) {
1671
- if (this._server === server) return;
3422
+ if (this.disposed || this._server === server) return;
1672
3423
  this._server = server;
1673
- this.setupWatcher(server.watcher);
3424
+ this.setupWatcher(server.watcher).catch((error) => this.logUpdateFailure(error));
3425
+ }
3426
+ /**
3427
+ * 生成物(pages.json 与 d.ts)的绝对路径
3428
+ *
3429
+ * 只依赖构造后不再变化的 `options` 与 `resolvedPagesJSONPath`,
3430
+ * 算一次缓存即可,不必每个事件重新解析
3431
+ */
3432
+ generatedFilePaths() {
3433
+ if (!this.generatedPathsCache) this.generatedPathsCache = [normalizeConfigPath(this.resolvedPagesJSONPath, this.root), ...this.options.dts ? [normalizeConfigPath(this.options.dts, this.root)] : []];
3434
+ return this.generatedPathsCache;
3435
+ }
3436
+ /** 按当前词法规则识别页面事件,真实路径只用于关联已收录来源 */
3437
+ pageFileForEvent(file, identity) {
3438
+ return this.pageWatcher.fileForEvent(file, identity);
3439
+ }
3440
+ pageDirectoryForEvent(directory) {
3441
+ return this.pageWatcher.directoryForEvent(directory);
1674
3442
  }
1675
3443
  /**
1676
- * 设置文件监听器,监听页面文件与配置文件变更
1677
- * 页面文件或配置文件变化时自动更新 pages.json
1678
- * @param watcher - chokidar 文件监听器实例
3444
+ * 注册配置文件与页面目录结构监听,并启动页面内容监听
3445
+ *
3446
+ * @param watcher - 配置文件及目录结构事件使用的 chokidar 监听器
1679
3447
  */
1680
3448
  async setupWatcher(watcher) {
1681
- watcher.add(this.pagesConfigSourcePaths);
1682
- const targetDirs = [...this.options.dirs, ...this.options.subPackages].map((v) => normalizePath(path.resolve(this.root, v)));
1683
- const isWatchedPageFile = (filePath) => {
1684
- const absolute = normalizePath(path.resolve(this.root, filePath));
1685
- return isTargetFile(filePath) && targetDirs.some((v) => absolute === v || absolute.startsWith(`${v}/`));
3449
+ if (this.disposed || this.watchers.has(watcher)) return;
3450
+ const onConfigFile = (filepath) => {
3451
+ const absolute = this.configWatch.match(filepath, this.generatedFilePaths());
3452
+ if (!absolute) return;
3453
+ debug.pages(`Config changed: ${absolute}`);
3454
+ this.updatePagesAndNotify();
3455
+ };
3456
+ const onDirectory = (directory) => {
3457
+ if (queryPageRules(false, () => this.pageDirectoryForEvent(directory))) this.queuePageUpdate();
3458
+ };
3459
+ const onSharedPageFile = (filepath) => {
3460
+ if (this.disposed || this.pageWatcher.running || !this.pageWatcherStart) return;
3461
+ if (!isTargetFile(normalizePath(filepath))) return;
3462
+ this.startPageWatcher();
3463
+ if (this.pageWatcher.running) this.queuePageUpdate();
1686
3464
  };
1687
- watcher.on("add", async (path) => {
1688
- path = normalizePath(path);
1689
- if (!isWatchedPageFile(path)) return;
1690
- debug.pages(`File added: ${path}`);
1691
- if (await this.updatePagesJSON()) this.onUpdate();
3465
+ const onReady = () => this.queuePageUpdate();
3466
+ watcher.on("add", onConfigFile);
3467
+ watcher.on("change", onConfigFile);
3468
+ watcher.on("unlink", onConfigFile);
3469
+ watcher.on("add", onSharedPageFile);
3470
+ watcher.on("change", onSharedPageFile);
3471
+ watcher.on("unlink", onSharedPageFile);
3472
+ watcher.on("addDir", onDirectory);
3473
+ watcher.on("unlinkDir", onDirectory);
3474
+ watcher.on("ready", onReady);
3475
+ this.watchers.set(watcher, () => {
3476
+ watcher.off("add", onConfigFile);
3477
+ watcher.off("change", onConfigFile);
3478
+ watcher.off("unlink", onConfigFile);
3479
+ watcher.off("add", onSharedPageFile);
3480
+ watcher.off("change", onSharedPageFile);
3481
+ watcher.off("unlink", onSharedPageFile);
3482
+ watcher.off("addDir", onDirectory);
3483
+ watcher.off("unlinkDir", onDirectory);
3484
+ watcher.off("ready", onReady);
1692
3485
  });
1693
- watcher.on("change", async (path) => {
1694
- path = normalizePath(path);
1695
- if (this.pagesConfigSourcePaths.includes(path)) {
1696
- debug.pages(`Config source changed: ${path}`);
1697
- if (await this.updatePagesJSON()) this.onUpdate();
1698
- return;
3486
+ this.watchedConfigPaths.set(watcher, /* @__PURE__ */ new Set());
3487
+ this.refreshConfigWatchers();
3488
+ if (!(this._server?.watcher === watcher && this._server?.config.server.watch === null)) {
3489
+ this.pageWatcherStart = {
3490
+ options: watcher.options,
3491
+ ignored: this._server?.watcher === watcher ? this._server?.watcher.options.ignored : void 0
3492
+ };
3493
+ this.startPageWatcher();
3494
+ }
3495
+ }
3496
+ /**
3497
+ * 启动页面监听
3498
+ *
3499
+ * @description 初始化失败只记录并保留启动参数,后续生成会重新尝试;监听属于辅助
3500
+ * 能力,不能阻断页面生成,也不能留下未处理的拒绝
3501
+ */
3502
+ startPageWatcher() {
3503
+ if (this.disposed || !this.pageWatcherStart) return;
3504
+ try {
3505
+ this.pageWatcher.start(this.pageWatcherStart.options, this.pageWatcherStart.ignored, this.scanPlan);
3506
+ } catch (error) {
3507
+ this.logUpdateFailure(error);
3508
+ }
3509
+ }
3510
+ /**
3511
+ * 释放本上下文的监听与待处理更新
3512
+ *
3513
+ * @description 关闭后不再接受监听注册或更新;已开始的生成会完成,重复调用等待同一次释放
3514
+ * 共享 watcher 的关闭仍由 Vite 负责
3515
+ */
3516
+ async disposeWatchers() {
3517
+ if (this.disposal) return this.disposal;
3518
+ this.disposed = true;
3519
+ this.pendingPageUpdate = void 0;
3520
+ for (const dispose of this.watchers.values()) dispose();
3521
+ this.watchers.clear();
3522
+ this.watchedConfigPaths.clear();
3523
+ this._server = void 0;
3524
+ this.pageWatcherStart = void 0;
3525
+ this.disposal = (async () => {
3526
+ try {
3527
+ await this.pageWatcher.close();
3528
+ } finally {
3529
+ await this.pendingPageFlush;
3530
+ await this.pendingUpdate;
1699
3531
  }
1700
- path = normalizePath(path);
1701
- if (!isWatchedPageFile(path)) return;
1702
- debug.pages(`File changed: ${path}`);
1703
- if (await this.updatePagesJSON(path)) this.onUpdate();
1704
- });
1705
- watcher.on("unlink", async (path) => {
1706
- path = normalizePath(path);
1707
- if (!isWatchedPageFile(path)) return;
1708
- debug.pages(`File removed: ${path}`);
1709
- if (await this.updatePagesJSON()) this.onUpdate();
3532
+ })();
3533
+ return this.disposal;
3534
+ }
3535
+ /** 补充未覆盖的监听路径,注册成功后记账,失败时保留重试机会 */
3536
+ addWatchPaths(watcher, paths, added, description) {
3537
+ const wanted = paths.filter((file) => !added.has(file) && ![...added].some((existing) => file.startsWith(`${existing}/`)));
3538
+ if (!wanted.length) return;
3539
+ try {
3540
+ watcher.add(wanted);
3541
+ for (const file of wanted) added.add(file);
3542
+ } catch (error) {
3543
+ this.logger?.error(`Failed to watch ${description}: ${error instanceof Error ? error.message : error}`, {
3544
+ timestamp: true,
3545
+ error: error instanceof Error ? error : void 0
3546
+ });
3547
+ }
3548
+ }
3549
+ /**
3550
+ * 让监听路径与当前配置依赖保持一致
3551
+ *
3552
+ * 只新增路径,从不 unwatch:chokidar 对文件调用 unwatch 会连带
3553
+ * 拆掉父目录监听,新建文件就再也发现不了。因此监听集合是并集,
3554
+ * 会随着会话累积;每次刷新都按当前依赖重建配置监听计划,
3555
+ * 真正决定是否触发的始终是计划里的依赖集合(恢复目录除外,那是
3556
+ * 故意留的兜底)。
3557
+ */
3558
+ refreshConfigWatchers() {
3559
+ this.configWatch.update(buildConfigWatchPlan({
3560
+ root: this.root,
3561
+ artifactDirectory: this.configLoader.artifactDirectory,
3562
+ sourcePaths: this.pagesConfigSourcePaths,
3563
+ dependencyPaths: this.pagesConfigDependencyPaths,
3564
+ recoveryCandidates: this.configLoader.watchInput.recoveryCandidates,
3565
+ recoveryDirectories: this.configLoader.watchInput.recoveryDirectories
3566
+ }));
3567
+ for (const watcher of this.watchers.keys()) {
3568
+ const added = this.watchedConfigPaths.get(watcher);
3569
+ const sharedRoot = this._server && watcher === this._server.watcher ? normalizeConfigPath(this._server.config.root, this.root) : void 0;
3570
+ this.addWatchPaths(watcher, this.configWatch.watchPaths(sharedRoot), added, "pages config dependencies");
3571
+ }
3572
+ }
3573
+ /** 配置变化的完整更新:失败只记录,不让 Promise 拒绝 */
3574
+ async updatePagesAndNotify() {
3575
+ try {
3576
+ if (await this.updatePagesJSON() && !this.disposed) this.onUpdate();
3577
+ } catch (error) {
3578
+ this.logUpdateFailure(error);
3579
+ }
3580
+ }
3581
+ /** 记录 watcher 更新失败,避免未处理的拒绝终止进程 */
3582
+ logUpdateFailure(error) {
3583
+ this.logger?.error(`Failed to update pages.json after a file change: ${error instanceof Error ? error.message : error}`, {
3584
+ timestamp: true,
3585
+ error: error instanceof Error ? error : void 0
1710
3586
  });
1711
3587
  }
1712
3588
  /**
3589
+ * 登记页面事件触发的更新:生成期间到达的事件合并到下一轮
3590
+ *
3591
+ * @description 连续删除、重建会一次性派发大量事件,逐个生成会反复
3592
+ * 重读全部页面;内容变更合并待刷新文件,结构变更执行完整刷新
3593
+ * @param filepath - 只读取该页面;省略时执行完整扫描
3594
+ */
3595
+ queuePageUpdate(filepath) {
3596
+ if (this.disposed) return;
3597
+ if (!this.pendingPageUpdate) this.pendingPageUpdate = {
3598
+ full: false,
3599
+ files: /* @__PURE__ */ new Set()
3600
+ };
3601
+ if (filepath) this.pendingPageUpdate.files.add(filepath);
3602
+ else this.pendingPageUpdate.full = true;
3603
+ if (!this.pendingPageFlush) this.pendingPageFlush = this.flushPageUpdates();
3604
+ }
3605
+ /**
3606
+ * 排空页面事件队列;失败只记录,不让 Promise 拒绝
3607
+ *
3608
+ * 页面事件的回调走这里而不是直接调 {@link updatePagesJSON}:一旦
3609
+ * 它拒绝(拿不到文件锁、声明文件写不进去),就成了未处理的拒绝,
3610
+ * Node 默认直接终止进程。开发时正在编辑文件,一次偶发的锁冲突
3611
+ * 不该把 dev server 打死。用户改一次文件就会再触发一次事件,
3612
+ * 所以这里报错即可。
3613
+ */
3614
+ async flushPageUpdates() {
3615
+ try {
3616
+ while (this.pendingPageUpdate) {
3617
+ const { full, files } = this.pendingPageUpdate;
3618
+ this.pendingPageUpdate = void 0;
3619
+ try {
3620
+ if ((full ? await this.updatePagesJSON() : files.size === 1 ? await this.updatePagesJSON([...files][0]) : await this.enqueuePagesUpdate([...files])) && !this.disposed) this.onUpdate();
3621
+ } catch (error) {
3622
+ this.logUpdateFailure(error);
3623
+ }
3624
+ }
3625
+ } finally {
3626
+ this.pendingPageFlush = void 0;
3627
+ }
3628
+ }
3629
+ /**
1713
3630
  * 页面更新回调,页面文件或配置变化时触发
1714
3631
  * 负责使虚拟模块失效并通知浏览器整页刷新
1715
3632
  */
@@ -1720,36 +3637,47 @@ var PageContext = class {
1720
3637
  this._server.ws.send({ type: "full-reload" });
1721
3638
  }
1722
3639
  /**
1723
- * 更新 pages.json 文件
1724
- * 这是负责协调整个页面配置生成流程的核心方法:
1725
- * 1. 检查文件变更(指定 filepath 时)
1726
- * 2. 加载用户配置
1727
- * 3. 扫描页面文件
1728
- * 4. 合并页面配置
1729
- * 5. 生成并写入 pages.json
1730
- * @param filepath - 发生变更的文件路径,用于增量更新判断
1731
- * @returns pages.json 是否成功更新
3640
+ * 刷新页面配置并更新 pages.json
3641
+ *
3642
+ * @description 同一实例的调用按顺序执行;指定页面时尝试增量读取,页面尚未收录或物理身份变化时执行完整刷新
3643
+ *
3644
+ * @param filepath 变更页面的绝对路径或相对 root 的路径;省略时执行完整刷新
3645
+ * @returns pages.json 写入新内容时为 `true`,内容未变、上下文已关闭或跳过生成时为 `false`
3646
+ * @throws 生成、写入或声明输出失败时拒绝;声明输出失败前 pages.json 可能已更新
1732
3647
  */
1733
3648
  async updatePagesJSON(filepath) {
1734
- if (filepath) {
1735
- const page = this.findPage(filepath);
1736
- if (page) {
1737
- await page.read();
1738
- if (!page.hasChanged()) {
1739
- debug.cache(`The page meta on page ${filepath} did not send any changes, skipping`);
1740
- return false;
1741
- }
3649
+ return this.enqueuePagesUpdate(filepath ? [filepath] : void 0);
3650
+ }
3651
+ enqueuePagesUpdate(files) {
3652
+ if (this.disposed) return Promise.resolve(false);
3653
+ const update = this.pendingUpdate.then(() => this.disposed ? false : this.generatePagesJSON(files));
3654
+ this.pendingUpdate = update.then(() => {}, () => {});
3655
+ return update;
3656
+ }
3657
+ async generatePagesJSON(files) {
3658
+ const candidates = files?.map((file) => this.findPages(file));
3659
+ const incremental = !!candidates?.length && candidates.every((pages) => pages.length > 0);
3660
+ if (incremental) {
3661
+ const pages = [...new Set(candidates.flat())];
3662
+ await Promise.all(pages.map((page) => page.read()));
3663
+ if (!pages.some((page) => page.hasChanged())) {
3664
+ debug.cache("Page metadata is unchanged, skipping generation");
3665
+ return false;
1742
3666
  }
1743
3667
  }
3668
+ this.generatedSnapshot = void 0;
1744
3669
  checkPagesJsonFileSync(this.resolvedPagesJSONPath);
1745
3670
  this.options.onBeforeLoadUserConfig();
1746
3671
  await this.loadUserPagesConfig();
1747
3672
  this.options.onAfterLoadUserConfig(this.pagesGlobConfig);
1748
3673
  if (this.options.mergePages) {
1749
3674
  this.options.onBeforeScanPages();
1750
- await this.scanPages();
1751
- await this.scanSubPages();
3675
+ this.refreshScanPlan();
3676
+ await this.scanPages(!incremental);
3677
+ await this.scanSubPages(!incremental);
1752
3678
  this.options.onAfterScanPages(this.pages, this.subPages);
3679
+ this.startPageWatcher();
3680
+ this.pageWatcher.refresh(this.requireScanPlan());
1753
3681
  }
1754
3682
  this.options.onBeforeMergePageMetaData(this.pages, this.pagesGlobConfig);
1755
3683
  await this.mergePageMetaData();
@@ -1760,6 +3688,13 @@ var PageContext = class {
1760
3688
  this.options.onBeforeWriteFile(this.resolvedPagesJSONPath);
1761
3689
  const homePath = this.pageMetaData.find((meta) => meta.type === "home")?.path;
1762
3690
  this.tabBar = await this.resolveTabBar();
3691
+ this.generatedSnapshot = createPagesSnapshot({
3692
+ pages: this.pageMetaData,
3693
+ subPackages: this.subPageMetaData,
3694
+ tabBar: this.tabBar,
3695
+ globConfig: this.pagesGlobConfig,
3696
+ homePath
3697
+ });
1763
3698
  const result = await writePagesJson(this.resolvedPagesJSONPath, {
1764
3699
  pages: this.pageMetaData,
1765
3700
  subPackages: this.subPageMetaData,
@@ -1775,9 +3710,29 @@ var PageContext = class {
1775
3710
  insertFinalNewline: this.options.insertFinalNewline
1776
3711
  }
1777
3712
  });
1778
- this.generateDeclaration();
1779
- if (result?.updated) this.options.onAfterWriteFile(this.resolvedPagesJSONPath, result.content);
1780
- return result?.updated ?? false;
3713
+ if (!result) throw new Error(`[vite-plugin-uni-pages] Failed to acquire the pages.json file lock: ${this.resolvedPagesJSONPath}`);
3714
+ try {
3715
+ await this.generateDeclaration();
3716
+ } catch (error) {
3717
+ if (result.updated) {
3718
+ this.options.onAfterWriteFile(this.resolvedPagesJSONPath, result.content);
3719
+ this.onUpdate();
3720
+ }
3721
+ throw error;
3722
+ }
3723
+ if (result.updated) this.options.onAfterWriteFile(this.resolvedPagesJSONPath, result.content);
3724
+ if (this.configWatch.consumeExpanded() && !this.disposed) this.queuePageUpdate();
3725
+ return result.updated;
3726
+ }
3727
+ /** 最近一次生成的路由快照;未写入文件时由当前工作字段合成 */
3728
+ get snapshot() {
3729
+ return this.generatedSnapshot ?? createPagesSnapshot({
3730
+ pages: this.pageMetaData,
3731
+ subPackages: this.subPageMetaData,
3732
+ tabBar: this.tabBar,
3733
+ globConfig: this.pagesGlobConfig,
3734
+ homePath: this.pageMetaData.find((meta) => meta.type === "home")?.path
3735
+ });
1781
3736
  }
1782
3737
  /**
1783
3738
  * 生成虚拟模块内容
@@ -1785,21 +3740,22 @@ var PageContext = class {
1785
3740
  * @returns 虚拟模块代码字符串
1786
3741
  */
1787
3742
  virtualModule() {
1788
- return [`export const pages = ${this.resolveRoutes()};`, `export const subPackages = ${this.resolveSubRoutes()};`].join("\n");
3743
+ const { pages, subPackages } = this.snapshot;
3744
+ return [`export const pages = ${stringify(pages, null, 2)};`, `export const subPackages = ${stringify(subPackages, null, 2)};`].join("\n");
1789
3745
  }
1790
3746
  /**
1791
3747
  * 将主包路由数据解析为 JSON 字符串
1792
3748
  * @returns 主包页面配置的 JSON 字符串
1793
3749
  */
1794
3750
  resolveRoutes() {
1795
- return stringify(this.pageMetaData, null, 2);
3751
+ return stringify(this.snapshot.pages, null, 2);
1796
3752
  }
1797
3753
  /**
1798
- * 将子包路由数据解析为 JSON 字符串
1799
- * @returns 子包页面配置的 JSON 字符串
3754
+ * 将分包路由数据解析为 JSON 字符串
3755
+ * @returns 分包页面配置的 JSON 字符串
1800
3756
  */
1801
3757
  resolveSubRoutes() {
1802
- return stringify(this.subPageMetaData, null, 2);
3758
+ return stringify(this.snapshot.subPackages, null, 2);
1803
3759
  }
1804
3760
  /**
1805
3761
  * 解析 tabBar 配置
@@ -1832,64 +3788,183 @@ var PageContext = class {
1832
3788
  generateDeclaration() {
1833
3789
  if (!this.options.dts) return;
1834
3790
  debug.declaration("generating");
1835
- return writeDeclaration({
1836
- pages: this.pageMetaData,
1837
- subPackages: this.subPageMetaData,
1838
- tabBar: this.tabBar,
1839
- globConfig: this.pagesGlobConfig
1840
- }, this.options.dts);
3791
+ return writeDeclaration(this.snapshot, this.options.dts);
1841
3792
  }
1842
3793
  /**
1843
- * 扫描主包页面目录并收集所有页面文件路径
1844
- * 根据配置的 dirs 选项扫描对应目录
3794
+ * 按词法目录扫描主包页面
3795
+ *
3796
+ * @description 各目录分别执行文件排除,物理别名保留到页面有效性与归属决议阶段
1845
3797
  */
1846
- async scanPages() {
1847
- const paths = this.options.dirs.flatMap((dir) => getPagePaths(dir, this.options));
1848
- debug.pages(paths);
3798
+ async scanPages(refreshContent = true) {
3799
+ const plan = this.requireScanPlan();
1849
3800
  const pages = /* @__PURE__ */ new Map();
1850
- for (const path of paths) {
1851
- const page = this.pages.get(path.absolutePath) || new Page(this, path);
1852
- pages.set(path.absolutePath, page);
3801
+ for (const [matchIndex, dir] of plan.mainDirs.entries()) for (const pagePath of getPagePaths(dir, this.options, plan.basePath)) {
3802
+ if (pages.has(pagePath.absolutePath)) continue;
3803
+ const page = this.trackPage(pagePath, this.pages.get(pagePath.absolutePath) ?? this.mainPageCandidates.get(pagePath.absolutePath), {
3804
+ dir,
3805
+ explicitRoot: false,
3806
+ ruleIndex: 0,
3807
+ matchIndex
3808
+ }, refreshContent);
3809
+ if (page) pages.set(pagePath.absolutePath, page);
1853
3810
  }
1854
- this.pages = pages;
3811
+ debug.pages([...pages.keys()]);
3812
+ this.mainPageCandidates = pages;
3813
+ this.pages = new Map(pages);
3814
+ }
3815
+ /**
3816
+ * 登记本轮来源并复用身份与输出基准均未变化的 Page
3817
+ *
3818
+ * @description 整轮更新使读取缓存失效,增量更新复用目标页的预读结果与其他页面缓存
3819
+ * @returns 路径仍存在时返回页面实例,扫描期间已消失时为 undefined
3820
+ */
3821
+ trackPage(pagePath, previous, source, refreshContent) {
3822
+ const identity = resolvePageIdentity(pagePath.absolutePath);
3823
+ if (!identity) return;
3824
+ const page = previous && this.pageSources.get(previous)?.identity === identity && previous.path.relativePath === pagePath.relativePath ? previous : new Page(this.pageEnvironment(), pagePath);
3825
+ if (refreshContent) page.invalidate();
3826
+ this.pageSources.set(page, {
3827
+ file: pagePath.absolutePath,
3828
+ identity,
3829
+ dir: source.dir,
3830
+ root: source.root,
3831
+ explicitRoot: source.explicitRoot,
3832
+ ruleIndex: source.ruleIndex,
3833
+ matchIndex: source.matchIndex,
3834
+ rootSource: source.rootSource,
3835
+ rulePattern: this.configuredRule(source)
3836
+ });
3837
+ return page;
3838
+ }
3839
+ pageEnvironment() {
3840
+ return {
3841
+ platform: this.platform,
3842
+ getLogger: () => this.logger
3843
+ };
3844
+ }
3845
+ /** 页面来源的原始声明规则,用于冲突诊断;hook 页面可能没有 */
3846
+ configuredRule(source) {
3847
+ if (source.root === void 0) return this.options.dir;
3848
+ const pattern = this.options.subPackagePatterns[source.ruleIndex];
3849
+ return typeof pattern === "string" ? pattern : pattern?.dir;
1855
3850
  }
1856
3851
  /**
1857
- * 按页面文件绝对路径查找被跟踪的页面(主包或任一子包)
3852
+ * 按词法路径或物理身份查找全部缓存候选
1858
3853
  * @param filepath - 页面文件的绝对路径
1859
- * @returns 被跟踪的页面,文件未被跟踪时为 undefined
3854
+ * @returns 主包和分包中属于同一文件的页面实例,每个实例只返回一次
1860
3855
  */
1861
- findPage(filepath) {
1862
- const mainPage = this.pages.get(filepath);
1863
- if (mainPage) return mainPage;
1864
- for (const pages of this.subPages.values()) {
1865
- const subPage = pages.get(filepath);
1866
- if (subPage) return subPage;
1867
- }
3856
+ findPages(filepath) {
3857
+ const absolute = normalizePath(path.resolve(this.root, filepath));
3858
+ const groups = [
3859
+ this.mainPageCandidates,
3860
+ ...this.subPageCandidates.values(),
3861
+ this.pages,
3862
+ ...this.subPages.values()
3863
+ ];
3864
+ const identity = resolvePageIdentity(absolute);
3865
+ if (!identity) return [];
3866
+ const matched = groups.flatMap((pages) => [...pages.entries()].flatMap(([file, page]) => {
3867
+ const source = this.pageSources.get(page);
3868
+ return file === absolute || source?.identity === identity ? [page] : [];
3869
+ }));
3870
+ if (matched.some((page) => this.pageSources.get(page)?.identity !== identity || resolvePageIdentity(page.path.absolutePath) !== identity)) return [];
3871
+ return [...new Set(matched)];
1868
3872
  }
1869
3873
  /**
1870
- * 扫描子包页面目录并收集所有子包页面文件路径
1871
- * 根据配置的 subPackages 选项扫描对应目录
3874
+ * 按词法来源扫描分包页面
3875
+ *
3876
+ * @description 同一物理目录的不同别名仍分别执行文件排除,避免某个别名被排除后漏页
1872
3877
  */
1873
- async scanSubPages() {
3878
+ async scanSubPages(refreshContent = true) {
3879
+ const plan = this.requireScanPlan();
1874
3880
  const paths = {};
1875
3881
  const subPages = /* @__PURE__ */ new Map();
1876
- for (const dir of this.options.subPackages) {
1877
- const pagePaths = getPagePaths(dir, this.options);
3882
+ const sources = this.subPackageSourcesByDir();
3883
+ for (const [dir, source] of sources) {
3884
+ const pagePaths = getPagePaths(dir, this.options, plan.basePath);
1878
3885
  paths[dir] = pagePaths;
1879
3886
  const pages = /* @__PURE__ */ new Map();
1880
- for (const path of pagePaths) {
1881
- const page = this.subPages.get(dir)?.get(path.absolutePath) || new Page(this, path);
1882
- pages.set(path.absolutePath, page);
3887
+ for (const pagePath of pagePaths) {
3888
+ const page = this.trackPage(pagePath, this.subPages.get(dir)?.get(pagePath.absolutePath) ?? this.subPageCandidates.get(dir)?.get(pagePath.absolutePath), source, refreshContent);
3889
+ if (page) pages.set(pagePath.absolutePath, page);
1883
3890
  }
1884
3891
  subPages.set(dir, pages);
1885
3892
  }
1886
3893
  debug.subPages(JSON.stringify(paths, null, 2));
1887
- this.subPages = subPages;
3894
+ this.subPageCandidates = subPages;
3895
+ this.subPages = new Map([...subPages].map(([dir, pages]) => [dir, new Map(pages)]));
3896
+ }
3897
+ /**
3898
+ * 为每个物理扫描目录选取声明来源
3899
+ *
3900
+ * @description 同一路径的不同写法只扫描一次,定位信息按显式优先选择;
3901
+ * 符号链接别名是不同的 absolute,仍分别保留各自的词法路径
3902
+ */
3903
+ subPackageSourcesByDir() {
3904
+ const selected = /* @__PURE__ */ new Map();
3905
+ const sources = this.scanPlan?.subPackageSources ?? [];
3906
+ for (const source of sources) {
3907
+ const previous = selected.get(source.absolute);
3908
+ if (!previous || comparePageSources(source, previous) < 0) selected.set(source.absolute, source);
3909
+ }
3910
+ return new Map([...selected.values()].map((source) => [source.dir, source]));
3911
+ }
3912
+ /** 获取目录的最终分包 root,未登记时相对 outDir 计算默认值 */
3913
+ subPackageRoot(dir) {
3914
+ return this.scanPlan?.subPackageRootMap.get(dir) ?? this.options.subPackageRootMap.get(dir) ?? normalizePath(path.relative(this.basePath, path.resolve(this.options.root, dir)));
3915
+ }
3916
+ /**
3917
+ * 决议有效页面的归属并校验生成路由
3918
+ *
3919
+ * @description 先读取宏并应用既有平台规则,再选择代表;冲突校验通过后才裁剪页面映射,
3920
+ * 配置覆盖与输出去重在此之后执行
3921
+ */
3922
+ async resolveScanSources() {
3923
+ const candidates = [];
3924
+ const subSources = this.options.mergePages ? this.subPackageSourcesByDir() : /* @__PURE__ */ new Map();
3925
+ const groups = [[void 0, this.pages], ...this.subPages];
3926
+ await Promise.all(groups.flatMap(([, pages]) => [...pages.values()].map((page) => page.ensureLoaded())));
3927
+ for (const [dir, pages] of groups) {
3928
+ const root = dir === void 0 ? void 0 : this.subPackageRoot(dir);
3929
+ for (const [file, page] of pages) {
3930
+ if (page.skipped) continue;
3931
+ const meta = await page.getPageMeta();
3932
+ const effective = dir === void 0 && this.withUniPlatform ? filterPlatformSuffixPages([meta], this.platform)[0] : meta;
3933
+ if (!effective) continue;
3934
+ const original = this.pageSources.get(page);
3935
+ const identity = original?.identity ?? resolvePageIdentity(file);
3936
+ if (!identity) continue;
3937
+ const source = dir === void 0 ? void 0 : subSources.get(dir);
3938
+ const ruleIndex = source?.ruleIndex ?? original?.ruleIndex ?? this.options.subPackages.indexOf(dir);
3939
+ candidates.push({
3940
+ ...original,
3941
+ ...source,
3942
+ page,
3943
+ file,
3944
+ identity,
3945
+ dir: dir ?? original?.dir ?? this.options.dir,
3946
+ root,
3947
+ explicitRoot: source?.explicitRoot ?? original?.explicitRoot ?? false,
3948
+ ruleIndex,
3949
+ matchIndex: source?.matchIndex ?? original?.matchIndex ?? 0,
3950
+ rulePattern: original?.rulePattern ?? this.configuredRule({
3951
+ root,
3952
+ ruleIndex
3953
+ }),
3954
+ route: root === void 0 ? effective.path : path.posix.join(root, normalizePath(path.relative(root, effective.path)))
3955
+ });
3956
+ }
3957
+ }
3958
+ const selected = selectPageSources(candidates);
3959
+ validateGeneratedRoutes(selected, this.withUniPlatform ? this.platform : void 0);
3960
+ const active = new Set(candidates.map((source) => source.page));
3961
+ const representatives = new Set(selected.map((source) => source.page));
3962
+ for (const pages of [this.pages, ...this.subPages.values()]) for (const [file, page] of pages) if (active.has(page) && !representatives.has(page)) pages.delete(file);
1888
3963
  }
1889
3964
  /**
1890
3965
  * 解析 pages 规则并设置页面类型
1891
3966
  * @param pages 页面路径映射
1892
- * @param packageType 页面包类型(主包或子包)
3967
+ * @param packageType 页面包类型(主包或分包)
1893
3968
  * @param overrides 自定义页面配置
1894
3969
  * @returns pages 规则
1895
3970
  */
@@ -1929,25 +4004,33 @@ var PageContext = class {
1929
4004
  return result;
1930
4005
  }
1931
4006
  /**
1932
- * 合并主包页面配置
1933
- * 过滤掉属于子包的页面,再解析页面配置并与用户配置合并
4007
+ * 决议扫描来源后合并主包页面配置
4008
+ *
4009
+ * @description 有效分包优先认领物理页面,生成来源冲突在用户配置覆盖前检查
1934
4010
  */
1935
4011
  async mergePageMetaData() {
1936
- for (const pages of this.subPages.values()) for (const subPageAbsolutePath of pages.keys()) this.pages.delete(subPageAbsolutePath);
4012
+ await this.resolveScanSources();
1937
4013
  const pageMetaData = await this.parsePages(this.pages, "main", this.pagesGlobConfig?.pages);
1938
4014
  this.pageMetaData = pageMetaData;
1939
4015
  debug.pages(this.pageMetaData);
1940
4016
  }
1941
4017
  /**
1942
- * 合并子包页面配置
1943
- * 为每个子包解析页面配置并处理子包配置继承
1944
- * 保留用户配置中子包级别的属性(如 plugins)
4018
+ * 合并分包页面配置
4019
+ *
4020
+ * @description 静态、glob 和回调来源先按最终 root 汇集,再对整组应用一次配置覆盖,
4021
+ * 保留手工页面和分包 plugins 配置
1945
4022
  */
1946
4023
  async mergeSubPageMetaData() {
1947
4024
  const packagesByRoot = /* @__PURE__ */ new Map();
1948
4025
  const subPackages = this.pagesGlobConfig?.subPackages || [];
4026
+ const pagesByRoot = /* @__PURE__ */ new Map();
1949
4027
  for (const [dir, pages] of this.subPages) {
1950
- const root = this.options.subPackageRootMap.get(dir) ?? normalizePath(path.relative(this.basePath, path.join(this.options.root, dir)));
4028
+ const root = this.subPackageRoot(dir);
4029
+ const collected = pagesByRoot.get(root) ?? /* @__PURE__ */ new Map();
4030
+ for (const [file, page] of pages) collected.set(file, page);
4031
+ pagesByRoot.set(root, collected);
4032
+ }
4033
+ for (const [root, pages] of pagesByRoot) {
1951
4034
  const globPackage = subPackages?.find((v) => v.root === root);
1952
4035
  const overrides = globPackage?.pages?.map((page) => {
1953
4036
  if (!page.path || page.path.startsWith(`${root}/`)) return page;
@@ -1989,9 +4072,8 @@ function resolveBasePath(options) {
1989
4072
  * @param options - 解析后的配置项
1990
4073
  * @returns 包含相对路径与绝对路径的页面路径数组
1991
4074
  */
1992
- function getPagePaths(dir, options) {
4075
+ function getPagePaths(dir, options, basePath) {
1993
4076
  const pagesDirPath = normalizePath(path.resolve(options.root, dir));
1994
- const basePath = resolveBasePath(options);
1995
4077
  const files = getPageFiles(pagesDirPath, options);
1996
4078
  debug.pages(dir, files);
1997
4079
  return files.map((file) => normalizePath(file)).map((file) => ({
@@ -2134,25 +4216,75 @@ async function generateAll(userOptions = {}, overrides = {}) {
2134
4216
  * @returns Vite 插件实例
2135
4217
  */
2136
4218
  function VitePluginUniPages(userOptions = {}) {
2137
- let ctx;
2138
- checkPagesJsonFileSync(resolvePagesJsonPath(process.env.VITE_ROOT_DIR || process.cwd(), userOptions.outDir ?? "src"));
2139
- return {
4219
+ let initialization;
4220
+ let preparedParams;
4221
+ const resolvedPagesJSONPath = resolvePagesJsonPath(process.env.VITE_ROOT_DIR || process.cwd(), userOptions.outDir ?? "src");
4222
+ if (existsSync(path.dirname(resolvedPagesJSONPath))) checkPagesJsonFileSync(resolvedPagesJSONPath);
4223
+ const plugin = {
2140
4224
  name: "vite-plugin-uni-pages",
2141
4225
  enforce: "pre",
4226
+ prepare(options) {
4227
+ if (typeof options?.platformSuffix !== "boolean") return Promise.reject(/* @__PURE__ */ new TypeError("[vite-plugin-uni-pages] prepare() requires an explicit boolean platformSuffix option."));
4228
+ try {
4229
+ return initialize(options.root ?? process.env.VITE_ROOT_DIR ?? process.cwd(), options.platformSuffix, "prepare").promise;
4230
+ } catch (error) {
4231
+ return Promise.reject(error);
4232
+ }
4233
+ },
2142
4234
  /**
2143
4235
  * Vite configResolved 钩子
2144
4236
  * 初始化 PageContext,设置 logger,生成初始 pages.json
2145
4237
  */
2146
4238
  async configResolved(config) {
2147
- ctx = new PageContext(userOptions, config.root);
2148
- if (config.plugins.some((v) => v.name === "vite-plugin-uni-platform")) ctx.withUniPlatform = true;
2149
- const logger = createLogger(void 0, { prefix: "[vite-plugin-uni-pages]" });
2150
- ctx.setLogger(logger);
2151
- await ctx.updatePagesJSON();
2152
- if (config.command === "build") {
2153
- if (config.build.watch) ctx.setupWatcher(chokidar.watch([...ctx.options.dirs, ...ctx.options.subPackages].map((v) => normalizePath(path.resolve(config.root, v)))));
4239
+ const state = initialize(config.root, config.plugins.some((v) => v.name === "vite-plugin-uni-platform"), config.command);
4240
+ state.watching = config.command === "build" && !!config.build.watch;
4241
+ try {
4242
+ await state.promise;
4243
+ if (state.closing) throw new Error("[vite-plugin-uni-pages] The pages context was closed during initialization.");
4244
+ if (state.watching) {
4245
+ const lockDirectory = normalizeConfigPath(`${state.context.resolvedPagesJSONPath}.lock`, config.root);
4246
+ state.buildWatcher = chokidar.watch([], {
4247
+ ignoreInitial: true,
4248
+ followSymlinks: false,
4249
+ ignored: (file) => {
4250
+ const absolute = normalizeConfigPath(file, config.root);
4251
+ return absolute.split("/").some((part) => part === "node_modules" || part === ".git") || absolute === lockDirectory || absolute.startsWith(`${lockDirectory}/`);
4252
+ }
4253
+ });
4254
+ await state.context.setupWatcher(state.buildWatcher);
4255
+ }
4256
+ } catch (error) {
4257
+ await release(state);
4258
+ throw error;
2154
4259
  }
2155
4260
  },
4261
+ options(options) {
4262
+ const state = initialization;
4263
+ if (state?.command !== "build") return;
4264
+ const hooks = {
4265
+ options: void 0,
4266
+ load: (id) => loadVirtualModule(state, id),
4267
+ closeWatcher: () => release(state),
4268
+ closeBundle: () => state.watching ? void 0 : release(state)
4269
+ };
4270
+ const plugins = Array.isArray(options.plugins) ? options.plugins : [options.plugins];
4271
+ plugins.forEach((candidate, index) => {
4272
+ if (candidate && typeof candidate === "object" && "options" in candidate && candidate.options === plugin.options) plugins[index] = {
4273
+ ...candidate,
4274
+ ...hooks
4275
+ };
4276
+ });
4277
+ return {
4278
+ ...options,
4279
+ plugins
4280
+ };
4281
+ },
4282
+ async closeWatcher() {
4283
+ if (initialization) await release(initialization);
4284
+ },
4285
+ async closeBundle() {
4286
+ if (initialization && !initialization.watching) await release(initialization);
4287
+ },
2156
4288
  /**
2157
4289
  * 代码转换钩子
2158
4290
  * 从 Vue SFC 中移除 definePage 宏调用,避免运行时报错
@@ -2179,7 +4311,7 @@ function VitePluginUniPages(userOptions = {}) {
2179
4311
  * 设置文件监听与 HMR 支持
2180
4312
  */
2181
4313
  configureServer(server) {
2182
- ctx.setupViteServer(server);
4314
+ initialization?.context.setupViteServer(server);
2183
4315
  },
2184
4316
  /**
2185
4317
  * 模块解析钩子
@@ -2193,9 +4325,99 @@ function VitePluginUniPages(userOptions = {}) {
2193
4325
  * 返回虚拟模块的代码内容
2194
4326
  */
2195
4327
  load(id) {
2196
- if (id === RESOLVED_MODULE_ID_VIRTUAL) return ctx.virtualModule();
4328
+ return loadVirtualModule(initialization, id);
2197
4329
  }
2198
4330
  };
4331
+ function initialize(root, platformSuffix, source) {
4332
+ const resolvedRoot = normalizePath(path.resolve(root));
4333
+ const platform = process.env.UNI_PLATFORM;
4334
+ if (!platform) throw new Error("[vite-plugin-uni-pages] UNI_PLATFORM must be set before initializing pages. Run through the uni-app CLI or set the compilation platform before calling the plugin.");
4335
+ const params = {
4336
+ root: resolvedRoot,
4337
+ platform,
4338
+ platformSuffix
4339
+ };
4340
+ if (initialization?.closing || source !== "prepare" && initialization?.command) throw new Error("[vite-plugin-uni-pages] This plugin instance already has an active Vite session. Wait for it to close, or create a separate UniPages() instance for overlapping builds and servers. If Vite exited before starting Rollup or the dev server, this instance cannot be released either.");
4341
+ const expected = preparedParams ?? initialization?.params;
4342
+ const mismatched = expected && Object.keys(params).find((key) => expected[key] !== params[key]);
4343
+ if (mismatched) throw new Error(`[vite-plugin-uni-pages] ${mismatched} does not match the initialized pages context. Use the same root, UNI_PLATFORM and UniPlatform setting for prepare() and Vite.`);
4344
+ if (initialization) {
4345
+ if (source === "prepare") {
4346
+ initialization.prepared = true;
4347
+ if (initialization.ready) preparedParams = params;
4348
+ } else initialization.command = source;
4349
+ return initialization;
4350
+ }
4351
+ const context = new PageContext(userOptions, resolvedRoot, platform);
4352
+ context.withUniPlatform = platformSuffix;
4353
+ context.setLogger(createLogger(void 0, { prefix: "[vite-plugin-uni-pages]" }));
4354
+ const state = {
4355
+ params,
4356
+ context,
4357
+ prepared: source === "prepare",
4358
+ ready: false,
4359
+ command: source === "prepare" ? void 0 : source,
4360
+ watching: false,
4361
+ promise: Promise.resolve().then(async () => {
4362
+ await context.updatePagesJSON();
4363
+ if (state.closing) throw new Error("[vite-plugin-uni-pages] The pages context was closed during initialization.");
4364
+ state.ready = true;
4365
+ if (state.prepared) preparedParams = params;
4366
+ return plugin;
4367
+ }).catch(async (error) => {
4368
+ try {
4369
+ await context.disposeWatchers();
4370
+ } catch (cleanupError) {
4371
+ reportReleaseFailure(state, cleanupError);
4372
+ } finally {
4373
+ if (initialization === state && !state.closing) initialization = void 0;
4374
+ }
4375
+ throw error;
4376
+ })
4377
+ };
4378
+ initialization = state;
4379
+ return state;
4380
+ }
4381
+ function loadVirtualModule(state, id) {
4382
+ if (id !== "\0virtual:uni-pages") return;
4383
+ if (!state || state.closing) throw new Error("[vite-plugin-uni-pages] The pages context is closed.");
4384
+ return state.context.virtualModule();
4385
+ }
4386
+ /** 记录释放失败;关闭流程不因清理错误失败 */
4387
+ function reportReleaseFailure(state, error) {
4388
+ state.context.logger?.error(`Failed to release the pages context: ${error instanceof Error ? error.message : error}`, {
4389
+ timestamp: true,
4390
+ error: error instanceof Error ? error : void 0
4391
+ });
4392
+ }
4393
+ /**
4394
+ * 释放当前会话
4395
+ *
4396
+ * @description 解绑监听、等待已开始的生成并关闭构建 watcher;清理错误只记录,完成后释放实例占用
4397
+ */
4398
+ function release(state) {
4399
+ if (!state.closing) {
4400
+ const disposing = state.context.disposeWatchers();
4401
+ state.closing = (async () => {
4402
+ try {
4403
+ await disposing;
4404
+ } catch (error) {
4405
+ reportReleaseFailure(state, error);
4406
+ } finally {
4407
+ await state.promise.catch(() => {});
4408
+ try {
4409
+ await state.buildWatcher?.close();
4410
+ } catch (error) {
4411
+ reportReleaseFailure(state, error);
4412
+ } finally {
4413
+ if (initialization === state) initialization = void 0;
4414
+ }
4415
+ }
4416
+ })();
4417
+ }
4418
+ return state.closing;
4419
+ }
4420
+ return plugin;
2199
4421
  }
2200
4422
  //#endregion
2201
- export { DefineConditional, FILE_EXTENSIONS, MODULE_ID_VIRTUAL, OUTPUT_NAME, Page, PageContext, RESOLVED_MODULE_ID_VIRTUAL, VitePluginUniPages, VitePluginUniPages as default, checkPagesJsonFileSync, createPages, debug, defineUniPages, evaluateDefinePage, filterPlatformSuffixPages, findDefinePageMacro, generateAll, getPageFiles, isConditional, isTargetFile, mergePagesJson, parseSFC, resolveConditional, resolveOptions, resolvePageDirs, resolvePagesJsonPath, withFileLock, writePagesJson };
4423
+ export { DefineConditional, FILE_EXTENSIONS, MODULE_ID_VIRTUAL, OUTPUT_NAME, Page, PageContext, RESOLVED_MODULE_ID_VIRTUAL, VitePluginUniPages, VitePluginUniPages as default, checkPagesJsonFileSync, createPages, debug, defineUniPages, discoverSubPackageDirectories, evaluateDefinePage, filterPlatformSuffixPages, findDefinePageMacro, generateAll, getPageFiles, getSubPackageDirectorySources, isConditional, isMissingFsError, isPageDirectoryEvent, isPageFileInDirectories, isTargetFile, mergePagesJson, normalizePagePattern, parseSFC, refreshPageDirectories, resolveConditional, resolveOptions, resolvePageDirs, resolvePagesJsonPath, resolveSubPackageDirs, retryOnEperm, unescapePagePattern, withFileLock, writePagesJson };