@mandujs/core 0.25.3 → 0.27.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/package.json +2 -1
- package/src/bundler/dev.ts +8 -0
- package/src/client/spa-nav-helper.ts +82 -0
- package/src/content/collection.ts +322 -19
- package/src/content/index.ts +11 -2
- package/src/content/llms-txt.ts +98 -17
- package/src/content/sidebar.ts +446 -28
- package/src/generator/generate.ts +8 -0
- package/src/kitchen/api/routes-api.ts +8 -1
- package/src/router/fs-patterns.ts +26 -1
- package/src/router/fs-routes.ts +21 -1
- package/src/router/fs-scanner.ts +81 -0
- package/src/router/fs-types.ts +52 -1
- package/src/router/index.ts +2 -0
- package/src/routes/index.ts +74 -0
- package/src/routes/metadata-routes.ts +427 -0
- package/src/routes/types.ts +341 -0
- package/src/runtime/server.ts +113 -0
- package/src/runtime/ssr.ts +12 -0
- package/src/runtime/streaming-ssr.ts +16 -0
- package/src/spec/schema.ts +51 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"./observability": "./src/observability/index.ts",
|
|
38
38
|
"./perf": "./src/perf/index.ts",
|
|
39
39
|
"./perf/hmr-markers": "./src/perf/hmr-markers.ts",
|
|
40
|
+
"./routes": "./src/routes/index.ts",
|
|
40
41
|
"./scheduler": "./src/scheduler/index.ts",
|
|
41
42
|
"./storage/s3": "./src/storage/s3/index.ts",
|
|
42
43
|
"./bundler/prerender": "./src/bundler/prerender.ts",
|
package/src/bundler/dev.ts
CHANGED
|
@@ -522,6 +522,14 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
522
522
|
apiModuleSet.add(normalizeFsPath(absPath));
|
|
523
523
|
watchDirs.add(path.dirname(absPath));
|
|
524
524
|
}
|
|
525
|
+
|
|
526
|
+
// Track metadata-route modules so edits to `app/sitemap.ts`
|
|
527
|
+
// etc. trigger the same hot-reload pipeline as API routes.
|
|
528
|
+
if (route.kind === "metadata" && route.module) {
|
|
529
|
+
const absPath = path.resolve(rootDir, route.module);
|
|
530
|
+
apiModuleSet.add(normalizeFsPath(absPath));
|
|
531
|
+
watchDirs.add(path.dirname(absPath));
|
|
532
|
+
}
|
|
525
533
|
}
|
|
526
534
|
|
|
527
535
|
// spec/slots 디렉토리도 추가
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #208 — Minimal inline SPA-navigation helper.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained IIFE injected into the SSR `<head>` that upgrades plain
|
|
5
|
+
* full-page navigations into client-side `history.pushState` +
|
|
6
|
+
* `fetch` + DOM-swap transitions, without loading any JS bundle.
|
|
7
|
+
*
|
|
8
|
+
* Motivating use case: docs / blog / marketing sites that build with
|
|
9
|
+
* `hydration: "none"` (no islands). Under Issue #193 the opt-out SPA
|
|
10
|
+
* router lives in `@mandujs/core/client` (`router.ts`), which only ships
|
|
11
|
+
* inside a hydration bundle. Zero-JS pages therefore lost the "feels
|
|
12
|
+
* like a SPA" behavior that `spa: true` (the framework default) promises.
|
|
13
|
+
*
|
|
14
|
+
* This helper fills the gap: ~1.6 KB of inline JavaScript that the
|
|
15
|
+
* browser parses and runs immediately, no module graph, no network
|
|
16
|
+
* round-trip. Paired with the `@view-transition { navigation: auto }`
|
|
17
|
+
* style block (#192) the result is a visually-animated pushState
|
|
18
|
+
* navigation on every internal link click.
|
|
19
|
+
*
|
|
20
|
+
* Design constraints (locked — changing any of these needs an explicit
|
|
21
|
+
* rationale in the PR):
|
|
22
|
+
*
|
|
23
|
+
* 1. **Exclusion parity with the full router** (`router.ts`
|
|
24
|
+
* `handleLinkClick`): every browser-owned escape hatch — modifier
|
|
25
|
+
* keys, non-left click, `target` other than `_self`, `download`,
|
|
26
|
+
* `mailto:` / `tel:` / `javascript:` / …, cross-origin, hash-only,
|
|
27
|
+
* no `href`, `data-no-spa`, and `event.defaultPrevented` — is
|
|
28
|
+
* checked here too. Regression matrix lives at
|
|
29
|
+
* `tests/client/spa-nav-helper-exclusions.test.ts`.
|
|
30
|
+
*
|
|
31
|
+
* 2. **Co-existence with the full router**: both handlers listen
|
|
32
|
+
* on `document` `click`. The helper bails out early when
|
|
33
|
+
* `window.__MANDU_ROUTER_STATE__` is present — that global is
|
|
34
|
+
* installed by `initializeRouter()` before it calls
|
|
35
|
+
* `addEventListener`, so on hydrated pages the full router wins.
|
|
36
|
+
* On pure-SSR pages the state global is missing and the helper
|
|
37
|
+
* is authoritative.
|
|
38
|
+
*
|
|
39
|
+
* 3. **View Transitions API** — we call
|
|
40
|
+
* `document.startViewTransition(cb)` when available, mirroring the
|
|
41
|
+
* `@view-transition` at-rule we already inject. Browsers without
|
|
42
|
+
* the API (Firefox, Safari < 18.2) execute the callback
|
|
43
|
+
* synchronously so the feature is a pure progressive enhancement.
|
|
44
|
+
*
|
|
45
|
+
* 4. **DOM swap strategy**: replace `document.body.innerHTML` using
|
|
46
|
+
* the parsed incoming document's `<body>`. This preserves the
|
|
47
|
+
* `<head>` across navigations (avoids re-running inline scripts
|
|
48
|
+
* like this helper) while still picking up `<title>` and
|
|
49
|
+
* `<meta>` changes via a selective head-element merge. We also
|
|
50
|
+
* reset `document.title`.
|
|
51
|
+
*
|
|
52
|
+
* 5. **Inline, not external**: same rationale as #192's prefetch
|
|
53
|
+
* helper — inline removes the extra round-trip on every SSR
|
|
54
|
+
* response, keeps the CSP posture simple (only two inline scripts:
|
|
55
|
+
* prefetch + spa-nav), and sidesteps the "zero-JS but loads one
|
|
56
|
+
* JS file anyway" awkwardness.
|
|
57
|
+
*
|
|
58
|
+
* 6. **Opt-out via `ssr.spa: false`**: the injection site
|
|
59
|
+
* (`ssr.ts::renderToHTML`, `streaming-ssr.ts::generateHTMLShell`)
|
|
60
|
+
* omits the `<script>` block entirely when the user's config sets
|
|
61
|
+
* `spa: false`. No runtime check needed inside the IIFE.
|
|
62
|
+
*
|
|
63
|
+
* The exported `SPA_NAV_HELPER_SCRIPT` wraps the IIFE in a
|
|
64
|
+
* `<script>` tag, ready to paste into `<head>` alongside the prefetch
|
|
65
|
+
* helper and `@view-transition` style block.
|
|
66
|
+
*
|
|
67
|
+
* Size target: ≤3 KB raw (currently ≈2.7 KB after the defensive
|
|
68
|
+
* hardNav / DOMParser-availability guards). If this grows past 3 KB we
|
|
69
|
+
* should revisit the inline-vs-external trade-off.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inner IIFE — exposed for unit tests that want to parse the source.
|
|
74
|
+
*
|
|
75
|
+
* Byte-minified on purpose (no comments, short names). The high-level
|
|
76
|
+
* flow is documented in this file's JSDoc; anyone editing this string
|
|
77
|
+
* MUST update the exclusion-matrix test to match.
|
|
78
|
+
*/
|
|
79
|
+
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;function hardNav(u){try{L.href=u;}catch(_){}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h||h.charAt(0)==="#")return null;var u;try{u=new URL(h,L.origin);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;return u;}function swap(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(nh&&ch){var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}var nb=doc.body;if(nb)document.body.innerHTML=nb.innerHTML;try{window.scrollTo(0,0);}catch(_){}}catch(_){}}function nav(url,push){fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok||!r.headers.get("content-type")||r.headers.get("content-type").indexOf("text/html")<0){hardNav(url);return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url);return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(_){hardNav(url);return;}if(push){try{H.pushState({mandu:1},"",url);}catch(_){hardNav(url);return;}}var run=function(){swap(doc);try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(_){run();}}else{run();}}).catch(function(){hardNav(url);});}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
|
|
80
|
+
|
|
81
|
+
/** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
|
|
82
|
+
export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
|
|
@@ -70,6 +70,68 @@ export interface CompiledCollectionEntry<T = Record<string, unknown>>
|
|
|
70
70
|
Component: () => unknown;
|
|
71
71
|
/** Rendered HTML string (only populated when MDX tooling is available). */
|
|
72
72
|
html?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Diagnostic info describing which pipeline produced `Component`.
|
|
75
|
+
*
|
|
76
|
+
* - `"unified"` — the full `unified + remark + rehype` chain ran.
|
|
77
|
+
* `html` is populated, `Component` wraps it via
|
|
78
|
+
* `dangerouslySetInnerHTML`.
|
|
79
|
+
* - `"fallback-missing-deps"` — one or more optional MDX deps were
|
|
80
|
+
* absent. `Component` returns a `<pre>` shell.
|
|
81
|
+
* - `"fallback-pipeline-error"` — deps loaded but the pipeline
|
|
82
|
+
* threw; `Component` returns a `<pre>` shell with `error` set.
|
|
83
|
+
*/
|
|
84
|
+
compilationMode: "unified" | "fallback-missing-deps" | "fallback-pipeline-error";
|
|
85
|
+
/** The pipeline error when `compilationMode === "fallback-pipeline-error"`. */
|
|
86
|
+
error?: Error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compile-time options forwarded to `getCompiled()`. All fields are
|
|
91
|
+
* optional; supplying any of the `*Plugins` arrays triggers the
|
|
92
|
+
* `unified + remark-parse + remark-rehype + rehype-stringify` pipeline
|
|
93
|
+
* (when the deps are installed).
|
|
94
|
+
*
|
|
95
|
+
* The plugins arrays are typed as `unknown[]` because the optional MDX
|
|
96
|
+
* ecosystem does not carry types into `@mandujs/core`. Consumers pass
|
|
97
|
+
* whatever their plugin entry exports — typically a function or a
|
|
98
|
+
* `[plugin, options]` tuple.
|
|
99
|
+
*/
|
|
100
|
+
export interface CompileOptions {
|
|
101
|
+
/** Extra remark plugins applied BEFORE `remark-rehype`. */
|
|
102
|
+
remarkPlugins?: unknown[];
|
|
103
|
+
/** Extra rehype plugins applied AFTER `remark-rehype`. */
|
|
104
|
+
rehypePlugins?: unknown[];
|
|
105
|
+
/**
|
|
106
|
+
* When true, suppress the warning emitted when optional MDX deps
|
|
107
|
+
* are missing and the function falls back to a `<pre>` shell.
|
|
108
|
+
* Use this in build scripts where you deliberately do not install
|
|
109
|
+
* the MDX toolchain. Default: false.
|
|
110
|
+
*/
|
|
111
|
+
silent?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Callback invoked by `Collection.watch()` on every filesystem event
|
|
116
|
+
* that could affect the collection. The handler is called with a
|
|
117
|
+
* shallow describe object so consumers can decide whether to
|
|
118
|
+
* `collection.invalidate()` and re-render, or skip (e.g. when the
|
|
119
|
+
* event targets a file outside the `extensions` allow-list).
|
|
120
|
+
*
|
|
121
|
+
* The watcher intentionally does NOT call `invalidate()` itself —
|
|
122
|
+
* callers typically know when to force a reload (debounce, batch with
|
|
123
|
+
* other changes) and the library staying hands-off matches how
|
|
124
|
+
* `chokidar`-style APIs behave in the wild.
|
|
125
|
+
*/
|
|
126
|
+
export type CollectionWatchHandler = (event: {
|
|
127
|
+
type: "change" | "rename";
|
|
128
|
+
filePath: string;
|
|
129
|
+
}) => void;
|
|
130
|
+
|
|
131
|
+
/** Control handle returned by `Collection.watch()`. */
|
|
132
|
+
export interface CollectionWatchHandle {
|
|
133
|
+
/** Stop listening to this watcher. Idempotent. */
|
|
134
|
+
unsubscribe(): void;
|
|
73
135
|
}
|
|
74
136
|
|
|
75
137
|
/**
|
|
@@ -147,16 +209,41 @@ const DEFAULT_EXTENSIONS = [".md", ".mdx", ".markdown"] as const;
|
|
|
147
209
|
/**
|
|
148
210
|
* Collection instance returned by `defineCollection({ path, ... })`.
|
|
149
211
|
*
|
|
212
|
+
* # Caching
|
|
213
|
+
*
|
|
150
214
|
* Load results are cached in-memory after the first `.load()` call.
|
|
151
215
|
* This is intentional — at the MVP we treat collections as build-time
|
|
152
|
-
* data that doesn't change within a process lifetime.
|
|
153
|
-
*
|
|
154
|
-
*
|
|
216
|
+
* data that doesn't change within a process lifetime. Call
|
|
217
|
+
* `.invalidate()` to force a rescan.
|
|
218
|
+
*
|
|
219
|
+
* # Watcher lifecycle (Issue #204 — critical)
|
|
220
|
+
*
|
|
221
|
+
* **Default: zero watchers.** `all()`, `get()`, and `getCompiled()`
|
|
222
|
+
* do NOT open any `fs.watch` handle. Build scripts like
|
|
223
|
+
* `scripts/prebuild-docs.ts` can run `await docs.all()` and the
|
|
224
|
+
* process will exit cleanly — no active handles left to pin the
|
|
225
|
+
* event loop.
|
|
226
|
+
*
|
|
227
|
+
* **Opt-in via `watch()`**: dev-mode tooling that wants change
|
|
228
|
+
* notifications calls `const handle = collection.watch(cb)`. This
|
|
229
|
+
* opens ONE `fs.watch` handle per collection (not per file), so the
|
|
230
|
+
* cost is bounded regardless of collection size.
|
|
231
|
+
*
|
|
232
|
+
* **Cleanup**: user code either calls `handle.unsubscribe()` or
|
|
233
|
+
* `await collection[Symbol.asyncDispose]()` (ES2023 `using`
|
|
234
|
+
* semantics). Either path closes every open watcher so the process
|
|
235
|
+
* exits.
|
|
155
236
|
*/
|
|
156
237
|
export class Collection<T = Record<string, unknown>> {
|
|
157
238
|
readonly options: DefineCollectionOptions<T>;
|
|
158
239
|
private entries: CollectionEntry<T>[] | null = null;
|
|
159
240
|
private loadPromise: Promise<CollectionEntry<T>[]> | null = null;
|
|
241
|
+
/**
|
|
242
|
+
* Active `fs.watch` handles — one per `watch()` call. Stored so
|
|
243
|
+
* `dispose()` can close every handle regardless of whether the
|
|
244
|
+
* user tracked the returned `unsubscribe` callback.
|
|
245
|
+
*/
|
|
246
|
+
private watchHandles: Set<{ close: () => void }> = new Set();
|
|
160
247
|
|
|
161
248
|
constructor(options: DefineCollectionOptions<T>) {
|
|
162
249
|
this.options = options;
|
|
@@ -280,18 +367,38 @@ export class Collection<T = Record<string, unknown>> {
|
|
|
280
367
|
* `Component` still returns a valid React element — a `<pre>` wrapper
|
|
281
368
|
* around the raw markdown — so callers never have to branch on the
|
|
282
369
|
* missing-dep case.
|
|
370
|
+
*
|
|
371
|
+
* # Plugin support (Issue #205)
|
|
372
|
+
*
|
|
373
|
+
* Pass `{ remarkPlugins, rehypePlugins }` to extend the pipeline
|
|
374
|
+
* — e.g. `rehype-slug`, `rehype-autolink-headings`, `shiki` for
|
|
375
|
+
* syntax highlighting. Plugins are applied in array order, remark
|
|
376
|
+
* plugins before `remark-rehype` and rehype plugins after.
|
|
377
|
+
*
|
|
378
|
+
* # Diagnostics (Issue #205)
|
|
379
|
+
*
|
|
380
|
+
* The returned entry includes a `compilationMode` discriminator
|
|
381
|
+
* so callers can tell whether the full pipeline ran or the fallback
|
|
382
|
+
* was used. When a dep is missing we emit a single-line warning
|
|
383
|
+
* (unless `silent: true`) naming which module could not be
|
|
384
|
+
* resolved — previously the fallback was silent, which made it
|
|
385
|
+
* impossible to diagnose why a `<pre>` appeared.
|
|
283
386
|
*/
|
|
284
387
|
async getCompiled(
|
|
285
|
-
slug: string
|
|
388
|
+
slug: string,
|
|
389
|
+
compileOptions: CompileOptions = {}
|
|
286
390
|
): Promise<CompiledCollectionEntry<T> | undefined> {
|
|
287
391
|
const entry = await this.get(slug);
|
|
288
392
|
if (!entry) return undefined;
|
|
289
|
-
const rendered = await renderMarkdownSafe(entry.content);
|
|
290
|
-
|
|
393
|
+
const rendered = await renderMarkdownSafe(entry.content, compileOptions);
|
|
394
|
+
const compiled: CompiledCollectionEntry<T> = {
|
|
291
395
|
...entry,
|
|
292
|
-
html: rendered.html,
|
|
293
396
|
Component: rendered.Component,
|
|
397
|
+
compilationMode: rendered.mode,
|
|
294
398
|
};
|
|
399
|
+
if (rendered.html !== undefined) compiled.html = rendered.html;
|
|
400
|
+
if (rendered.error !== undefined) compiled.error = rendered.error;
|
|
401
|
+
return compiled;
|
|
295
402
|
}
|
|
296
403
|
|
|
297
404
|
/**
|
|
@@ -301,6 +408,127 @@ export class Collection<T = Record<string, unknown>> {
|
|
|
301
408
|
invalidate(): void {
|
|
302
409
|
this.entries = null;
|
|
303
410
|
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Subscribe to filesystem events for this collection's root
|
|
414
|
+
* directory. The watcher is lazy — created here, not in the
|
|
415
|
+
* constructor — so callers who never call `watch()` pay no cost
|
|
416
|
+
* and their process exits cleanly after `all()`.
|
|
417
|
+
*
|
|
418
|
+
* The callback is invoked with a `{ type, filePath }` object
|
|
419
|
+
* where `filePath` is relative to the collection root. The
|
|
420
|
+
* caller typically calls `collection.invalidate()` in response
|
|
421
|
+
* and re-renders.
|
|
422
|
+
*
|
|
423
|
+
* Returns a handle with `unsubscribe()`. You can call that
|
|
424
|
+
* directly, or call `collection.dispose()` / use `await using`
|
|
425
|
+
* (ES2023) to close every watcher at once.
|
|
426
|
+
*/
|
|
427
|
+
watch(handler: CollectionWatchHandler): CollectionWatchHandle {
|
|
428
|
+
const root = this.resolveRoot();
|
|
429
|
+
if (!fs.existsSync(root)) {
|
|
430
|
+
// Nothing to watch — return a no-op handle so callers don't
|
|
431
|
+
// have to branch on "directory exists". If the directory
|
|
432
|
+
// appears later, they can unsubscribe and re-watch.
|
|
433
|
+
return { unsubscribe: () => {} };
|
|
434
|
+
}
|
|
435
|
+
const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
|
|
436
|
+
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
|
437
|
+
|
|
438
|
+
// Use node's `fs.watch` directly — one handle per collection
|
|
439
|
+
// root. `recursive: true` is the expensive bit; it is not
|
|
440
|
+
// supported on Linux before kernel 5.0 but the dev-mode path
|
|
441
|
+
// targets macOS/Windows/recent Linux where it works. If a
|
|
442
|
+
// project needs stricter compatibility, they can wrap
|
|
443
|
+
// `chokidar` in user code and call `invalidate()` themselves.
|
|
444
|
+
let watcher: fs.FSWatcher;
|
|
445
|
+
try {
|
|
446
|
+
watcher = fs.watch(root, { recursive: true }, (eventType, filename) => {
|
|
447
|
+
if (!filename) return;
|
|
448
|
+
const normalized = String(filename).replace(/\\/g, "/");
|
|
449
|
+
// Filter by extension so unrelated files don't fire the
|
|
450
|
+
// callback. We still let the event propagate for rename
|
|
451
|
+
// events on directories (no extension) — those can affect
|
|
452
|
+
// slugs.
|
|
453
|
+
const lastDot = normalized.lastIndexOf(".");
|
|
454
|
+
const ext = lastDot >= 0 ? normalized.slice(lastDot).toLowerCase() : "";
|
|
455
|
+
if (ext && !extSet.has(ext)) return;
|
|
456
|
+
try {
|
|
457
|
+
handler({
|
|
458
|
+
type: eventType === "rename" ? "rename" : "change",
|
|
459
|
+
filePath: normalized,
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// Swallow user-handler errors so one buggy consumer
|
|
463
|
+
// does not tear down the watcher for everyone else.
|
|
464
|
+
console.error(
|
|
465
|
+
`[content] watch handler threw for ${normalized}:`,
|
|
466
|
+
err instanceof Error ? err.message : err
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
} catch (err) {
|
|
471
|
+
// Some platforms (notably older Linux) don't support
|
|
472
|
+
// `recursive: true`. Log once and return a no-op handle so
|
|
473
|
+
// the caller's code continues — missing-reload is
|
|
474
|
+
// degradation, not breakage.
|
|
475
|
+
console.warn(
|
|
476
|
+
`[content] fs.watch(${root}) failed — hot reload disabled for this collection:`,
|
|
477
|
+
err instanceof Error ? err.message : err
|
|
478
|
+
);
|
|
479
|
+
return { unsubscribe: () => {} };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const entry = { close: () => watcher.close() };
|
|
483
|
+
this.watchHandles.add(entry);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
unsubscribe: () => {
|
|
487
|
+
if (!this.watchHandles.has(entry)) return;
|
|
488
|
+
this.watchHandles.delete(entry);
|
|
489
|
+
try {
|
|
490
|
+
watcher.close();
|
|
491
|
+
} catch {
|
|
492
|
+
// Already closed — ignore.
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Close every active watcher opened by `.watch()`. Safe to call
|
|
500
|
+
* repeatedly. After `dispose()` the collection remains usable
|
|
501
|
+
* — calling `watch()` again creates a fresh handle.
|
|
502
|
+
*/
|
|
503
|
+
dispose(): void {
|
|
504
|
+
for (const handle of this.watchHandles) {
|
|
505
|
+
try {
|
|
506
|
+
handle.close();
|
|
507
|
+
} catch {
|
|
508
|
+
// Handle already closed — ignore.
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
this.watchHandles.clear();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* ES2023 async-dispose support. Enables:
|
|
516
|
+
*
|
|
517
|
+
* ```ts
|
|
518
|
+
* await using docs = defineCollection({ path: 'content/docs' });
|
|
519
|
+
* const unsubscribe = docs.watch(onChange);
|
|
520
|
+
* // ... work ...
|
|
521
|
+
* // docs[Symbol.asyncDispose]() runs automatically at scope exit
|
|
522
|
+
* ```
|
|
523
|
+
*
|
|
524
|
+
* The async variant is used (instead of sync `Symbol.dispose`)
|
|
525
|
+
* because real watchers in the ecosystem close asynchronously
|
|
526
|
+
* — we keep the signature future-proof even though `fs.watch`
|
|
527
|
+
* happens to close synchronously today.
|
|
528
|
+
*/
|
|
529
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
530
|
+
this.dispose();
|
|
531
|
+
}
|
|
304
532
|
}
|
|
305
533
|
|
|
306
534
|
/**
|
|
@@ -359,6 +587,14 @@ function walkDir(dir: string, out: string[], extSet: Set<string>): void {
|
|
|
359
587
|
}
|
|
360
588
|
}
|
|
361
589
|
|
|
590
|
+
/** Internal result type from `renderMarkdownSafe`. */
|
|
591
|
+
interface RenderedMarkdown {
|
|
592
|
+
html?: string;
|
|
593
|
+
Component: () => unknown;
|
|
594
|
+
mode: CompiledCollectionEntry<unknown>["compilationMode"];
|
|
595
|
+
error?: Error;
|
|
596
|
+
}
|
|
597
|
+
|
|
362
598
|
/**
|
|
363
599
|
* Lazy markdown renderer. Attempts to load `unified` + the standard
|
|
364
600
|
* remark/rehype plugin chain; when any piece is missing, falls back
|
|
@@ -367,10 +603,17 @@ function walkDir(dir: string, out: string[], extSet: Set<string>): void {
|
|
|
367
603
|
* We go through `Function("return import(...)")` instead of a direct
|
|
368
604
|
* dynamic `import()` so TS doesn't resolve the optional modules
|
|
369
605
|
* during typecheck — they are NOT in `@mandujs/core` deps by design.
|
|
606
|
+
*
|
|
607
|
+
* The `options.remarkPlugins` / `options.rehypePlugins` arrays let
|
|
608
|
+
* docs sites add `rehype-slug`, `rehype-autolink-headings`, `shiki`,
|
|
609
|
+
* etc. — the caller is responsible for installing those modules.
|
|
370
610
|
*/
|
|
371
611
|
async function renderMarkdownSafe(
|
|
372
|
-
body: string
|
|
373
|
-
|
|
612
|
+
body: string,
|
|
613
|
+
options: CompileOptions = {}
|
|
614
|
+
): Promise<RenderedMarkdown> {
|
|
615
|
+
const { remarkPlugins = [], rehypePlugins = [], silent = false } = options;
|
|
616
|
+
|
|
374
617
|
// Passing the module specifier through a Function-wrapped dynamic
|
|
375
618
|
// import keeps TypeScript from erroring on optional peer deps; if
|
|
376
619
|
// any module is missing we fall through to the raw-markdown path.
|
|
@@ -381,6 +624,7 @@ async function renderMarkdownSafe(
|
|
|
381
624
|
return null;
|
|
382
625
|
}
|
|
383
626
|
};
|
|
627
|
+
|
|
384
628
|
const unified = (await tryImport("unified")) as
|
|
385
629
|
| { unified: () => unknown }
|
|
386
630
|
| null;
|
|
@@ -394,38 +638,97 @@ async function renderMarkdownSafe(
|
|
|
394
638
|
| { default: unknown }
|
|
395
639
|
| null;
|
|
396
640
|
|
|
397
|
-
|
|
641
|
+
// Collect the names of missing modules so the warning is
|
|
642
|
+
// actionable — a generic "MDX tooling not installed" is hard to
|
|
643
|
+
// act on when you DO have some of it installed.
|
|
644
|
+
const missing: string[] = [];
|
|
645
|
+
if (!unified) missing.push("unified");
|
|
646
|
+
if (!remarkParse) missing.push("remark-parse");
|
|
647
|
+
if (!remarkRehype) missing.push("remark-rehype");
|
|
648
|
+
if (!rehypeStringify) missing.push("rehype-stringify");
|
|
649
|
+
|
|
650
|
+
if (missing.length === 0 && unified && remarkParse && remarkRehype && rehypeStringify) {
|
|
398
651
|
try {
|
|
399
652
|
type Processor = {
|
|
400
|
-
use: (plugin: unknown) => Processor;
|
|
653
|
+
use: (plugin: unknown, options?: unknown) => Processor;
|
|
401
654
|
process: (src: string) => Promise<{ toString: () => string }>;
|
|
402
655
|
};
|
|
403
656
|
// Type-punned unified chain — optional peer deps don't carry
|
|
404
657
|
// their own types into our graph, so we route through `unknown`.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
658
|
+
let chain = unified.unified() as unknown as Processor;
|
|
659
|
+
chain = chain.use(remarkParse.default);
|
|
660
|
+
// User-supplied remark plugins run between `remark-parse`
|
|
661
|
+
// and `remark-rehype` so they can transform the MDAST.
|
|
662
|
+
for (const plugin of remarkPlugins) {
|
|
663
|
+
chain = applyPlugin(chain, plugin);
|
|
664
|
+
}
|
|
665
|
+
chain = chain.use(remarkRehype.default);
|
|
666
|
+
// Rehype plugins run AFTER `remark-rehype` so they see the
|
|
667
|
+
// HAST — this is the hook point for `rehype-slug` etc.
|
|
668
|
+
for (const plugin of rehypePlugins) {
|
|
669
|
+
chain = applyPlugin(chain, plugin);
|
|
670
|
+
}
|
|
671
|
+
chain = chain.use(rehypeStringify.default);
|
|
672
|
+
const file = await chain.process(body);
|
|
411
673
|
const html = file.toString();
|
|
412
674
|
return {
|
|
413
675
|
html,
|
|
414
676
|
Component: () => createHtmlElement(html),
|
|
677
|
+
mode: "unified",
|
|
678
|
+
};
|
|
679
|
+
} catch (err) {
|
|
680
|
+
// Pipeline itself threw — report it so the caller sees the
|
|
681
|
+
// underlying failure instead of a silent `<pre>`.
|
|
682
|
+
if (!silent) {
|
|
683
|
+
console.warn(
|
|
684
|
+
"[content] MDX pipeline failed; falling back to <pre>. Underlying error:",
|
|
685
|
+
err instanceof Error ? err.message : err
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
Component: () => createPreElement(body),
|
|
690
|
+
mode: "fallback-pipeline-error",
|
|
691
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
415
692
|
};
|
|
416
|
-
} catch {
|
|
417
|
-
// Fall through to raw fallback
|
|
418
693
|
}
|
|
419
694
|
}
|
|
420
695
|
|
|
421
696
|
// Fallback: emit a simple React element wrapping the raw body in a
|
|
422
697
|
// `<pre>` so pages don't 500. Pages that need real MDX should
|
|
423
698
|
// install `unified` + remark/rehype in their project.
|
|
699
|
+
if (!silent && missing.length > 0) {
|
|
700
|
+
// One warning per call — if the project is rendering 500 pages
|
|
701
|
+
// this will be noisy, but that noise is the feedback users
|
|
702
|
+
// need to know WHY their markdown is not compiling. `silent:
|
|
703
|
+
// true` mutes this for build scripts that deliberately opt out.
|
|
704
|
+
console.warn(
|
|
705
|
+
`[content] MDX tooling missing: ${missing.join(
|
|
706
|
+
", "
|
|
707
|
+
)}. Install these peer deps to enable full rendering — falling back to <pre> shell.`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
424
710
|
return {
|
|
425
711
|
Component: () => createPreElement(body),
|
|
712
|
+
mode: "fallback-missing-deps",
|
|
426
713
|
};
|
|
427
714
|
}
|
|
428
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Apply a plugin spec to a unified chain. The unified plugin
|
|
718
|
+
* ecosystem accepts either a bare function or a `[plugin, options]`
|
|
719
|
+
* tuple, so we handle both without pulling the unified types in.
|
|
720
|
+
*/
|
|
721
|
+
function applyPlugin<P extends { use: (plugin: unknown, options?: unknown) => P }>(
|
|
722
|
+
chain: P,
|
|
723
|
+
plugin: unknown
|
|
724
|
+
): P {
|
|
725
|
+
if (Array.isArray(plugin)) {
|
|
726
|
+
const [fn, ...rest] = plugin;
|
|
727
|
+
return chain.use(fn, ...rest);
|
|
728
|
+
}
|
|
729
|
+
return chain.use(plugin);
|
|
730
|
+
}
|
|
731
|
+
|
|
429
732
|
/**
|
|
430
733
|
* Build a lightweight React element carrying HTML content. We avoid
|
|
431
734
|
* importing React directly so `@mandujs/core/content` stays
|
package/src/content/index.ts
CHANGED
|
@@ -185,6 +185,9 @@ export type {
|
|
|
185
185
|
CompiledCollectionEntry,
|
|
186
186
|
CollectionSort,
|
|
187
187
|
DefineCollectionOptions,
|
|
188
|
+
CompileOptions,
|
|
189
|
+
CollectionWatchHandler,
|
|
190
|
+
CollectionWatchHandle,
|
|
188
191
|
} from "./collection";
|
|
189
192
|
|
|
190
193
|
export { z } from "./schema";
|
|
@@ -196,8 +199,14 @@ export type { ParsedFrontmatter } from "./frontmatter";
|
|
|
196
199
|
export { slugFromPath } from "./slug";
|
|
197
200
|
export type { SlugFromPathOptions } from "./slug";
|
|
198
201
|
|
|
199
|
-
export { generateSidebar } from "./sidebar";
|
|
200
|
-
export type {
|
|
202
|
+
export { generateSidebar, generateCategoryTree } from "./sidebar";
|
|
203
|
+
export type {
|
|
204
|
+
SidebarNode,
|
|
205
|
+
GenerateSidebarOptions,
|
|
206
|
+
Category,
|
|
207
|
+
CategoryEntry,
|
|
208
|
+
DirMeta,
|
|
209
|
+
} from "./sidebar";
|
|
201
210
|
|
|
202
211
|
export { generateLLMSTxt } from "./llms-txt";
|
|
203
212
|
export type { LLMSTxtInput, GenerateLLMSTxtOptions } from "./llms-txt";
|