@dogsbay/format-vitepress 0.2.0-beta.131 → 0.2.0-beta.133

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.
@@ -1,13 +1,32 @@
1
+ /**
2
+ * VitePress corpus discovery over a {@link Corpus} (no Node): find the project (the folder
3
+ * holding `.vitepress`), read its config, resolve `srcDir`, list the pages, and build nav. The
4
+ * CLI runs it over a folder (`folder.ts`), the playground over a map of files.
5
+ *
6
+ * Snippet / include resolution and asset-href rewriting are handed to the translator as
7
+ * closures over the corpus, so the translator itself stays I/O-free and unit-testable.
8
+ *
9
+ * Paths are posix and relative to the corpus root. A corpus is someone else's repo: snippets,
10
+ * includes and `srcDir` stay inside the project, once links resolve (`Corpus.contains`).
11
+ */
1
12
  import type { NavItem } from "@dogsbay/types";
13
+ import type { Corpus } from "@dogsbay/corpus";
2
14
  import { type VitePressConfig } from "../config.js";
3
15
  import { linkToSlug } from "../nav.js";
4
16
  import type { TranslateOptions } from "../translate.js";
5
17
  export interface VitepressPageSource {
6
- filePath: string;
18
+ /** The page's file, relative to the corpus root. */
19
+ path: string;
7
20
  slug: string;
8
21
  }
22
+ /** An asset directory to mirror, relative to the corpus root (`ImportResult.assetMounts`). */
23
+ export interface VitepressAssetDir {
24
+ dir: string;
25
+ into?: string;
26
+ mediaOnly?: boolean;
27
+ }
9
28
  export interface VitepressLoadResult {
10
- /** Project root — the directory that CONTAINS `.vitepress`. */
29
+ /** Project root — the folder that CONTAINS `.vitepress` ("" for the corpus root). */
11
30
  root: string;
12
31
  /** Content root — `srcDir`, which page slugs are relative to. */
13
32
  srcDir: string;
@@ -17,29 +36,33 @@ export interface VitepressLoadResult {
17
36
  /** How nav was obtained, for the import summary. */
18
37
  navSource: "sidebar" | "directory";
19
38
  /** Directories to mirror as assets. */
20
- assetDirs: {
21
- dir: string;
22
- into?: string;
23
- mediaOnly?: boolean;
24
- }[];
39
+ assetDirs: VitepressAssetDir[];
25
40
  warnings: string[];
26
41
  }
27
42
  export interface VitepressLoadOptions {
28
43
  hrefPrefix?: string;
29
- /** Execute the config instead of reading it statically (see config.ts). */
30
- evalConfig?: boolean;
44
+ /**
45
+ * The config, already read: the CLI's `--eval-config` imports it (Node). Otherwise it is
46
+ * read statically from the corpus.
47
+ */
48
+ config?: VitePressConfig;
49
+ /** Notes from reading the config (a failed `--eval-config`), kept with the loader's. */
50
+ warnings?: string[];
31
51
  }
32
- /**
33
- * `.vitepress/config.*` is the only unambiguous marker. Requiring it is what
34
- * lets this plugin sit safely in the registry among the other Markdown
35
- * importers: it cannot claim a plain-Markdown, MkDocs or GitBook corpus.
36
- */
37
- export declare function findVitepressDir(source: string): string | undefined;
38
- export declare function detectVitepress(source: string): boolean;
39
52
  /** `guide/install.md` → `guide/install`; `guide/index.md` → `guide/index`. */
40
53
  export declare function pathToSlug(relPath: string): string;
41
- /** Minimal glob → RegExp for `srcExclude` (`**\/*.md`, `todo.md`, `a/*.md`). */
42
- export declare function globToRegExp(glob: string): RegExp;
54
+ /**
55
+ * Minimal glob matcher for `srcExclude` (`**\/*.md`, `todo.md`, `a/*.md`, `{a,b}`): `*` and
56
+ * `?` stay within a path segment, `**\/` is zero or more folders, `**` anything.
57
+ *
58
+ * Not a RegExp: the globs come from someone else's config, and a backtracking regex of
59
+ * `*a*a*a…b` or `**\/**\/…` took over a minute on one path. Matching is memoized over
60
+ * (glob position, path position), so it is polynomial in both. Returns `{ test }` so a
61
+ * caller can use it where it used a RegExp.
62
+ */
63
+ export declare function globToRegExp(glob: string): {
64
+ test(path: string): boolean;
65
+ };
43
66
  /**
44
67
  * Extract a `#region` from a file.
45
68
  *
@@ -56,7 +79,7 @@ export declare function extractRegion(content: string, region: string): string |
56
79
  */
57
80
  export declare function dedent(text: string): string;
58
81
  /** Per-page translate options (snippet resolution + asset rewriting). */
59
- export declare function translateOptionsFor(page: VitepressPageSource, srcDir: string, root: string, warnings: string[]): TranslateOptions;
82
+ export declare function translateOptionsFor(corpus: Corpus, page: VitepressPageSource, srcDir: string, root: string): TranslateOptions;
60
83
  /**
61
84
  * Translate options anchored at `dir`.
62
85
  *
@@ -66,8 +89,13 @@ export declare function translateOptionsFor(page: VitepressPageSource, srcDir: s
66
89
  * same-named sibling existed next to the page — silently, with no ledger
67
90
  * entry — and dropped the include entirely when it did not.
68
91
  */
69
- export declare function optionsForDir(dir: string, srcDir: string, root: string, warnings: string[]): TranslateOptions;
70
- export declare function loadVitepress(source: string, opts?: VitepressLoadOptions): Promise<VitepressLoadResult>;
92
+ export declare function optionsForDir(corpus: Corpus, listed: Set<string>, dir: string, srcDir: string, root: string): TranslateOptions;
93
+ /** The VitePress project in `corpus`: its root and config file, or undefined. */
94
+ export declare function findVitepressProject(corpus: Corpus): {
95
+ root: string;
96
+ configPath: string;
97
+ } | undefined;
98
+ export declare function loadVitepressCorpus(corpus: Corpus, opts?: VitepressLoadOptions): VitepressLoadResult;
71
99
  /** Used only by the fallback nav, where no page has been parsed yet. */
72
100
  export declare function titleFromSlug(slug: string): string;
73
101
  export { linkToSlug };
@@ -1,77 +1,13 @@
1
- /**
2
- * VitePress corpus discovery: locate `.vitepress`, read the config, resolve
3
- * `srcDir`, enumerate pages, and build nav.
4
- *
5
- * The only module in this package that touches the filesystem. Snippet /
6
- * include resolution and asset-href rewriting are handed to the translator as
7
- * closures so the translator itself stays I/O-free and unit-testable.
8
- */
9
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
10
- import { dirname, join, relative, resolve, sep } from "node:path";
11
- import { findConfigFile, readConfig, evalConfig } from "../config.js";
1
+ import { dirOf, isPlainPath, joinPath, normalizePath, vitepressConfigPath, vitepressPage, vitepressRoots, within } from "@dogsbay/detect";
2
+ import { parseConfig } from "../config.js";
12
3
  import { buildNav, navFromSlugs, linkToSlug } from "../nav.js";
13
- const SKIP_DIRS = new Set([
14
- ".vitepress",
15
- "node_modules",
16
- "public",
17
- "dist",
18
- ".git",
19
- "cache",
20
- ".cache",
21
- ]);
22
- // ── Detection ─────────────────────────────────────────────────────────────
23
- /**
24
- * `.vitepress/config.*` is the only unambiguous marker. Requiring it is what
25
- * lets this plugin sit safely in the registry among the other Markdown
26
- * importers: it cannot claim a plain-Markdown, MkDocs or GitBook corpus.
27
- */
28
- export function findVitepressDir(source) {
29
- const candidates = [source, join(source, "docs"), join(source, "site"), join(source, "website")];
30
- for (const dir of candidates) {
31
- const vp = join(dir, ".vitepress");
32
- if (existsSync(vp) && findConfigFile(vp))
33
- return vp;
34
- }
35
- // One level down, for monorepos whose docs live in an unconventional dir.
36
- let entries = [];
37
- try {
38
- entries = readdirSync(source);
39
- }
40
- catch {
41
- return undefined;
42
- }
43
- for (const name of entries) {
44
- if (name.startsWith(".") || name === "node_modules")
45
- continue;
46
- const vp = join(source, name, ".vitepress");
47
- try {
48
- if (statSync(vp).isDirectory() && findConfigFile(vp))
49
- return vp;
50
- }
51
- catch {
52
- /* not a directory */
53
- }
54
- }
55
- return undefined;
56
- }
57
- export function detectVitepress(source) {
58
- try {
59
- if (!existsSync(source) || !statSync(source).isDirectory())
60
- return false;
61
- }
62
- catch {
63
- return false;
64
- }
65
- return findVitepressDir(source) !== undefined;
66
- }
67
4
  // ── Slugs ─────────────────────────────────────────────────────────────────
68
5
  /** `guide/install.md` → `guide/install`; `guide/index.md` → `guide/index`. */
69
6
  export function pathToSlug(relPath) {
70
7
  return relPath.replace(/\\/g, "/").replace(/\.md$/i, "");
71
8
  }
72
- /** Minimal glob → RegExp for `srcExclude` (`**\/*.md`, `todo.md`, `a/*.md`). */
73
- export function globToRegExp(glob) {
74
- let out = "";
9
+ function globTokens(glob) {
10
+ const out = [];
75
11
  for (let i = 0; i < glob.length; i++) {
76
12
  const c = glob[i];
77
13
  if (c === "*") {
@@ -79,84 +15,96 @@ export function globToRegExp(glob) {
79
15
  // `**` followed by a separator matches zero or more leading
80
16
  // directories; a bare `**` matches anything, separators included.
81
17
  if (glob[i + 2] === "/") {
82
- out += "(?:.*/)?";
18
+ // A run of `**/**/` matches what one does
19
+ if (out[out.length - 1]?.t !== "**/")
20
+ out.push({ t: "**/" });
83
21
  i += 2;
84
22
  }
85
23
  else {
86
- out += ".*";
24
+ out.push({ t: "**" });
87
25
  i += 1;
88
26
  }
89
27
  continue;
90
28
  }
91
- out += "[^/]*";
29
+ if (out[out.length - 1]?.t !== "*")
30
+ out.push({ t: "*" });
92
31
  continue;
93
32
  }
94
33
  if (c === "?") {
95
- out += "[^/]";
34
+ out.push({ t: "?" });
96
35
  continue;
97
36
  }
98
37
  // Brace alternation — picomatch (what VitePress uses) supports it.
99
- // Escaping it to a literal made `**/*.{draft,tmp}.md` match nothing, so
38
+ // Treating it as literal made `**/*.{draft,tmp}.md` match nothing, so
100
39
  // an author's srcExclude silently failed to exclude anything.
101
40
  if (c === "{") {
102
41
  const close = glob.indexOf("}", i);
103
42
  if (close > i) {
104
- const alts = glob
105
- .slice(i + 1, close)
106
- .split(",")
107
- .map((a) => a.replace(/[.+^${}()|[\]\\*?]/g, "\\$&"));
108
- out += `(?:${alts.join("|")})`;
43
+ out.push({ t: "alt", opts: glob.slice(i + 1, close).split(",") });
109
44
  i = close;
110
45
  continue;
111
46
  }
112
47
  }
113
- out += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
48
+ out.push({ t: "lit", c });
114
49
  }
115
- return new RegExp(`^${out}$`);
50
+ return out;
116
51
  }
117
- // ── Page walk ─────────────────────────────────────────────────────────────
118
- function walkMarkdown(dir, srcDir, out, seen = new Set()) {
119
- // Guard on the REAL path. A directory symlink pointing back up the tree
120
- // (`docs/guide/loop -> docs`) is not infinite — the OS stops it with ELOOP —
121
- // but it multiplies the corpus: a one-page tree walked as 41 pages, each a
122
- // duplicate import of the same file under a 447-character slug.
123
- let real;
124
- try {
125
- real = realpathSync(dir);
126
- }
127
- catch {
128
- return;
129
- }
130
- if (seen.has(real))
131
- return;
132
- seen.add(real);
133
- let entries;
134
- try {
135
- entries = readdirSync(dir);
136
- }
137
- catch {
138
- return;
139
- }
140
- for (const name of entries) {
141
- const full = join(dir, name);
142
- let st;
143
- try {
144
- st = statSync(full);
145
- }
146
- catch {
147
- continue;
148
- }
149
- if (st.isDirectory()) {
150
- if (SKIP_DIRS.has(name) || name.startsWith("."))
151
- continue;
152
- walkMarkdown(full, srcDir, out, seen);
153
- continue;
154
- }
155
- if (!name.toLowerCase().endsWith(".md"))
156
- continue;
157
- const rel = relative(srcDir, full).replace(/\\/g, "/");
158
- out.push({ filePath: full, slug: pathToSlug(rel) });
159
- }
52
+ /**
53
+ * Minimal glob matcher for `srcExclude` (`**\/*.md`, `todo.md`, `a/*.md`, `{a,b}`): `*` and
54
+ * `?` stay within a path segment, `**\/` is zero or more folders, `**` anything.
55
+ *
56
+ * Not a RegExp: the globs come from someone else's config, and a backtracking regex of
57
+ * `*a*a*a…b` or `**\/**\/…` took over a minute on one path. Matching is memoized over
58
+ * (glob position, path position), so it is polynomial in both. Returns `{ test }` so a
59
+ * caller can use it where it used a RegExp.
60
+ */
61
+ export function globToRegExp(glob) {
62
+ const toks = globTokens(glob);
63
+ return {
64
+ test(path) {
65
+ const n = path.length;
66
+ const memo = new Uint8Array((toks.length + 1) * (n + 1)); // 0 unknown, 1 no, 2 yes
67
+ const match = (g, i) => {
68
+ if (g === toks.length)
69
+ return i === n;
70
+ const slot = g * (n + 1) + i;
71
+ if (memo[slot])
72
+ return memo[slot] === 2;
73
+ const tok = toks[g];
74
+ let ok = false;
75
+ switch (tok.t) {
76
+ case "lit":
77
+ ok = path[i] === tok.c && match(g + 1, i + 1);
78
+ break;
79
+ case "?":
80
+ ok = i < n && path[i] !== "/" && match(g + 1, i + 1);
81
+ break;
82
+ case "*":
83
+ ok = match(g + 1, i);
84
+ for (let j = i; !ok && j < n && path[j] !== "/"; j++)
85
+ ok = match(g + 1, j + 1);
86
+ break;
87
+ case "**":
88
+ ok = match(g + 1, i);
89
+ for (let j = i; !ok && j < n; j++)
90
+ ok = match(g + 1, j + 1);
91
+ break;
92
+ case "**/":
93
+ ok = match(g + 1, i);
94
+ for (let j = i; !ok && j < n; j++)
95
+ if (path[j] === "/")
96
+ ok = match(g + 1, j + 1);
97
+ break;
98
+ case "alt":
99
+ ok = tok.opts.some((o) => path.startsWith(o, i) && match(g + 1, i + o.length));
100
+ break;
101
+ }
102
+ memo[slot] = ok ? 2 : 1;
103
+ return ok;
104
+ };
105
+ return match(0, 0);
106
+ },
107
+ };
160
108
  }
161
109
  // ── Snippet / include resolution ──────────────────────────────────────────
162
110
  /**
@@ -221,76 +169,42 @@ export function dedent(text) {
221
169
  return text;
222
170
  return lines.map((l) => l.slice(min)).join("\n");
223
171
  }
224
- /**
225
- * Is `target` genuinely inside `root`?
226
- *
227
- * `relative()` alone is a STRING computation — it does not follow symlinks,
228
- * but `readFileSync` does. A single symlink under the docs tree
229
- * (`docs/guide/link -> /tmp`) turned a `<<<` reference into arbitrary file
230
- * read driven by document content. Both ends are resolved to their real paths
231
- * first, and the boundary is checked on a path SEGMENT so that a sibling
232
- * directory sharing a prefix (`/a/docs` vs `/a/docs-secret`) cannot pass.
233
- */
234
- function isInside(root, target) {
235
- let realRoot;
236
- let realTarget;
237
- try {
238
- realRoot = realpathSync(root);
239
- }
240
- catch {
241
- realRoot = root;
242
- }
243
- try {
244
- realTarget = realpathSync(target);
245
- }
246
- catch {
247
- // Not created yet / broken link — fall back to the lexical path, which is
248
- // still checked below.
249
- realTarget = target;
250
- }
251
- if (realTarget === realRoot)
252
- return true;
253
- const rel = relative(realRoot, realTarget);
254
- if (!rel)
255
- return true;
256
- return rel !== ".." && !rel.startsWith(`..${sep}`);
172
+ /** Is `path` inside the project `root`, once links resolve? */
173
+ function inProject(corpus, listed, path, root) {
174
+ if (path === undefined || !within(path, root))
175
+ return false;
176
+ // A listed file is checked through its real path: a symlink under the docs tree
177
+ // (`docs/guide/link -> /tmp`) must not turn a `<<<` reference into a read outside it.
178
+ return !corpus.contains || !listed.has(path) || corpus.contains(path, root);
179
+ }
180
+ /** `@/x` and `/x` are relative to `srcDir` (VitePress's alias); anything else to `dir`. */
181
+ function snippetPath(spec, dir, srcDir) {
182
+ const target = spec.startsWith("@/")
183
+ ? joinPath(srcDir, spec.slice(2))
184
+ : spec.startsWith("/")
185
+ ? joinPath(srcDir, spec.slice(1))
186
+ : joinPath(dir, spec);
187
+ return normalizePath(target);
257
188
  }
258
189
  /**
259
190
  * Build the `readSnippet` closure for one page.
260
191
  *
261
- * `@/` is VitePress's alias for `srcDir`; everything else resolves relative to
262
- * the page's own directory. A path escaping `root` is refused — a migration
263
- * should not read files outside the project it was pointed at.
192
+ * A path escaping `root` is refused — a migration should not read files outside the project
193
+ * it was pointed at.
264
194
  */
265
- function makeReadSnippet(pageDir, srcDir, root, warnings) {
195
+ function makeReadSnippet(corpus, listed, pageDir, srcDir, root) {
266
196
  return (req) => {
267
- const raw = req.path;
268
- const abs = raw.startsWith("@/")
269
- ? resolve(srcDir, raw.slice(2))
270
- : raw.startsWith("/")
271
- ? resolve(srcDir, raw.slice(1))
272
- : resolve(pageDir, raw);
273
- if (!isInside(root, abs)) {
274
- warnings.push(`refused to read "${raw}" — outside the project root`);
275
- return undefined;
276
- }
277
- if (!existsSync(abs))
278
- return undefined;
279
- let content;
280
- try {
281
- content = readFileSync(abs, "utf-8");
282
- }
283
- catch {
197
+ const path = snippetPath(req.path, pageDir, srcDir);
198
+ if (!inProject(corpus, listed, path, root))
199
+ return { missing: "refused to read: outside the project root" };
200
+ const content = corpus.text(path);
201
+ if (content === undefined)
284
202
  return undefined;
285
- }
286
- const rel = relative(root, abs);
287
203
  if (!req.region)
288
204
  return content;
289
205
  const extracted = extractRegion(content, req.region);
290
- if (extracted === undefined) {
291
- warnings.push(`region "#${req.region}" not found in ${rel}`);
292
- return undefined;
293
- }
206
+ if (extracted === undefined)
207
+ return { missing: `region "#${req.region}" not found in ${root ? path.slice(root.length + 1) : path}` };
294
208
  return extracted;
295
209
  };
296
210
  }
@@ -309,16 +223,15 @@ function makeAssetHref(pageDir, srcDir) {
309
223
  return src;
310
224
  if (src.startsWith("/"))
311
225
  return `/_assets${src}`;
312
- const abs = resolve(pageDir, src);
313
- const rel = relative(srcDir, abs).replace(/\\/g, "/");
314
- if (rel.startsWith(".."))
226
+ const path = normalizePath(joinPath(pageDir, src));
227
+ if (path === undefined || !within(path, srcDir))
315
228
  return src;
316
- return `/_assets/pages/${rel}`;
229
+ return `/_assets/pages/${srcDir ? path.slice(srcDir.length + 1) : path}`;
317
230
  };
318
231
  }
319
232
  /** Per-page translate options (snippet resolution + asset rewriting). */
320
- export function translateOptionsFor(page, srcDir, root, warnings) {
321
- return optionsForDir(dirname(page.filePath), srcDir, root, warnings);
233
+ export function translateOptionsFor(corpus, page, srcDir, root) {
234
+ return optionsForDir(corpus, new Set(corpus.paths), dirOf(page.path), srcDir, root);
322
235
  }
323
236
  /**
324
237
  * Translate options anchored at `dir`.
@@ -329,57 +242,59 @@ export function translateOptionsFor(page, srcDir, root, warnings) {
329
242
  * same-named sibling existed next to the page — silently, with no ledger
330
243
  * entry — and dropped the include entirely when it did not.
331
244
  */
332
- export function optionsForDir(dir, srcDir, root, warnings) {
245
+ export function optionsForDir(corpus, listed, dir, srcDir, root) {
333
246
  return {
334
- readSnippet: makeReadSnippet(dir, srcDir, root, warnings),
247
+ readSnippet: makeReadSnippet(corpus, listed, dir, srcDir, root),
335
248
  assetHref: makeAssetHref(dir, srcDir),
336
249
  resolveDir: (spec) => {
337
- const abs = spec.startsWith("@/")
338
- ? resolve(srcDir, spec.slice(2))
339
- : spec.startsWith("/")
340
- ? resolve(srcDir, spec.slice(1))
341
- : resolve(dir, spec);
342
- return isInside(root, abs) ? dirname(abs) : undefined;
250
+ const path = snippetPath(spec, dir, srcDir);
251
+ return inProject(corpus, listed, path, root) ? dirOf(path) : undefined;
343
252
  },
344
- optionsForDir: (nextDir) => optionsForDir(nextDir, srcDir, root, warnings),
253
+ optionsForDir: (nextDir) => optionsForDir(corpus, listed, nextDir, srcDir, root),
345
254
  };
346
255
  }
347
256
  // ── Load ──────────────────────────────────────────────────────────────────
348
- export async function loadVitepress(source, opts = {}) {
349
- const hrefPrefix = opts.hrefPrefix ?? "";
350
- const warnings = [];
351
- const vitepressDir = findVitepressDir(source);
352
- if (!vitepressDir) {
353
- throw new Error(`No .vitepress/config.* found under ${source}`);
354
- }
355
- const root = dirname(vitepressDir);
356
- const configPath = findConfigFile(vitepressDir);
357
- if (!configPath)
358
- throw new Error(`No config file in ${vitepressDir}`);
359
- let config;
360
- if (opts.evalConfig) {
361
- try {
362
- config = await evalConfig(configPath);
363
- }
364
- catch (err) {
365
- const msg = err instanceof Error ? err.message : String(err);
366
- warnings.push(`--eval-config failed (${msg}); falling back to static reading`);
367
- config = readConfig(configPath);
368
- }
369
- }
370
- else {
371
- config = readConfig(configPath);
257
+ /** The VitePress project in `corpus`: its root and config file, or undefined. */
258
+ export function findVitepressProject(corpus) {
259
+ for (const root of vitepressRoots(corpus.paths)) {
260
+ const configPath = vitepressConfigPath(corpus.paths, root);
261
+ if (configPath)
262
+ return { root, configPath };
372
263
  }
264
+ return undefined;
265
+ }
266
+ export function loadVitepressCorpus(corpus, opts = {}) {
267
+ const hrefPrefix = opts.hrefPrefix ?? "";
268
+ const warnings = [...(opts.warnings ?? [])];
269
+ const project = findVitepressProject(corpus);
270
+ if (!project)
271
+ throw new Error("No .vitepress/config.* found");
272
+ const { root } = project;
273
+ const config = opts.config ?? parseConfig(corpus.text(project.configPath) ?? "", project.configPath);
373
274
  for (const u of config.unresolved) {
374
275
  warnings.push(`config: could not read ${u}`);
375
276
  }
376
- const srcDir = config.srcDir ? resolve(root, config.srcDir) : root;
377
- if (!existsSync(srcDir)) {
277
+ // `srcDir` comes from the repo being imported: it may not lead out of the project.
278
+ const srcDir = config.srcDir ? normalizePath(joinPath(root, config.srcDir)) : root;
279
+ if (srcDir === undefined || !within(srcDir, root)) {
280
+ throw new Error(`srcDir "${config.srcDir}" is outside the project`);
281
+ }
282
+ const listed = new Set(corpus.paths);
283
+ if (srcDir !== root && !corpus.paths.some((p) => within(p, srcDir))) {
378
284
  throw new Error(`srcDir "${config.srcDir}" does not exist (resolved to ${srcDir})`);
379
285
  }
380
- // Pages.
286
+ // Pages: the visible .md files under srcDir that stay in the project once links resolve.
381
287
  const found = [];
382
- walkMarkdown(srcDir, srcDir, found);
288
+ for (const path of corpus.paths) {
289
+ if (!within(path, srcDir) || path === srcDir)
290
+ continue;
291
+ const rel = srcDir ? path.slice(srcDir.length + 1) : path;
292
+ if (!vitepressPage(rel))
293
+ continue;
294
+ if (!inProject(corpus, listed, path, root))
295
+ continue;
296
+ found.push({ path, slug: pathToSlug(rel) });
297
+ }
383
298
  const excludes = (config.srcExclude ?? []).map(globToRegExp);
384
299
  const rewrites = config.rewrites ?? {};
385
300
  // VitePress supports dynamic route params in a rewrite key
@@ -391,23 +306,32 @@ export async function loadVitepress(source, opts = {}) {
391
306
  warnings.push(`rewrite rule "${key}" uses dynamic parameters, which are not expanded — pages under it keep their source paths`);
392
307
  }
393
308
  }
309
+ const rel = (path) => (srcDir ? path.slice(srcDir.length + 1) : path);
394
310
  const pages = [];
395
311
  const bySlug = new Map();
396
312
  for (const p of found) {
397
313
  const relMd = `${p.slug}.md`;
398
314
  if (excludes.some((re) => re.test(relMd) || re.test(p.slug)))
399
315
  continue;
400
- // `rewrites` remaps a SOURCE path to the served path.
401
- const rewritten = rewrites[relMd] ?? rewrites[p.slug];
402
- const page = rewritten ? { ...p, slug: pathToSlug(rewritten) } : p;
316
+ // `rewrites` remaps a SOURCE path to the served path. It comes from the repo too: a
317
+ // rewrite that leads out of the content root is not a slug.
318
+ // Own string keys only: a page named `constructor.md` read Object.prototype's.
319
+ const own = (k) => (Object.hasOwn(rewrites, k) && typeof rewrites[k] === "string" ? rewrites[k] : undefined);
320
+ const rewritten = own(relMd) ?? own(p.slug);
321
+ const normalized = rewritten === undefined ? undefined : normalizePath(rewritten);
322
+ const target = normalized && isPlainPath(normalized) ? normalized : undefined;
323
+ if (rewritten !== undefined && !target) {
324
+ warnings.push(`rewrite of ${relMd} to "${rewritten}" leads out of the site or is not a plain path — kept its source path`);
325
+ }
326
+ const page = target ? { ...p, slug: pathToSlug(target) } : p;
403
327
  // Two files landing on one slug means a page is DROPPED by the exporter —
404
328
  // the run still says "Imported N pages" and one of them silently isn't
405
329
  // there. Name both sources; a migration that loses a page must say so.
406
330
  const prior = bySlug.get(page.slug);
407
331
  if (prior) {
408
- warnings.push(`slug collision "${page.slug}": ${relative(srcDir, prior)} and ${relative(srcDir, page.filePath)} — only one survives`);
332
+ warnings.push(`slug collision "${page.slug}": ${rel(prior)} and ${rel(page.path)} — only one survives`);
409
333
  }
410
- bySlug.set(page.slug, page.filePath);
334
+ bySlug.set(page.slug, page.path);
411
335
  pages.push(page);
412
336
  }
413
337
  pages.sort((a, b) => a.slug.localeCompare(b.slug));
@@ -426,11 +350,12 @@ export async function loadVitepress(source, opts = {}) {
426
350
  navSource = "directory";
427
351
  }
428
352
  const assetDirs = [];
429
- const publicDir = join(root, "public");
430
- if (existsSync(publicDir))
353
+ const hasDir = (dir) => corpus.paths.some((p) => within(p, dir) && p !== dir);
354
+ const publicDir = joinPath(root, "public");
355
+ if (hasDir(publicDir))
431
356
  assetDirs.push({ dir: publicDir });
432
- const vpPublic = join(vitepressDir, "public");
433
- if (existsSync(vpPublic))
357
+ const vpPublic = joinPath(root, ".vitepress/public");
358
+ if (hasDir(vpPublic))
434
359
  assetDirs.push({ dir: vpPublic });
435
360
  assetDirs.push({ dir: srcDir, into: "pages", mediaOnly: true });
436
361
  return { root, srcDir, config, pages, nav, navSource, assetDirs, warnings };