@grimoire-rs/indexer 0.4.4 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +35 -4
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/config.d.ts +107 -7
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +181 -36
  10. package/dist/config.js.map +1 -1
  11. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  12. package/dist/renderer/astro/components/CardLogo.js +58 -0
  13. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  14. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  15. package/dist/renderer/astro/components/Catalog.js +467 -108
  16. package/dist/renderer/astro/components/Catalog.tsx +756 -349
  17. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  18. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  19. package/dist/renderer/astro/components/CopyButton.js +28 -0
  20. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  21. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  22. package/dist/renderer/astro/components/KindMark.js +66 -0
  23. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  24. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  25. package/dist/renderer/astro/components/PackageCard.js +50 -0
  26. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  27. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  28. package/dist/renderer/astro/components/PackageRow.js +32 -0
  29. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  30. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  31. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  32. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  33. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  34. package/dist/renderer/astro/layouts/Base.astro +860 -206
  35. package/dist/renderer/astro/lib/base.d.ts +25 -0
  36. package/dist/renderer/astro/lib/base.js +23 -0
  37. package/dist/renderer/astro/lib/base.ts +27 -0
  38. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  39. package/dist/renderer/astro/lib/catalog.js +36 -0
  40. package/dist/renderer/astro/lib/catalog.ts +37 -0
  41. package/dist/renderer/astro/lib/commands.d.ts +58 -0
  42. package/dist/renderer/astro/lib/commands.js +86 -0
  43. package/dist/renderer/astro/lib/commands.ts +117 -0
  44. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  45. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  46. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  47. package/dist/renderer/astro/pages/index.astro +40 -87
  48. package/dist/renderer/astro/pages/p/[...slug].astro +340 -195
  49. package/dist/renderer/astro/styles/tokens.css +40 -5
  50. package/dist/renderer/index.d.ts +58 -0
  51. package/dist/renderer/index.d.ts.map +1 -1
  52. package/dist/renderer/index.js +547 -5
  53. package/dist/renderer/index.js.map +1 -1
  54. package/dist/renderer/types.d.ts +9 -0
  55. package/dist/renderer/types.d.ts.map +1 -1
  56. package/package.json +9 -4
  57. package/templates/README.md +6 -0
  58. package/templates/gitignore +4 -1
  59. package/templates/theme/README.md +38 -0
  60. package/templates/tsconfig.json +47 -0
@@ -4,6 +4,12 @@
4
4
  // (`./astro`, `../public`); the *data* comes from `<outDir>/all.json`,
5
5
  // which `src/data/` compiles — nothing here walks an index tree.
6
6
  import fs from "node:fs/promises";
7
+ // The callback `watch` rather than the promises one: this needs a handle
8
+ // `stop()` can close, and `fsPromises.watch` is an async iterator instead.
9
+ // `mkdirSync` for the same reason, one caller down — see `ensureThemeDir`.
10
+ // `readFileSync` too, for `indexerVersion` below: the manifest is read at
11
+ // call time rather than imported, and nothing here is on a hot path.
12
+ import { mkdirSync, readFileSync, watch } from "node:fs";
7
13
  import path from "node:path";
8
14
  import { fileURLToPath } from "node:url";
9
15
  import { build, dev } from "astro";
@@ -50,6 +56,223 @@ const here = path.dirname(fileURLToPath(import.meta.url));
50
56
  export const ASTRO_SRC_DIR = path.join(here, "astro");
51
57
  /** Default `public/` layer; holds the fallback favicon. */
52
58
  export const DEFAULT_PUBLIC_DIR = path.join(here, "public");
59
+ /**
60
+ * This package's own version, named in every C-004 overlay log line so a
61
+ * theme author can tell which release the file they replaced came from.
62
+ *
63
+ * Read from disk rather than imported, so `rootDir: src` stays intact.
64
+ * `../../package.json` reaches the package root from `src/renderer/` and
65
+ * `dist/renderer/` alike — both sit exactly two directories under it, the
66
+ * same hop `packageVersion()` makes in `src/cli/main.ts`. Deliberately a
67
+ * second copy of those four lines rather than an import: `main.ts` is the
68
+ * commander entry point and it already imports this module, so reaching the
69
+ * other way would close a cycle and drag the CLI into every render.
70
+ */
71
+ export function indexerVersion() {
72
+ const manifest = fileURLToPath(new URL("../../package.json", import.meta.url));
73
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
74
+ const version = parsed.version;
75
+ return typeof version === "string" ? version : "0.0.0";
76
+ }
77
+ /**
78
+ * Directory in the index repo whose contents are laid over the renderer's
79
+ * own Astro sources — the structural half of the theming contract, where
80
+ * `customCss` is the CSS half.
81
+ *
82
+ * A file here either adds one (`theme/pages/setup.astro` becomes `/setup/`)
83
+ * or replaces the shipped file of the same path. What that costs is stated
84
+ * in the docs, not enforced here: page routes and the `@grim/*` specifier
85
+ * are contract, component paths and props are not yet.
86
+ */
87
+ export const THEME_DIR = "theme";
88
+ /**
89
+ * Theme-relative paths `overlayTheme` refuses to copy, no matter what an
90
+ * index repo puts under `theme/`.
91
+ *
92
+ * `lib` is the renderer's own helper tree (`astro/lib/*`, reached from a
93
+ * page or component through `@grim/lib/...`) — letting a theme silently
94
+ * replace one of those changes behaviour for every importer, not just the
95
+ * overridden path, with no doc describing it. `content.config.ts` is
96
+ * Astro's single content-collection entrypoint; a themed one would have to
97
+ * reproduce the `./enrich` glob loader `stage` already wires up, and one
98
+ * that gets it wrong breaks every collection at once with no traceable
99
+ * cause. This list is decided once, here, before the first consumer exists
100
+ * — narrowing it later breaks whoever already overrode a path that becomes
101
+ * newly excluded, so it is not loosened or tightened per bug report.
102
+ *
103
+ * Every entry means one thing: *this root-relative path, and everything
104
+ * under it*. One rule, so a third entry works the day it is added and
105
+ * cannot silently do nothing — which is exactly what a list read
106
+ * positionally (`[0]` a directory, `[1]` a basename) would have done. It
107
+ * also settles `content.config.ts` at the **root only**: the deny exists
108
+ * because Astro resolves exactly one content-collection entrypoint there,
109
+ * and `components/content.config.ts` carries no such meaning.
110
+ *
111
+ * Entries are lowercase because the comparison is. APFS and NTFS are
112
+ * case-insensitive, so `theme/LIB/data.ts` names the same file to the
113
+ * filesystem that serves it, and CI is `ubuntu-latest` only — it can never
114
+ * catch a case bypass, which is why the check has to be right rather than
115
+ * tested into shape.
116
+ *
117
+ * Not a security boundary, and no doc may call it one: `fs.cp` does not
118
+ * dereference symlinks, so `theme/lib -> ../../..` is copied through as a
119
+ * live symlink whatever this list says. It is a compatibility guard.
120
+ */
121
+ export const OVERLAY_DENY = ["lib", "content.config.ts"];
122
+ /**
123
+ * The `OVERLAY_DENY` entry a theme-relative path falls under — a
124
+ * `path.relative(theme, …)` result, so no leading `theme/` and no `./` — or
125
+ * `null` when the overlay accepts it.
126
+ *
127
+ * It returns the *entry* rather than a boolean because that entry is the
128
+ * whole reason: `lib/a.ts` and `lib/b.ts` are refused by the same rule, and a
129
+ * reporter that names each of them says one thing twice.
130
+ */
131
+ function deniedEntry(relPath) {
132
+ const rel = relPath.split(path.sep).join("/").toLowerCase();
133
+ return OVERLAY_DENY.find((deny) => rel === deny || rel.startsWith(`${deny}/`)) ?? null;
134
+ }
135
+ /** Whether a theme-relative path is one `OVERLAY_DENY` refuses. */
136
+ function isOverlayDenied(relPath) {
137
+ return deniedEntry(relPath) !== null;
138
+ }
139
+ /**
140
+ * `fs.cp`'s `filter` for both writers that copy `theme/`: the build's bulk
141
+ * overlay and the dev server's per-event mirror.
142
+ *
143
+ * It has to be the `filter` and not a test on the watcher's event path.
144
+ * `fs.watch` adds a per-directory watch only once a subtree already exists,
145
+ * so `mv /tmp/prepared theme/` fires one event naming `theme/` and none for
146
+ * anything inside it — and the handler's recursive copy then carries the
147
+ * whole moved-in subtree past any per-event check. `filter` is consulted per
148
+ * entry and skips a denied directory's children along with it, so it holds
149
+ * however the copy was triggered, and it keeps holding if the list is ever
150
+ * inverted into an allow-list.
151
+ *
152
+ * One predicate for both writers on purpose: a deny-list only one of them
153
+ * honours is worse than none. The author develops against `dev`, watches
154
+ * the denied file take effect, ships it, and the build drops it in silence.
155
+ *
156
+ * `onDenied` is C-001's one stderr line per skipped path, and it is reported
157
+ * from in here precisely so it is one line per entry *as the copy sees it*:
158
+ * a denied `lib/` holding two files is refused once, at the directory, and
159
+ * its children are never visited — so naming a file inside one would be
160
+ * inventing a path the filter never judged.
161
+ *
162
+ * Both writers pass a reporter, for the reason two paragraphs up: silence in
163
+ * `dev` is that same defect read backwards. The author saves a denied file,
164
+ * sees the site not change, and is told nothing until a build much later. The
165
+ * dev mirror's filter runs per watch event, so it reports once per deny entry
166
+ * per session rather than on every keystroke — see `watchTheme`.
167
+ */
168
+ function overlayFilter(theme, onDenied) {
169
+ return (from) => {
170
+ // Reported as spelled on disk — the lowercasing lives in the comparison,
171
+ // not in the message, so `theme/LIB` reads back as the author wrote it.
172
+ const rel = path.relative(theme, from).split(path.sep).join("/");
173
+ if (!isOverlayDenied(rel))
174
+ return true;
175
+ onDenied?.(rel);
176
+ return false;
177
+ };
178
+ }
179
+ /**
180
+ * Lay `<root>/theme` over the staged sources. Absent is the normal case.
181
+ *
182
+ * `force` is half the mechanism: an overlaid file that names a shipped path
183
+ * wins, and one that names a new path is simply added. `overlayFilter` is
184
+ * the other half — a path in `OVERLAY_DENY` is not copied at all, here or
185
+ * in `watchTheme`.
186
+ *
187
+ * Two kinds of line reach stderr, and they are disjoint by construction. A
188
+ * denied path is named once, by the filter, as it is refused. A path that
189
+ * survives the filter *and* names a file the renderer ships is named once
190
+ * more, with the version that shipped it — which is what makes the Unstable
191
+ * tier honest about what moved underneath a pinned `^0.4.x`. A denied path
192
+ * earns no replacement line however many shipped files it names: it was not
193
+ * overlaid, so the claim would simply be false. A path that only adds a file
194
+ * earns neither, because adding one is no compatibility risk.
195
+ */
196
+ async function overlayTheme(root, src) {
197
+ const theme = path.join(root, THEME_DIR);
198
+ if (!(await exists(theme)))
199
+ return;
200
+ await fs.cp(theme, src, {
201
+ recursive: true,
202
+ force: true,
203
+ filter: overlayFilter(theme, (rel) => {
204
+ console.error(`theme/${rel}: not overlaid — grimoire-indexer keeps its own copy of this path`);
205
+ }),
206
+ });
207
+ for (const rel of await replacedShippedPaths(theme)) {
208
+ console.error(`theme/${rel}: replaces a file shipped by grimoire-indexer ${indexerVersion()}`);
209
+ }
210
+ }
211
+ /**
212
+ * Which paths under `theme/` replace a file the renderer ships, out of
213
+ * every path a build is about to overlay. `overlayTheme` logs one stderr
214
+ * line per path this returns — naming the path and the indexer's own
215
+ * version, so the Unstable tier is honest about what changed underneath a
216
+ * pinned `^0.4.x` — and says nothing about a path with no shipped
217
+ * counterpart, because adding a new file is not a compatibility risk.
218
+ *
219
+ * Denied paths are dropped here as well as in the filter. They were never
220
+ * copied, so reporting one as a replacement would state something untrue —
221
+ * and `isOverlayDenied` matches a denied directory's children by prefix, so
222
+ * the walk needs no pruning of its own to get that right.
223
+ *
224
+ * Files only: a directory that shares a name with a shipped one replaces
225
+ * nothing by existing, and every file inside it is judged on its own.
226
+ */
227
+ async function replacedShippedPaths(theme) {
228
+ const entries = await fs.readdir(theme, { recursive: true, withFileTypes: true });
229
+ const replaced = [];
230
+ for (const entry of entries) {
231
+ if (!entry.isFile())
232
+ continue;
233
+ const rel = path
234
+ .relative(theme, path.join(entry.parentPath, entry.name))
235
+ .split(path.sep)
236
+ .join("/");
237
+ if (isOverlayDenied(rel))
238
+ continue;
239
+ if (await exists(path.join(ASTRO_SRC_DIR, rel)))
240
+ replaced.push(rel);
241
+ }
242
+ return replaced;
243
+ }
244
+ /**
245
+ * Warn about the one index-repo file that breaks a build without saying so.
246
+ *
247
+ * The staged root sits *inside* the index repo (it has to — Astro renames
248
+ * the built server assets into `outDir`, and that rename is EXDEV across
249
+ * filesystems), so tsconfig resolution can walk up out of it. A tsconfig
250
+ * that cannot be parsed does not fail loudly: the JSX transform falls back
251
+ * to React, and the first preact hook rendered dies as `Cannot read
252
+ * properties of undefined (reading 'context')` — naming preact, naming
253
+ * lucide, naming nothing that leads back to a tsconfig.
254
+ *
255
+ * `extends` is the only way to get an unparseable one by accident, and it is
256
+ * unparseable exactly while `node_modules` is missing — which is every
257
+ * scaffolded repo before its first install, and every CI job that reorders
258
+ * `npm ci` after the build. Two `exists` calls turn that into a sentence.
259
+ *
260
+ * ponytail: a substring test, not a resolution: reproducing tsconfck's
261
+ * lookup to answer "would this actually resolve" costs more than the warning
262
+ * is worth. It over-warns on an `extends` that resolves by relative path.
263
+ */
264
+ async function warnUnparseableTsconfig(root) {
265
+ const file = path.join(root, "tsconfig.json");
266
+ if (!(await exists(file)))
267
+ return;
268
+ if (await exists(path.join(root, "node_modules")))
269
+ return;
270
+ const text = await fs.readFile(file, "utf8").catch(() => "");
271
+ if (!/"extends"\s*:/.test(text))
272
+ return;
273
+ console.error(`${file}: "extends" cannot resolve without node_modules — run your install first.\n` +
274
+ " Left as is, the build fails while rendering a page, with an error naming preact rather than this file.");
275
+ }
53
276
  async function readPackages(outDir) {
54
277
  const file = path.join(outDir, "all.json");
55
278
  let text;
@@ -108,8 +331,14 @@ function withRatings(packages, stats) {
108
331
  return packages.map((p) => {
109
332
  // A ref present but carrying only other stats, and a `rating` whose `up`
110
333
  // is not a number, are both unrated — same as not being listed at all.
111
- const up = entries[p.ref]?.rating?.up;
112
- return typeof up === "number" ? { ...p, rating: { up } } : p;
334
+ const rating = entries[p.ref]?.rating;
335
+ if (typeof rating?.up !== "number")
336
+ return p;
337
+ // `url` rides along so a card can offer the vote; `target` does not, and
338
+ // must not — it is the forge's own thread id, useless on a page and
339
+ // otherwise inlined into every visitor's HTML.
340
+ const url = typeof rating.url === "string" ? rating.url : undefined;
341
+ return { ...p, rating: url ? { up: rating.up, url } : { up: rating.up } };
113
342
  });
114
343
  }
115
344
  /**
@@ -189,7 +418,22 @@ async function readCustomCss(root, customCss) {
189
418
  * the staged root gets a `node_modules/` of symlinks to wherever *this*
190
419
  * package's copies actually live.
191
420
  */
192
- const RUNTIME_DEPS = ["astro", "@astrojs/preact", "preact", "lucide-preact", "@mdi/js"];
421
+ const RUNTIME_DEPS = [
422
+ "astro",
423
+ "@astrojs/preact",
424
+ "preact",
425
+ // Not imported by anything here, and load-bearing anyway. The staged root
426
+ // lives inside the index repo, so Node's walk-up reaches that repo's own
427
+ // `node_modules` for any specifier the staged links do not cover — and an
428
+ // index repo installs this package, so it HAS a copy. Renderer and
429
+ // renderer-to-string then come from two different installs, preact is
430
+ // loaded twice, and the first hook rendered fails as `Cannot read
431
+ // properties of undefined (reading 'context')`. Linking it keeps every
432
+ // preact-touching module on one copy.
433
+ "preact-render-to-string",
434
+ "lucide-preact",
435
+ "@mdi/js",
436
+ ];
193
437
  // Node refuses to create a directory symlink on Windows without elevation;
194
438
  // a junction needs no privilege and behaves the same for resolution.
195
439
  const LINK_TYPE = process.platform === "win32" ? "junction" : "dir";
@@ -213,6 +457,24 @@ function packageRoot(dep) {
213
457
  }
214
458
  return dir;
215
459
  }
460
+ /**
461
+ * Copy `ASTRO_SRC_DIR` to `<dir>/original`, the path `stage` returns as
462
+ * `original`. `@grim-original/` (see `inlineConfig`) resolves into this
463
+ * copy, so an override that needs to wrap the file it replaced — rather
464
+ * than fully own it — has somewhere to reach that file from; `@grim/`
465
+ * cannot serve that purpose once the theme has replaced the path there, and
466
+ * the package's own `exports` block a deep import of the installed copy
467
+ * from outside this staged root entirely.
468
+ *
469
+ * A sibling of `<dir>/src` rather than a directory under it: Astro routes
470
+ * `srcDir/pages`, so a pristine copy nested inside `src` would publish a
471
+ * second, un-overlaid copy of every shipped page at a parallel URL.
472
+ */
473
+ async function stagePristineCopy(dir) {
474
+ const original = path.join(dir, "original");
475
+ await fs.cp(ASTRO_SRC_DIR, original, { recursive: true });
476
+ return original;
477
+ }
216
478
  /**
217
479
  * Build the directory Astro treats as its project root, for one build.
218
480
  *
@@ -235,15 +497,60 @@ function packageRoot(dep) {
235
497
  * repo's own `public/`, and whatever the data compile already wrote into
236
498
  * `outDir`. That last layer is the point — Astro empties `outDir` before it
237
499
  * builds, and `/all.json` is a frozen public URL that must survive.
500
+ *
501
+ * The returned `original` names two different things and only one of them
502
+ * is disposable. On the staged path it is `<dir>/original`, inside the
503
+ * scratch dir, and goes with it. On the `srcDir` path it is the caller's
504
+ * **live working tree** — `src/renderer/astro` under `scripts/dev.mjs` —
505
+ * which is why `stop()` removes `staged.dir` and nothing else. Never write
506
+ * `fs.rm(staged.original)`: today it would be a no-op inside a tree already
507
+ * being removed, and the first day someone reaches for it, it deletes this
508
+ * repository's renderer sources.
509
+ *
510
+ * The whole body after `mkdtemp` is wrapped, because nothing else can catch
511
+ * a throw from in here: the callers' own `try`/`finally` blocks wrap
512
+ * `build`/`dev`, which run only once `stage` has already returned. Without
513
+ * it a fault partway through — an unreadable `theme/`, a dependency that
514
+ * will not resolve — leaves a `.index-*` directory sitting in the user's
515
+ * index repo, and the error they see says nothing about it.
238
516
  */
239
517
  async function stage(root, outDir, srcDir) {
240
518
  const dir = await fs.mkdtemp(path.join(root, ".index-"));
519
+ try {
520
+ return await stageInto(dir, root, outDir, srcDir);
521
+ }
522
+ catch (err) {
523
+ await fs.rm(dir, { recursive: true, force: true });
524
+ throw err;
525
+ }
526
+ }
527
+ /** `stage`'s body, split out so its one cleanup path stays readable. */
528
+ async function stageInto(dir, root, outDir, srcDir) {
241
529
  // A caller-supplied `srcDir` is used in place of the staged copy: Vite
242
530
  // watches `srcDir` directly, so dev edits in the working tree hot-reload.
243
531
  // A copy would freeze the templates at server start.
244
532
  const src = srcDir ?? path.join(dir, "src");
245
- if (!srcDir)
533
+ // The overlay rides on the copy, and only on the copy: a caller-supplied
534
+ // `srcDir` is this repository's own working tree, and writing an index
535
+ // repo's theme into it would edit the renderer's sources under the
536
+ // author's feet. `scripts/dev.mjs` is the only caller that passes one.
537
+ let original;
538
+ if (!srcDir) {
246
539
  await fs.cp(ASTRO_SRC_DIR, src, { recursive: true });
540
+ // Taken *before* the overlay's `force` copy can replace anything, so
541
+ // `@grim-original/` (see `inlineConfig`) keeps resolving to the shipped
542
+ // file even once `overlayTheme` has replaced it under `src`.
543
+ original = await stagePristineCopy(dir);
544
+ await overlayTheme(root, src);
545
+ await warnUnparseableTsconfig(root);
546
+ }
547
+ else {
548
+ // No overlay ever touches a caller-supplied `srcDir` (see above), so
549
+ // there is nothing to take a pristine copy of — the working tree
550
+ // already is its own original. It is also *not* ours to delete: see the
551
+ // docblock's paragraph on the two meanings of `original`.
552
+ original = srcDir;
553
+ }
247
554
  for (const dep of RUNTIME_DEPS) {
248
555
  const link = path.join(dir, "node_modules", dep);
249
556
  await fs.mkdir(path.dirname(link), { recursive: true }); // `@scope/` needs its dir
@@ -278,7 +585,7 @@ async function stage(root, outDir, srcDir) {
278
585
  if (await exists(layer))
279
586
  await fs.cp(layer, staticDir, { recursive: true, force: true });
280
587
  }
281
- return { dir, src };
588
+ return { dir, src, original };
282
589
  }
283
590
  /**
284
591
  * Read and validate every input Astro will be handed.
@@ -332,10 +639,74 @@ function inlineConfig(opts, staged, { config, packages, css, base }) {
332
639
  __GRIMOIRE_DATA__: JSON.stringify({ config, packages, css }),
333
640
  __GRIMOIRE_BASE__: JSON.stringify(base),
334
641
  },
642
+ // What a page under `theme/pages/` imports the shipped components and
643
+ // helpers by. A relative specifier would work and would encode how
644
+ // deep that page sits under `pages/`, so moving the file breaks it;
645
+ // this does not. It resolves *after* the overlay, so an index that
646
+ // replaced a component imports its own — which is the intent.
647
+ //
648
+ // `@grim-original/` resolves into the pristine copy `stage` takes
649
+ // *before* the overlay runs, so an override that needs to wrap the
650
+ // file it replaced — rather than fully own it — has somewhere to
651
+ // reach that file from. `@grim/` cannot serve that purpose once the
652
+ // theme has replaced the path, and the package's own `exports` block
653
+ // a deep import of the installed copy from outside this staged root.
654
+ //
655
+ // The trailing slash on both is load-bearing: Vite's string aliases
656
+ // match by prefix, and a bare `@grim` would also swallow
657
+ // `@grimoire-rs/…`. Neither prefix is a prefix of the other, so
658
+ // neither swallows its sibling.
659
+ resolve: {
660
+ alias: {
661
+ "@grim/": `${staged.src}/`,
662
+ "@grim-original/": `${staged.original}/`,
663
+ },
664
+ },
335
665
  plugins: [bundlePreactRenderer],
336
666
  },
337
667
  };
338
668
  }
669
+ /**
670
+ * Warn about a `/`-rooted `nav` or `footerLinks` href that leads nowhere.
671
+ *
672
+ * Read out of `outDir` **after** the build, because that is the one place
673
+ * both halves of "somewhere to land" exist at once: the emitted routes —
674
+ * `theme/pages/` included, which no enumeration of the shipped sources
675
+ * would ever see — and every `public/` layer, which Astro has by then
676
+ * copied in. A check written against the packaged pages would report an
677
+ * index's own added page as dead, which is worse than no check.
678
+ *
679
+ * Warns and never fails, deliberately. `/logo.svg` names a file the index
680
+ * repo dropped in `public/`, and one wrong character names a file it did
681
+ * not; nothing here can tell that apart from a path served by something
682
+ * outside this build. A build that stopped on it would refuse a whole site
683
+ * over a typo in a footer.
684
+ *
685
+ * `nav` and `footerLinks` together: same shape, same defect, and fixing one
686
+ * of the two is how the sibling ships.
687
+ */
688
+ async function warnDanglingLinks(links, outDir) {
689
+ for (const { key, entries } of links) {
690
+ for (const [i, link] of entries.entries()) {
691
+ // An absolute URL is somebody else's site. A fragment or a query
692
+ // addresses a place inside the page, not a different one.
693
+ if (!link.href.startsWith("/"))
694
+ continue;
695
+ const target = path.resolve(outDir, `.${link.href.replace(/[?#].*$/, "")}`);
696
+ // `validateUrlShape` already refuses the shapes that escape, so this
697
+ // is a second answer rather than the only one — but a `..` segment
698
+ // here would report a hit no visitor can reach, which is the one
699
+ // outcome worse than a false warning.
700
+ const contained = path.relative(outDir, target);
701
+ const reachable = !contained.startsWith("..") &&
702
+ !path.isAbsolute(contained) &&
703
+ ((await exists(target)) || (await exists(path.join(target, "index.html"))));
704
+ if (reachable)
705
+ continue;
706
+ console.error(`${key}[${i}].href "${link.href}": nothing is published at that path — the link will 404`);
707
+ }
708
+ }
709
+ }
339
710
  /** Render the catalog site from `<outDir>/all.json` into `outDir`. */
340
711
  export async function buildSite(opts) {
341
712
  const inputs = await resolveInputs(opts);
@@ -363,6 +734,160 @@ export async function buildSite(opts) {
363
734
  process.chdir(cwd);
364
735
  await fs.rm(staged.dir, { recursive: true, force: true });
365
736
  }
737
+ // After the build, and outside the `finally`: there is nothing to check
738
+ // against until the routes exist, and a build that threw has a real error
739
+ // to report rather than a list of links that were never emitted.
740
+ await warnDanglingLinks([
741
+ { key: "nav", entries: inputs.config.nav },
742
+ { key: "footerLinks", entries: inputs.config.footerLinks },
743
+ ], opts.outDir);
744
+ }
745
+ /**
746
+ * Mirror edits under `<root>/theme` into the staged sources, so they reload.
747
+ *
748
+ * The staged tree is a copy taken at startup, so without this an edit to a
749
+ * theme file would need a server restart — in the one command whose whole
750
+ * job is the review loop for a branding change. Copying the changed file
751
+ * into the staged tree makes it an ordinary change inside Vite's own root,
752
+ * which it already watches.
753
+ *
754
+ * A *deleted* theme file is not handled: the staged copy of it stays, so the
755
+ * shipped original does not come back until the next start. Recovering that
756
+ * means diffing the tree against `ASTRO_SRC_DIR`, and a restart is the
757
+ * honest answer — the log line says so rather than leaving it a mystery.
758
+ */
759
+ /**
760
+ * Create `theme/` if it is not there yet, so `watch` below always has a
761
+ * directory to attach to — a `theme/` created only *after* `dev` starts
762
+ * currently never gets picked up at all, because nothing was ever watching
763
+ * for it to appear. A failure to create it branches on `ENOENT` — silent,
764
+ * the same "no `theme/`" case the caller already treats as normal — versus
765
+ * everything else, which should log rather than disappear the same way.
766
+ *
767
+ * Synchronous (`mkdirSync`) and staying that way. `watchTheme` is a
768
+ * constructor that returns a handle and every other step in it is sync;
769
+ * making this one `await` ripples three signatures — `watchTheme`,
770
+ * `devSite`'s call site, and the handle's type — to save one word. The
771
+ * callback `watch` above is imported from `node:fs` for the same reason.
772
+ */
773
+ function ensureThemeDir(theme) {
774
+ try {
775
+ mkdirSync(theme, { recursive: true });
776
+ }
777
+ catch (err) {
778
+ // ENOENT here means `root` itself is gone, which is not a theming
779
+ // problem and not this function's to report — the caller is already
780
+ // about to fail on it far more legibly.
781
+ if (err.code === "ENOENT")
782
+ return;
783
+ console.error(`${theme}: could not be created — theme edits will not reload`, err);
784
+ }
785
+ }
786
+ /**
787
+ * What an `'error'` event from the underlying watcher does: log it and
788
+ * disable further mirroring, without throwing, so a fault in the platform's
789
+ * recursive-watch backend degrades the dev server to "restart to resync"
790
+ * instead of taking it down.
791
+ *
792
+ * A factory, because "disable mirroring" means setting `watchTheme`'s own
793
+ * `stopped` — a local a module-level handler cannot reach, leaving it able
794
+ * to log and nothing else, which is half the contract. `disable` is that
795
+ * flag's setter, passed in at wire-up.
796
+ *
797
+ * `disable` runs first: an `'error'` may well be followed by more events,
798
+ * and mirroring half a tree is worse than mirroring none of it — the served
799
+ * site would then disagree with the working tree in a way the author has no
800
+ * way to see.
801
+ */
802
+ function handleWatcherFault(disable) {
803
+ return (err) => {
804
+ disable();
805
+ console.error(`${THEME_DIR}/: no longer watched for changes — restart the dev server to resync`, err);
806
+ };
807
+ }
808
+ function watchTheme(root, src) {
809
+ const theme = path.join(root, THEME_DIR);
810
+ ensureThemeDir(theme);
811
+ // One editor save fires several events for the same path, and two `cp`
812
+ // calls racing on one file have each other's half-written target — one
813
+ // unlinks it while the other chmods it, and the copy that *did* land gets
814
+ // reported as a failure. Serializing costs nothing at this volume and
815
+ // makes the error below mean what it says.
816
+ let queue = Promise.resolve();
817
+ // `stop()` deletes the staged tree, so a copy still in flight then fails on
818
+ // a directory that is *supposed* to be gone and reports a resync problem
819
+ // that does not exist. The flag is set before the tree goes, and `close()`
820
+ // waits for what was already queued — a stat here would only narrow the
821
+ // race, not end it.
822
+ // One line per denied ENTRY per session. The build copies once from
823
+ // `theme/` itself, so its filter is asked about `lib` and never about its
824
+ // children. Here the copy root is whatever the watcher named, so the same
825
+ // rule is judged again for `lib/moved.ts` and `lib/written.ts` — and an
826
+ // editor saving into a denied directory would otherwise scroll the reason
827
+ // it is being ignored off the screen with repeats of itself.
828
+ const reported = new Set();
829
+ const reportDeniedOnce = (rel) => {
830
+ const entry = deniedEntry(rel);
831
+ if (entry === null || reported.has(entry))
832
+ return;
833
+ reported.add(entry);
834
+ console.error(`${THEME_DIR}/${entry}: not mirrored — grimoire-indexer keeps its own copy of this path`);
835
+ };
836
+ let stopped = false;
837
+ let watcher;
838
+ try {
839
+ watcher = watch(theme, { recursive: true }, (_event, name) => {
840
+ if (name === null || stopped)
841
+ return;
842
+ const rel = name.toString();
843
+ queue = queue
844
+ .then(() => fs.cp(path.join(theme, rel), path.join(src, rel), {
845
+ recursive: true,
846
+ force: true,
847
+ // The same predicate the build overlays through, for the same
848
+ // reason and applied the same way — per copied entry, never per
849
+ // watch event. See `overlayFilter`.
850
+ filter: overlayFilter(theme, reportDeniedOnce),
851
+ }))
852
+ .catch((err) => {
853
+ // A delete or a rename away, which this does not mirror — and the
854
+ // only signal that the served site no longer matches the tree.
855
+ // stderr, because the dev server's own output is the payload here.
856
+ console.error(`theme/${rel}: not mirrored into the dev server — restart to resync`, err);
857
+ });
858
+ });
859
+ }
860
+ catch (err) {
861
+ // This used to mean "no `theme/` at all, the normal case", and it stopped
862
+ // meaning that the moment `ensureThemeDir` above guaranteed the
863
+ // directory. What is left to throw here is a real fault — EMFILE, or a
864
+ // platform whose recursive-watch backend is unsupported — and dev is
865
+ // still perfectly usable without the mirror, so this says what was lost
866
+ // rather than taking the server down or disappearing into a silent null.
867
+ // ENOENT is the one exception: `ensureThemeDir` already declined to
868
+ // report a missing `root`, and this would be the same non-news twice.
869
+ if (err.code !== "ENOENT") {
870
+ console.error(`${THEME_DIR}/: cannot be watched — theme edits will not reload until you restart`, err);
871
+ }
872
+ return null;
873
+ }
874
+ watcher.on("error", handleWatcherFault(() => {
875
+ stopped = true;
876
+ }));
877
+ return {
878
+ async close() {
879
+ stopped = true;
880
+ watcher.close();
881
+ await queue;
882
+ // Give back the `theme/` `ensureThemeDir` may have created, so a `dev`
883
+ // run against an index repo that has no theme does not litter it with
884
+ // an empty directory nobody asked for. Non-recursive `rmdir` is the
885
+ // whole guard: it fails on a directory with anything in it, so an
886
+ // author's own first file is what keeps theirs — no bookkeeping about
887
+ // who created it, and nothing that could ever delete real work.
888
+ await fs.rmdir(theme).catch(() => { });
889
+ },
890
+ };
366
891
  }
367
892
  /**
368
893
  * Serve the same site `buildSite` would emit, with HMR — the review loop for
@@ -380,15 +905,32 @@ export async function devSite(opts) {
380
905
  process.chdir(staged.dir);
381
906
  try {
382
907
  const server = await dev({ ...astro, server: { port: opts.port } });
908
+ // Only meaningful for a staged copy — with a caller-supplied `srcDir`
909
+ // there is no overlay to mirror, and Vite already watches that tree.
910
+ const themeWatcher = opts.srcDir ? null : watchTheme(opts.root, staged.src);
383
911
  const { address, port } = server.address;
384
912
  const host = address === "::" || address === "0.0.0.0" ? "localhost" : address;
385
913
  // No trailing slash on a subpath base: Astro's dev router registers the
386
914
  // base itself as the landing route, and `/index-repo/` 404s where
387
915
  // `/index-repo` renders. (`base` is already "/" for a domain-rooted
388
916
  // site, which is why it is interpolated rather than joined.)
917
+ //
918
+ // C-006. Every step `stop()` takes is destructive and none of them is
919
+ // safe to repeat: `server.stop()` throws on a dev server already down,
920
+ // and the `fs.rm` would delete whatever now stands at the staged path
921
+ // rather than the tree this call staged.
922
+ let stopped = false;
389
923
  return {
390
924
  url: `http://${host}:${port}${astro.base}`,
391
925
  async stop() {
926
+ // Check and set before the first `await`, so two overlapping calls
927
+ // cannot both get past it.
928
+ if (stopped)
929
+ return;
930
+ stopped = true;
931
+ // Before the staged tree goes: it waits for any mirror copy already
932
+ // in flight, which would otherwise fail against a deleted directory.
933
+ await themeWatcher?.close();
392
934
  await server.stop();
393
935
  process.chdir(cwd);
394
936
  await fs.rm(staged.dir, { recursive: true, force: true });