@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.
- package/CHANGELOG.md +190 -0
- package/NOTICE +30 -0
- package/README.md +76 -331
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +35 -4
- package/dist/cli/init.js.map +1 -1
- package/dist/config.d.ts +107 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +181 -36
- package/dist/config.js.map +1 -1
- package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
- package/dist/renderer/astro/components/CardLogo.js +58 -0
- package/dist/renderer/astro/components/CardLogo.tsx +96 -0
- package/dist/renderer/astro/components/Catalog.d.ts +14 -1
- package/dist/renderer/astro/components/Catalog.js +467 -108
- package/dist/renderer/astro/components/Catalog.tsx +756 -349
- package/dist/renderer/astro/components/CommandBar.astro +66 -0
- package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
- package/dist/renderer/astro/components/CopyButton.js +28 -0
- package/dist/renderer/astro/components/CopyButton.tsx +56 -0
- package/dist/renderer/astro/components/KindMark.d.ts +69 -0
- package/dist/renderer/astro/components/KindMark.js +66 -0
- package/dist/renderer/astro/components/KindMark.tsx +141 -0
- package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
- package/dist/renderer/astro/components/PackageCard.js +50 -0
- package/dist/renderer/astro/components/PackageCard.tsx +273 -0
- package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
- package/dist/renderer/astro/components/PackageRow.js +32 -0
- package/dist/renderer/astro/components/PackageRow.tsx +126 -0
- package/dist/renderer/astro/components/PickerMenu.astro +5 -14
- package/dist/renderer/astro/components/SiteFooter.astro +64 -0
- package/dist/renderer/astro/components/SiteHeader.astro +74 -0
- package/dist/renderer/astro/components/VersionMenu.astro +2 -2
- package/dist/renderer/astro/layouts/Base.astro +860 -206
- package/dist/renderer/astro/lib/base.d.ts +25 -0
- package/dist/renderer/astro/lib/base.js +23 -0
- package/dist/renderer/astro/lib/base.ts +27 -0
- package/dist/renderer/astro/lib/catalog.d.ts +24 -0
- package/dist/renderer/astro/lib/catalog.js +36 -0
- package/dist/renderer/astro/lib/catalog.ts +37 -0
- package/dist/renderer/astro/lib/commands.d.ts +58 -0
- package/dist/renderer/astro/lib/commands.js +86 -0
- package/dist/renderer/astro/lib/commands.ts +117 -0
- package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
- package/dist/renderer/astro/lib/keywordRail.js +99 -0
- package/dist/renderer/astro/lib/keywordRail.ts +110 -0
- package/dist/renderer/astro/pages/index.astro +40 -87
- package/dist/renderer/astro/pages/p/[...slug].astro +340 -195
- package/dist/renderer/astro/styles/tokens.css +40 -5
- package/dist/renderer/index.d.ts +58 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +547 -5
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/types.d.ts +9 -0
- package/dist/renderer/types.d.ts.map +1 -1
- package/package.json +9 -4
- package/templates/README.md +6 -0
- package/templates/gitignore +4 -1
- package/templates/theme/README.md +38 -0
- package/templates/tsconfig.json +47 -0
package/dist/renderer/index.js
CHANGED
|
@@ -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
|
|
112
|
-
|
|
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 = [
|
|
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
|
-
|
|
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 });
|