@lownoise-studio/rendershield 1.0.0 → 1.1.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 (54) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +32 -14
  3. package/dist/cli.js +28 -23
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cliArgs.d.ts +8 -0
  6. package/dist/cliArgs.d.ts.map +1 -0
  7. package/dist/cliArgs.js +56 -0
  8. package/dist/cliArgs.js.map +1 -0
  9. package/dist/commands/build.d.ts +2 -1
  10. package/dist/commands/build.d.ts.map +1 -1
  11. package/dist/commands/build.js +2 -2
  12. package/dist/commands/build.js.map +1 -1
  13. package/dist/commands/init.d.ts +2 -1
  14. package/dist/commands/init.d.ts.map +1 -1
  15. package/dist/commands/init.js +8 -7
  16. package/dist/commands/init.js.map +1 -1
  17. package/dist/commands/verify.d.ts +22 -8
  18. package/dist/commands/verify.d.ts.map +1 -1
  19. package/dist/commands/verify.js +158 -66
  20. package/dist/commands/verify.js.map +1 -1
  21. package/dist/configPath.d.ts +7 -0
  22. package/dist/configPath.d.ts.map +1 -0
  23. package/dist/configPath.js +8 -0
  24. package/dist/configPath.js.map +1 -0
  25. package/dist/core/generateWorker.js +1 -1
  26. package/dist/core/generateWorker.js.map +1 -1
  27. package/dist/core/listOutputRoutes.d.ts +4 -0
  28. package/dist/core/listOutputRoutes.d.ts.map +1 -0
  29. package/dist/core/listOutputRoutes.js +40 -0
  30. package/dist/core/listOutputRoutes.js.map +1 -0
  31. package/dist/core/loadConfig.d.ts +3 -1
  32. package/dist/core/loadConfig.d.ts.map +1 -1
  33. package/dist/core/loadConfig.js +45 -38
  34. package/dist/core/loadConfig.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/types.d.ts +1 -1
  40. package/dist/types.d.ts.map +1 -1
  41. package/docs/CONFIG.md +70 -0
  42. package/package.json +2 -1
  43. package/rendershield.config.schema.json +100 -0
  44. package/src/cli.ts +36 -26
  45. package/src/cliArgs.ts +71 -0
  46. package/src/commands/build.ts +3 -2
  47. package/src/commands/init.ts +8 -8
  48. package/src/commands/verify.ts +264 -79
  49. package/src/configPath.ts +17 -0
  50. package/src/core/generateWorker.ts +1 -1
  51. package/src/core/listOutputRoutes.ts +51 -0
  52. package/src/core/loadConfig.ts +59 -38
  53. package/src/index.ts +4 -1
  54. package/src/types.ts +1 -1
@@ -1,11 +1,16 @@
1
1
  import fs from "fs-extra";
2
2
  import path from "node:path";
3
3
  import { loadConfig } from "../core/loadConfig.js";
4
+ import {
5
+ listPrerenderIndexFiles,
6
+ indexHtmlPathToRoute,
7
+ } from "../core/listOutputRoutes.js";
4
8
  import {
5
9
  checkPrerenderContract,
6
10
  type ContractCheckResult,
7
11
  } from "../core/validateOutput.js";
8
12
  import { renderShieldError } from "../errors.js";
13
+ import type { CommandOptions } from "../configPath.js";
9
14
 
10
15
  function joinUrl(base: string, routePath: string): string {
11
16
  const b = base.endsWith("/") ? base.slice(0, -1) : base;
@@ -13,56 +18,11 @@ function joinUrl(base: string, routePath: string): string {
13
18
  return b + p;
14
19
  }
15
20
 
16
- async function findFirstIndexHtml(outDirAbs: string): Promise<string | null> {
17
- const stack: string[] = [outDirAbs];
18
-
19
- while (stack.length > 0) {
20
- const current = stack.pop() as string;
21
-
22
- let entries: fs.Dirent[];
23
- try {
24
- entries = await fs.readdir(current, { withFileTypes: true });
25
- } catch {
26
- continue;
27
- }
28
-
29
- entries.sort((a, b) => a.name.localeCompare(b.name));
30
-
31
- for (const entry of entries) {
32
- const full = path.join(current, entry.name);
33
-
34
- if (entry.isDirectory()) {
35
- stack.push(full);
36
- continue;
37
- }
38
-
39
- if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
40
- // Ignore index.html at the output root; prefer a routed page like
41
- // <section>/<slug>/index.html (or deeper).
42
- const rel = path.relative(outDirAbs, full);
43
- const parts = rel.split(path.sep).filter(Boolean);
44
- if (parts.length >= 2) return full;
45
- }
46
- }
47
- }
48
-
49
- return null;
50
- }
51
-
52
- function indexHtmlPathToRoute(outDirAbs: string, indexPathAbs: string): string {
53
- const rel = path.relative(outDirAbs, indexPathAbs);
54
- // rel: <section>/<slug>/index.html
55
- const noFile = rel.replace(/index\.html$/i, "");
56
- const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
57
- return "/" + normalized.replace(/^\/+/, "");
58
- }
59
-
60
21
  const BOT_UA =
61
22
  "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
62
23
  const HUMAN_UA =
63
24
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
64
25
 
65
- /** Heuristic: likely SPA shell if body has almost no visible content and no article. */
66
26
  function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
67
27
  const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
68
28
  const bodyHtml = bodyMatch ? bodyMatch[1] : html;
@@ -86,48 +46,136 @@ function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
86
46
  return { likely: false };
87
47
  }
88
48
 
89
- export type VerifyProdOptions = { prodUrl: string };
49
+ export type VerifyPageResult = {
50
+ routePath: string;
51
+ outputFile: string;
52
+ url: string;
53
+ contract?: ContractCheckResult;
54
+ };
55
+
56
+ export type VerifyOptions = CommandOptions & {
57
+ /** Set when --prod is passed (URL may be omitted with --all). */
58
+ prod?: boolean;
59
+ prodUrl?: string;
60
+ check?: boolean;
61
+ all?: boolean;
62
+ };
90
63
 
91
64
  export type VerifyLocalResult = {
92
65
  mode: "local";
93
66
  canonicalBase: string;
94
- routePath: string;
95
- outputFile: string;
96
- url: string;
67
+ checked: boolean;
68
+ ok: boolean;
69
+ pages: VerifyPageResult[];
97
70
  };
98
71
 
99
72
  export type VerifyProdResult = {
100
73
  mode: "prod";
101
- url: string;
102
- contract: ContractCheckResult;
74
+ ok: boolean;
75
+ pages: Array<{ url: string; contract: ContractCheckResult }>;
103
76
  };
104
77
 
105
78
  export type VerifyResult = VerifyLocalResult | VerifyProdResult;
106
79
 
107
80
  export async function cmdVerify(
108
81
  cwd = process.cwd(),
109
- options?: VerifyProdOptions
82
+ options: VerifyOptions = {}
110
83
  ): Promise<VerifyResult> {
111
- if (options?.prodUrl) {
112
- return runVerifyProd(options.prodUrl);
84
+ if (options.prod || options.prodUrl) {
85
+ return runVerifyProdMode(cwd, options);
113
86
  }
114
87
 
115
- const cfg = await loadConfig(cwd);
88
+ if (options.check) {
89
+ return runLocalCheckMode(cwd, options);
90
+ }
116
91
 
92
+ if (options.all) {
93
+ throw renderShieldError(
94
+ "CLI_INVALID_ARGS",
95
+ "verify --all requires --check (local) or --prod (production). Example: rendershield verify --all --check"
96
+ );
97
+ }
98
+
99
+ return runLocalSmoke(cwd, options);
100
+ }
101
+
102
+ async function runVerifyProdMode(
103
+ cwd: string,
104
+ options: VerifyOptions
105
+ ): Promise<VerifyProdResult> {
106
+ const cfg = await loadConfig(cwd, options);
117
107
  const outDirAbs = path.join(cwd, cfg.output.outDir);
118
- const exists = await fs.pathExists(outDirAbs);
119
108
 
120
- if (!exists) {
109
+ if (options.all) {
110
+ await assertOutputExists(outDirAbs, cfg.output.outDir);
111
+ const indexFiles = await listPrerenderIndexFiles(outDirAbs);
112
+ if (indexFiles.length === 0) {
113
+ throw renderShieldError(
114
+ "VERIFY_FAILED",
115
+ `No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
116
+ { outDir: cfg.output.outDir }
117
+ );
118
+ }
119
+ return runVerifyProdAll(cfg, cwd, outDirAbs, indexFiles);
120
+ }
121
+
122
+ if (!options.prodUrl) {
123
+ throw renderShieldError(
124
+ "CLI_INVALID_ARGS",
125
+ "verify --prod requires a URL, or use --prod --all to check every route from build output."
126
+ );
127
+ }
128
+
129
+ const single = await fetchAndVerifyProd(
130
+ options.prodUrl.startsWith("http") ? options.prodUrl : `https://${options.prodUrl}`
131
+ );
132
+ return { mode: "prod", ok: true, pages: [single] };
133
+ }
134
+
135
+ async function runLocalCheckMode(
136
+ cwd: string,
137
+ options: VerifyOptions
138
+ ): Promise<VerifyLocalResult> {
139
+ const cfg = await loadConfig(cwd, options);
140
+ const outDirAbs = path.join(cwd, cfg.output.outDir);
141
+ await assertOutputExists(outDirAbs, cfg.output.outDir);
142
+
143
+ const indexFiles = await listPrerenderIndexFiles(outDirAbs);
144
+ if (indexFiles.length === 0) {
121
145
  throw renderShieldError(
122
146
  "VERIFY_FAILED",
123
- `No prerender output directory found: ${cfg.output.outDir}/. Run: rendershield build`,
147
+ `No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
124
148
  { outDir: cfg.output.outDir }
125
149
  );
126
150
  }
127
151
 
128
- const firstIndex = await findFirstIndexHtml(outDirAbs);
152
+ if (options.all) {
153
+ return runLocalCheckAll(cwd, cfg, outDirAbs, indexFiles);
154
+ }
155
+
156
+ return runLocalCheckOne(cwd, cfg, outDirAbs, indexFiles[0]);
157
+ }
129
158
 
130
- if (!firstIndex) {
159
+ async function assertOutputExists(outDirAbs: string, outDir: string): Promise<void> {
160
+ if (!(await fs.pathExists(outDirAbs))) {
161
+ throw renderShieldError(
162
+ "VERIFY_FAILED",
163
+ `No prerender output directory found: ${outDir}/. Run: rendershield build`,
164
+ { outDir }
165
+ );
166
+ }
167
+ }
168
+
169
+ async function runLocalSmoke(
170
+ cwd: string,
171
+ options: VerifyOptions
172
+ ): Promise<VerifyLocalResult> {
173
+ const cfg = await loadConfig(cwd, options);
174
+ const outDirAbs = path.join(cwd, cfg.output.outDir);
175
+ await assertOutputExists(outDirAbs, cfg.output.outDir);
176
+
177
+ const indexFiles = await listPrerenderIndexFiles(outDirAbs);
178
+ if (indexFiles.length === 0) {
131
179
  throw renderShieldError(
132
180
  "VERIFY_FAILED",
133
181
  `No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
@@ -135,6 +183,7 @@ export async function cmdVerify(
135
183
  );
136
184
  }
137
185
 
186
+ const firstIndex = indexFiles[0];
138
187
  const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
139
188
  const url = joinUrl(cfg.site.canonicalBase, routePath);
140
189
  const outputFile = path.relative(cwd, firstIndex);
@@ -146,6 +195,7 @@ Using:
146
195
  canonicalBase: ${cfg.site.canonicalBase}
147
196
  routePath: ${routePath}
148
197
  output file: ${outputFile}
198
+ pages in output: ${indexFiles.length} (showing first; use --all --check to validate all)
149
199
 
150
200
  Smoke tests:
151
201
 
@@ -160,20 +210,158 @@ Smoke tests:
160
210
 
161
211
  Expected: x-rendershield: bot-hit (proves Worker served prerender to bot).
162
212
  If debugHeaders enabled: X-Bot-Detected, X-Prerender, X-Final-Path.
213
+
214
+ Tip: rendershield verify --check validates built HTML without fetching production.
215
+ `);
216
+
217
+ return {
218
+ mode: "local",
219
+ canonicalBase: cfg.site.canonicalBase,
220
+ checked: false,
221
+ ok: true,
222
+ pages: [{ routePath, outputFile, url }],
223
+ };
224
+ }
225
+
226
+ async function runLocalCheckOne(
227
+ cwd: string,
228
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
229
+ outDirAbs: string,
230
+ indexPath: string
231
+ ): Promise<VerifyLocalResult> {
232
+ const page = await checkLocalPage(cwd, outDirAbs, indexPath, cfg.site.canonicalBase);
233
+ const ok = page.contract?.ok ?? false;
234
+
235
+ console.log(`
236
+ RenderShield verify --check
237
+ Route: ${page.routePath}
238
+ File: ${page.outputFile}
239
+ Contract: ${ok ? "PASS" : "FAIL"}
240
+ ${!ok && page.contract ? page.contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
241
+ `);
242
+
243
+ if (!ok) {
244
+ throw renderShieldError(
245
+ "VERIFY_FAILED",
246
+ `Built HTML failed contract for ${page.routePath}. Missing: ${page.contract?.missing.join("; ")}`,
247
+ { routePath: page.routePath, missing: page.contract?.missing }
248
+ );
249
+ }
250
+
251
+ return {
252
+ mode: "local",
253
+ canonicalBase: cfg.site.canonicalBase,
254
+ checked: true,
255
+ ok: true,
256
+ pages: [page],
257
+ };
258
+ }
259
+
260
+ async function runLocalCheckAll(
261
+ cwd: string,
262
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
263
+ outDirAbs: string,
264
+ indexFiles: string[]
265
+ ): Promise<VerifyLocalResult> {
266
+ const pages: VerifyPageResult[] = [];
267
+ const failures: string[] = [];
268
+
269
+ for (const indexPath of indexFiles) {
270
+ const page = await checkLocalPage(cwd, outDirAbs, indexPath, cfg.site.canonicalBase);
271
+ pages.push(page);
272
+ if (!page.contract?.ok) {
273
+ failures.push(
274
+ `${page.routePath}: ${page.contract?.missing.join("; ") ?? "contract failed"}`
275
+ );
276
+ }
277
+ }
278
+
279
+ console.log(`
280
+ RenderShield verify --all --check
281
+ Pages: ${pages.length}
282
+ ${pages
283
+ .map((p) => ` ${p.contract?.ok ? "PASS" : "FAIL"} ${p.routePath}`)
284
+ .join("\n")}
163
285
  `);
164
286
 
287
+ if (failures.length > 0) {
288
+ throw renderShieldError(
289
+ "VERIFY_FAILED",
290
+ `Built HTML failed contract on ${failures.length} page(s). ${failures.join(" | ")}`,
291
+ { failures }
292
+ );
293
+ }
294
+
165
295
  return {
166
296
  mode: "local",
167
297
  canonicalBase: cfg.site.canonicalBase,
298
+ checked: true,
299
+ ok: true,
300
+ pages,
301
+ };
302
+ }
303
+
304
+ async function checkLocalPage(
305
+ cwd: string,
306
+ outDirAbs: string,
307
+ indexPath: string,
308
+ canonicalBase: string
309
+ ): Promise<VerifyPageResult> {
310
+ const html = await fs.readFile(indexPath, "utf8");
311
+ const routePath = indexHtmlPathToRoute(outDirAbs, indexPath);
312
+ const outputFile = path.relative(cwd, indexPath);
313
+ const contract = checkPrerenderContract(html, {
314
+ routePath,
315
+ outFile: outputFile,
316
+ });
317
+ return {
168
318
  routePath,
169
319
  outputFile,
170
- url,
320
+ url: joinUrl(canonicalBase, routePath),
321
+ contract,
171
322
  };
172
323
  }
173
324
 
174
- async function runVerifyProd(url: string): Promise<VerifyProdResult> {
175
- const normalizedUrl = url.startsWith("http") ? url : `https://${url}`;
325
+ async function runVerifyProdAll(
326
+ cfg: Awaited<ReturnType<typeof loadConfig>>,
327
+ _cwd: string,
328
+ outDirAbs: string,
329
+ indexFiles: string[]
330
+ ): Promise<VerifyProdResult> {
331
+ const pages: VerifyProdResult["pages"] = [];
332
+ const failures: string[] = [];
333
+
334
+ for (const indexPath of indexFiles) {
335
+ const routePath = indexHtmlPathToRoute(outDirAbs, indexPath);
336
+ const prodUrl = joinUrl(cfg.site.canonicalBase, routePath);
337
+ try {
338
+ const result = await fetchAndVerifyProd(prodUrl);
339
+ pages.push(result);
340
+ } catch (err: unknown) {
341
+ const msg = err instanceof Error ? err.message : String(err);
342
+ failures.push(`${routePath}: ${msg}`);
343
+ }
344
+ }
345
+
346
+ console.log(`
347
+ RenderShield verify --prod --all
348
+ Checked ${pages.length} URL(s) from build output.
349
+ `);
350
+
351
+ if (failures.length > 0) {
352
+ throw renderShieldError(
353
+ "VERIFY_FAILED",
354
+ `Production verify failed for ${failures.length} route(s). ${failures.join(" | ")}`,
355
+ { failures }
356
+ );
357
+ }
358
+
359
+ return { mode: "prod", ok: true, pages };
360
+ }
176
361
 
362
+ async function fetchAndVerifyProd(
363
+ normalizedUrl: string
364
+ ): Promise<{ url: string; contract: ContractCheckResult }> {
177
365
  let botHtml: string;
178
366
  let humanHtml: string;
179
367
  let botStatus: number;
@@ -206,24 +394,22 @@ async function runVerifyProd(url: string): Promise<VerifyProdResult> {
206
394
  );
207
395
  }
208
396
 
209
- // Prove routing: Worker must set x-rendershield: bot-hit for bot requests. No inference.
210
- const routingOk = xRenderShield === "bot-hit";
211
397
  if (xRenderShield === "bot-fallback") {
212
398
  throw renderShieldError(
213
399
  "VERIFY_FAILED",
214
- `verify --prod: bot request received x-rendershield: bot-fallback. ` +
215
- `Prerender origin returned non-200; Worker fell back to SPA. Fix deployment or origin so bots get prerendered HTML.`,
400
+ `verify --prod: bot request received x-rendershield: bot-fallback for ${normalizedUrl}. ` +
401
+ `Prerender origin returned non-200; Worker fell back to SPA.`,
216
402
  { url: normalizedUrl, xRenderShield }
217
403
  );
218
404
  }
219
- if (!routingOk) {
405
+ if (xRenderShield !== "bot-hit") {
220
406
  const hint = xRenderShield == null
221
- ? " If no Worker is deployed, use verify without --prod to check local/build output."
407
+ ? " If no Worker is deployed, use verify --check for local/build output."
222
408
  : "";
223
409
  throw renderShieldError(
224
410
  "VERIFY_FAILED",
225
- `verify --prod: expected x-rendershield: bot-hit (proving Worker routed bot to prerender). ` +
226
- `Got: ${xRenderShield ?? "(missing)"}. Ensure the Worker is deployed and bound to this route.${hint}`,
411
+ `verify --prod: expected x-rendershield: bot-hit for ${normalizedUrl}. ` +
412
+ `Got: ${xRenderShield ?? "(missing)"}.${hint}`,
227
413
  { url: normalizedUrl, xRenderShield }
228
414
  );
229
415
  }
@@ -235,32 +421,31 @@ async function runVerifyProd(url: string): Promise<VerifyProdResult> {
235
421
 
236
422
  const humanSpa = looksLikeSpaShell(humanHtml);
237
423
 
238
- // Report
239
424
  console.log(`
240
425
  RenderShield verify --prod
241
426
  URL: ${normalizedUrl}
242
427
 
243
428
  Fetch:
244
- Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield ?? "(none)"}
429
+ Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield}
245
430
  Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
246
431
 
247
- Routing: x-rendershield: bot-hit (Worker served prerendered HTML to bot)
432
+ Routing: x-rendershield: bot-hit
248
433
 
249
- Bot contract (title, meta, canonical, OG, JSON-LD, article):
250
- ${contract.ok ? "PASS — all required fields present." : "FAIL — missing or invalid:"}
434
+ Bot contract:
435
+ ${contract.ok ? "PASS" : "FAIL"}
251
436
  ${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
252
437
 
253
438
  Human response:
254
- ${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content (not a minimal SPA shell)."}
439
+ ${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content."}
255
440
  `);
256
441
 
257
442
  if (!contract.ok) {
258
443
  throw renderShieldError(
259
444
  "VERIFY_FAILED",
260
- `Production URL did not satisfy bot contract. Missing: ${contract.missing.join("; ")}`,
445
+ `Production URL did not satisfy bot contract: ${normalizedUrl}. Missing: ${contract.missing.join("; ")}`,
261
446
  { url: normalizedUrl, missing: contract.missing }
262
447
  );
263
448
  }
264
449
 
265
- return { mode: "prod", url: normalizedUrl, contract };
450
+ return { url: normalizedUrl, contract };
266
451
  }
@@ -0,0 +1,17 @@
1
+ import path from "node:path";
2
+
3
+ export const DEFAULT_CONFIG_NAME = "rendershield.config.json";
4
+
5
+ export type CommandOptions = {
6
+ /** Relative to cwd, or absolute. Defaults to rendershield.config.json in cwd. */
7
+ configPath?: string;
8
+ };
9
+
10
+ export function resolveConfigFile(
11
+ cwd: string,
12
+ configPath = DEFAULT_CONFIG_NAME
13
+ ): string {
14
+ return path.isAbsolute(configPath)
15
+ ? configPath
16
+ : path.join(cwd, configPath);
17
+ }
@@ -58,7 +58,7 @@ export default {
58
58
  return out;
59
59
  }
60
60
 
61
- const origin = ${JSON.stringify(cfg.worker.lovableOrigin)};
61
+ const origin = ${JSON.stringify(cfg.worker.spaOrigin)};
62
62
  const finalPath = toIndexHtml(url.pathname);
63
63
  const originUrl = origin + finalPath + url.search;
64
64
 
@@ -0,0 +1,51 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+
4
+ /** All routed index.html files under outDir (excludes bare outDir/index.html). */
5
+ export async function listPrerenderIndexFiles(
6
+ outDirAbs: string
7
+ ): Promise<string[]> {
8
+ const results: string[] = [];
9
+ const stack: string[] = [outDirAbs];
10
+
11
+ while (stack.length > 0) {
12
+ const current = stack.pop() as string;
13
+
14
+ let entries: fs.Dirent[];
15
+ try {
16
+ entries = await fs.readdir(current, { withFileTypes: true });
17
+ } catch {
18
+ continue;
19
+ }
20
+
21
+ entries.sort((a, b) => a.name.localeCompare(b.name));
22
+
23
+ for (const entry of entries) {
24
+ const full = path.join(current, entry.name);
25
+
26
+ if (entry.isDirectory()) {
27
+ stack.push(full);
28
+ continue;
29
+ }
30
+
31
+ if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
32
+ const rel = path.relative(outDirAbs, full);
33
+ const parts = rel.split(path.sep).filter(Boolean);
34
+ if (parts.length >= 2) results.push(full);
35
+ }
36
+ }
37
+ }
38
+
39
+ results.sort((a, b) => a.localeCompare(b));
40
+ return results;
41
+ }
42
+
43
+ export function indexHtmlPathToRoute(
44
+ outDirAbs: string,
45
+ indexPathAbs: string
46
+ ): string {
47
+ const rel = path.relative(outDirAbs, indexPathAbs);
48
+ const noFile = rel.replace(/index\.html$/i, "");
49
+ const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
50
+ return "/" + normalized.replace(/^\/+/, "");
51
+ }