@takazudo/zfb 0.1.0-next.2

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 (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +207 -0
  4. package/bin/zfb.mjs +61 -0
  5. package/dist/config.d.ts +542 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +24 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/content.d.ts +231 -0
  10. package/dist/content.d.ts.map +1 -0
  11. package/dist/content.js +449 -0
  12. package/dist/content.js.map +1 -0
  13. package/dist/frontmatter.d.ts +23 -0
  14. package/dist/frontmatter.d.ts.map +1 -0
  15. package/dist/frontmatter.js +142 -0
  16. package/dist/frontmatter.js.map +1 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +21 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/island.d.ts +121 -0
  22. package/dist/island.d.ts.map +1 -0
  23. package/dist/island.js +273 -0
  24. package/dist/island.js.map +1 -0
  25. package/dist/jsx-types.d.ts +37 -0
  26. package/dist/jsx-types.d.ts.map +1 -0
  27. package/dist/jsx-types.js +12 -0
  28. package/dist/jsx-types.js.map +1 -0
  29. package/dist/paginate.d.ts +43 -0
  30. package/dist/paginate.d.ts.map +1 -0
  31. package/dist/paginate.js +44 -0
  32. package/dist/paginate.js.map +1 -0
  33. package/dist/plugins.d.ts +259 -0
  34. package/dist/plugins.d.ts.map +1 -0
  35. package/dist/plugins.js +42 -0
  36. package/dist/plugins.js.map +1 -0
  37. package/dist/runtime.d.ts +101 -0
  38. package/dist/runtime.d.ts.map +1 -0
  39. package/dist/runtime.js +454 -0
  40. package/dist/runtime.js.map +1 -0
  41. package/dist/types.d.ts +27 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +33 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +98 -0
@@ -0,0 +1,231 @@
1
+ import { parseFrontmatter } from "./frontmatter.js";
2
+ import type { ParsedFrontmatter } from "./frontmatter.js";
3
+ import type { VNode } from "./jsx-types.js";
4
+ export { parseFrontmatter };
5
+ export type { ParsedFrontmatter };
6
+ /**
7
+ * One entry in an embedded content snapshot. Mirrors
8
+ * `crates/zfb-content/src/content_bridge.rs::EntrySnapshot`. Re-exported
9
+ * by `@takazudo/zfb-runtime/snapshot` for the runtime-side bundle. See
10
+ * that module for field-by-field documentation.
11
+ */
12
+ export interface SnapshotEntry {
13
+ readonly slug: string;
14
+ readonly frontmatter: unknown;
15
+ readonly body: string;
16
+ readonly module_specifier: string;
17
+ readonly rel_path: string;
18
+ }
19
+ /**
20
+ * Point-in-time snapshot of every configured collection. Mirrors
21
+ * `crates/zfb-content/src/content_bridge.rs::ContentSnapshot`.
22
+ */
23
+ export interface Snapshot {
24
+ readonly collections: Readonly<Record<string, readonly SnapshotEntry[]>>;
25
+ }
26
+ /**
27
+ * Register a [`Snapshot`] so [`getCollection`] resolves from memory.
28
+ *
29
+ * Pass `undefined` to clear (used by tests that need to restore the v0
30
+ * filesystem path between runs). Idempotent: the latest call wins.
31
+ */
32
+ export declare function setContentSnapshot(snapshot: Snapshot | undefined): void;
33
+ /**
34
+ * Read the currently-installed [`Snapshot`], or `undefined` if none is
35
+ * registered. Exposed mostly for tests; production callers should not
36
+ * need to introspect the bridge state.
37
+ */
38
+ export declare function getContentSnapshot(): Snapshot | undefined;
39
+ /**
40
+ * Props accepted by an entry's [`CollectionEntry.Content`] component.
41
+ *
42
+ * `components` mirrors Astro's `<Content components={...}>` contract:
43
+ * a flat record of element-name → override component (e.g. `{ h1: MyH1 }`).
44
+ * The default-components convention ships from `zfb`'s root export
45
+ * (`defaultComponents`, lands in Sub 6) and users compose with their own
46
+ * via `{ ...defaultComponents, ...mine }`.
47
+ */
48
+ export interface ContentProps {
49
+ /** Element-name → override component map. Optional. */
50
+ components?: Record<string, unknown>;
51
+ }
52
+ /**
53
+ * Public JSX-element shape returned by [`CollectionEntry.Content`].
54
+ *
55
+ * Matches the structural shape that both Preact's and React's `jsx-runtime`
56
+ * accept on either side of the boundary, mirroring the Island wrapper's
57
+ * approach. Consumers should treat this as opaque — its only contract is
58
+ * "renderable JSX value".
59
+ *
60
+ * Aliased as `JSX.Element` in the field signature: the JS runtime is
61
+ * type-erased and the actual VNode shape is supplied by the framework
62
+ * adapter at evaluation time.
63
+ */
64
+ export type ContentElement = {
65
+ readonly type: string | ((...args: unknown[]) => unknown);
66
+ readonly props: Readonly<Record<string, unknown>>;
67
+ readonly key: unknown;
68
+ };
69
+ /**
70
+ * Generic shape returned for one entry in a content collection. The `data`
71
+ * field carries parsed frontmatter, typed by the caller via the generic
72
+ * parameter.
73
+ */
74
+ export type CollectionEntry<T = Record<string, unknown>> = {
75
+ /** Filename without `.md` extension. Stable across runs. */
76
+ slug: string;
77
+ /** Parsed frontmatter. */
78
+ data: T;
79
+ /** Raw markdown body (frontmatter stripped). */
80
+ body: string;
81
+ /**
82
+ * Stable module specifier used as the bridge lookup key. Format:
83
+ * `mdx://<collection>/<slug>` (no hash component — the JS stub does
84
+ * not compile MDX, so it has no body hash to attach; the production
85
+ * Rust-side `zfb-content::collection::Entry::module_specifier` adds a
86
+ * `#<hash>` suffix and the bridge is responsible for matching either
87
+ * form against its registered components).
88
+ *
89
+ * This field is part of the v0+ JS surface so the bridge has something
90
+ * deterministic to key on without consulting per-call state.
91
+ */
92
+ module_specifier: string;
93
+ /**
94
+ * Renderable component for this entry.
95
+ *
96
+ * **Bridge contract.** At call time, `Content` consults
97
+ * `globalThis.__zfb?.content?.get(entry.module_specifier)`. If the
98
+ * bridge is present and returns a function, that function is invoked
99
+ * with `props` and its result returned verbatim.
100
+ *
101
+ * **Fallback.** Outside the renderer (unit tests, dev sandboxes, or any
102
+ * environment where `globalThis.__zfb.content.get` is absent or returns
103
+ * `undefined`), `Content` returns a JSX-shaped element rendering the
104
+ * raw markdown body inside a `<pre data-zfb-content-fallback>` block,
105
+ * with a leading `[zfb fallback render]` marker line so the visual
106
+ * distinction survives unstyled environments. The marker is also a
107
+ * grep target for "did the production renderer not run?" diagnostics.
108
+ *
109
+ * **Typed signature.** Returns `ContentElement` (a structural alias for
110
+ * `JSX.Element`) so consumers can drop `<entry.Content components={...} />`
111
+ * into both React and Preact JSX without per-framework type setup.
112
+ *
113
+ * @example
114
+ * const post = (await getCollection("blog"))[0];
115
+ * return <post.Content components={{ ...defaultComponents, h1: MyH1 }} />;
116
+ */
117
+ Content: (props: ContentProps) => ContentElement;
118
+ };
119
+ /**
120
+ * Load every `*.md` file in the named collection. Files starting with `.`
121
+ * or that lack a `.md` extension are ignored.
122
+ *
123
+ * **ADR-004 contract: this function is synchronous.** TSX page modules
124
+ * call it from anywhere — top-level, inside a render body, inside a
125
+ * `useMemo` — and SSR completes in a single pass without yielding. The
126
+ * snapshot path returns from memory; the filesystem fallback uses sync
127
+ * `node:fs` APIs so the surface stays unified. (The legacy async
128
+ * implementation was an oversight — the ADR predates it; SSG paths
129
+ * always saw a Promise where ADR-004 says they should see an array,
130
+ * which is why migrations from Astro tripped on `getCollection().filter
131
+ * is not a function`.)
132
+ *
133
+ * @example
134
+ * const posts = getCollection<{ title: string; date: string }>("blog");
135
+ */
136
+ export declare function getCollection<T = Record<string, unknown>>(name: string): CollectionEntry<T>[];
137
+ /**
138
+ * @internal
139
+ *
140
+ * Convert a `path.relative()` result into a forward-slash-separated
141
+ * slug with the trailing `.md` extension stripped.
142
+ *
143
+ * Slugs are URL-flavored identifiers, not filesystem paths — they
144
+ * MUST use `/` regardless of the host OS so a nested entry like
145
+ * `2024/hello.md` produces the slug `2024/hello` on both POSIX and
146
+ * Windows. Without this normalisation, Windows callers would see
147
+ * `2024\hello`, which then leaks through to `module_specifier` and
148
+ * any URL the consumer derives from the slug.
149
+ *
150
+ * Exported solely so the unit test suite can pin the Windows
151
+ * behaviour without needing an actual Windows host. Do not depend on
152
+ * this from application code — name and signature may change.
153
+ */
154
+ export declare function _relPathToSlug(relPath: string): string;
155
+ /**
156
+ * Public JSX-element shape returned by every override in [`defaultComponents`].
157
+ *
158
+ * Mirrors [`ContentElement`] and [`IslandElement`]: a structural alias for
159
+ * `JSX.Element` so consumers can drop these overrides into both React and
160
+ * Preact JSX without per-framework type setup.
161
+ */
162
+ export type ContentComponentElement = {
163
+ readonly type: string;
164
+ readonly props: Readonly<Record<string, unknown>>;
165
+ readonly key: unknown;
166
+ };
167
+ /**
168
+ * Props accepted by every default override. `children` and any extra
169
+ * attributes (`className`, `id`, `href`, …) are passed through verbatim
170
+ * to the underlying HTML element.
171
+ */
172
+ export interface ContentComponentProps {
173
+ children?: VNode;
174
+ [key: string]: unknown;
175
+ }
176
+ /**
177
+ * `<h2>` passthrough override. Ported from zudo-doc's `HeadingH2`, stripped
178
+ * of styling — v0 ships pass-through behaviour; visual treatment is layered
179
+ * on by the consumer (or by a follow-up enhancement pass).
180
+ */
181
+ export declare function ContentH2(props: ContentComponentProps): ContentComponentElement;
182
+ /** `<h3>` passthrough override. See [`ContentH2`] for the contract. */
183
+ export declare function ContentH3(props: ContentComponentProps): ContentComponentElement;
184
+ /** `<h4>` passthrough override. See [`ContentH2`] for the contract. */
185
+ export declare function ContentH4(props: ContentComponentProps): ContentComponentElement;
186
+ /** `<p>` passthrough override. Mirrors zudo-doc's `ContentParagraph`. */
187
+ export declare function ContentParagraph(props: ContentComponentProps): ContentComponentElement;
188
+ /** `<a>` passthrough override. Mirrors zudo-doc's `ContentLink`. */
189
+ export declare function ContentLink(props: ContentComponentProps): ContentComponentElement;
190
+ /** `<strong>` passthrough override. Mirrors zudo-doc's `ContentStrong`. */
191
+ export declare function ContentStrong(props: ContentComponentProps): ContentComponentElement;
192
+ /** `<blockquote>` passthrough override. Mirrors zudo-doc's `ContentBlockquote`. */
193
+ export declare function ContentBlockquote(props: ContentComponentProps): ContentComponentElement;
194
+ /** `<ul>` passthrough override. Mirrors zudo-doc's `ContentUl`. */
195
+ export declare function ContentUl(props: ContentComponentProps): ContentComponentElement;
196
+ /** `<ol>` passthrough override. Mirrors zudo-doc's `ContentOl`. */
197
+ export declare function ContentOl(props: ContentComponentProps): ContentComponentElement;
198
+ /** `<table>` passthrough override. Mirrors zudo-doc's `ContentTable`. */
199
+ export declare function ContentTable(props: ContentComponentProps): ContentComponentElement;
200
+ /** `<code>` passthrough override. Mirrors zudo-doc's `ContentCode`. */
201
+ export declare function ContentCode(props: ContentComponentProps): ContentComponentElement;
202
+ /**
203
+ * Default per-element override map — eleven entries covering the markdown
204
+ * tags the zudo-doc convention overrides (`h2`, `h3`, `h4`, `p`, `a`,
205
+ * `strong`, `blockquote`, `ul`, `ol`, `table`, `code`).
206
+ *
207
+ * `h1` is intentionally absent: page titles render from frontmatter, per
208
+ * the zudo-doc convention.
209
+ *
210
+ * Spread into a `components` prop to compose with custom overrides:
211
+ *
212
+ * ```tsx
213
+ * import { defaultComponents } from "zfb";
214
+ *
215
+ * <entry.Content components={{ ...defaultComponents, h2: MyFancyH2 }} />
216
+ * ```
217
+ */
218
+ export declare const defaultComponents: {
219
+ readonly h2: typeof ContentH2;
220
+ readonly h3: typeof ContentH3;
221
+ readonly h4: typeof ContentH4;
222
+ readonly p: typeof ContentParagraph;
223
+ readonly a: typeof ContentLink;
224
+ readonly strong: typeof ContentStrong;
225
+ readonly blockquote: typeof ContentBlockquote;
226
+ readonly ul: typeof ContentUl;
227
+ readonly ol: typeof ContentOl;
228
+ readonly table: typeof ContentTable;
229
+ readonly code: typeof ContentCode;
230
+ };
231
+ //# sourceMappingURL=content.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../src/content.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,449 @@
1
+ // `zfb/content` — minimal v0 content collection loader.
2
+ //
3
+ // Reads `*.md` files from a content collection directory, parses YAML
4
+ // frontmatter, and returns typed entries. This is a deliberately small
5
+ // stub so the bundled basic-blog template can call `getCollection("blog")` today;
6
+ // the production path lives in `crates/zfb-content` and will replace this
7
+ // once the JS-runtime decision (ADR-001) lands and the renderer wires the
8
+ // Rust pipeline back through to user code.
9
+ //
10
+ // Scope (v0):
11
+ // - YAML-ish frontmatter only: `key: value`, plus `key:\n - item` arrays.
12
+ // Quoted strings are unwrapped. ISO dates stay as strings.
13
+ // - Body is the post content **after** the closing `---`, returned as raw
14
+ // text. This is intentionally NOT pre-rendered HTML: the markdown
15
+ // pipeline lives in the Rust crate and the JS stub does not duplicate
16
+ // it.
17
+ // - Collection root is resolved from
18
+ // `process.env.ZFB_CONTENT_ROOT` (set by the dev/build pipeline), or
19
+ // `<cwd>/content` as a fallback for unit tests and direct invocation.
20
+ //
21
+ // TODO(zfb-content): swap this stub for the runtime-provided implementation
22
+ // once the content engine ships end-to-end.
23
+ import { parseFrontmatter } from "./frontmatter.js";
24
+ // Re-export the parser surface so existing `zfb/content` consumers that
25
+ // import `parseFrontmatter` / `ParsedFrontmatter` from the content
26
+ // subpath keep working. The implementation now lives in `./frontmatter.ts`
27
+ // (BCI-3 fs-free subpath) — this re-export is the bridge for callers
28
+ // that have not migrated yet.
29
+ export { parseFrontmatter };
30
+ /**
31
+ * Module-level snapshot slot. `undefined` means "no snapshot installed";
32
+ * `getCollection` falls back to the Node `fs` path. The runtime package
33
+ * sets this once during init and (in dev mode) overwrites it on each
34
+ * rebuild.
35
+ */
36
+ let installedSnapshot;
37
+ /**
38
+ * Register a [`Snapshot`] so [`getCollection`] resolves from memory.
39
+ *
40
+ * Pass `undefined` to clear (used by tests that need to restore the v0
41
+ * filesystem path between runs). Idempotent: the latest call wins.
42
+ */
43
+ export function setContentSnapshot(snapshot) {
44
+ installedSnapshot = snapshot;
45
+ }
46
+ /**
47
+ * Read the currently-installed [`Snapshot`], or `undefined` if none is
48
+ * registered. Exposed mostly for tests; production callers should not
49
+ * need to introspect the bridge state.
50
+ */
51
+ export function getContentSnapshot() {
52
+ return installedSnapshot;
53
+ }
54
+ // Cached node:fs / node:path module references. Populated lazily on first
55
+ // fs-path use (see [`loadNodeModules`]); reused on subsequent calls.
56
+ let cachedNodeFs;
57
+ let cachedNodePath;
58
+ /**
59
+ * Synchronously load `node:fs` and `node:path`, caching the results.
60
+ *
61
+ * The node specifiers are concatenated at runtime (`"node:" + "fs"`) so
62
+ * esbuild's static analyzer cannot follow them — that's the load-bearing
63
+ * detail here, because this module is reachable from browser-bundled
64
+ * island chains via the `@takazudo/zfb` root barrel (see top-of-file note).
65
+ *
66
+ * Uses CommonJS `require` via [`createRequire`] (stable, sync) rather than
67
+ * `await import()` (async, would force `getCollection` async and violate
68
+ * ADR-004). `createRequire` itself is fetched from `node:module` through
69
+ * the same runtime-built specifier pattern.
70
+ *
71
+ * If `createRequire` cannot be obtained at all (i.e. truly running in a
72
+ * browser-shaped runtime — which would mean a misconfigured island
73
+ * bundle), throws so the failure is loud rather than silent.
74
+ */
75
+ function loadNodeModules() {
76
+ if (cachedNodeFs !== undefined && cachedNodePath !== undefined) {
77
+ return { fs: cachedNodeFs, path: cachedNodePath };
78
+ }
79
+ // Runtime-built specifiers: opaque to esbuild's static analyzer.
80
+ const moduleSpecifier = "node:" + "module";
81
+ const fsSpecifier = "node:" + "fs";
82
+ const pathSpecifier = "node:" + "path";
83
+ // Strategy A: prefer the host `require` from a CommonJS context. We
84
+ // probe via `globalThis` and `Function`-built lookup so neither esbuild
85
+ // nor stricter ESM tooling errors out at the lookup site.
86
+ const dynamicGlobal = globalThis;
87
+ let nodeRequire = dynamicGlobal.require;
88
+ // Strategy B: ESM context — synthesize a require via `node:module`'s
89
+ // `createRequire`. Loading `node:module` itself through the same
90
+ // dynamic specifier shields it from esbuild's static walker.
91
+ if (typeof nodeRequire !== "function") {
92
+ // `Function("return require")()` returns the enclosing `require` when
93
+ // the bundler/loader injects one (Node CJS, esbuild default). Falls
94
+ // through if undefined — caught below.
95
+ try {
96
+ nodeRequire = new Function("return typeof require === 'function' ? require : undefined")();
97
+ }
98
+ catch {
99
+ nodeRequire = undefined;
100
+ }
101
+ }
102
+ if (typeof nodeRequire !== "function") {
103
+ // Last resort: synthesize via createRequire. Reaches `node:module`
104
+ // through a dynamic require we have to bootstrap somehow — the only
105
+ // way without a static `import` is `process.getBuiltinModule` (Node
106
+ // 22+) which exposes built-ins synchronously without a require.
107
+ const proc = globalThis.process;
108
+ const getBuiltin = proc?.getBuiltinModule;
109
+ if (typeof getBuiltin === "function") {
110
+ const mod = getBuiltin(moduleSpecifier);
111
+ nodeRequire = mod.createRequire(import.meta.url);
112
+ }
113
+ }
114
+ if (typeof nodeRequire !== "function") {
115
+ throw new Error("zfb/content: cannot load node:fs / node:path — no Node-style require available. " +
116
+ "This module's filesystem path requires a Node runtime; if you see this in a browser " +
117
+ "bundle, the bundler should externalize node:* imports (the islands bundler does so).");
118
+ }
119
+ cachedNodeFs = nodeRequire(fsSpecifier);
120
+ cachedNodePath = nodeRequire(pathSpecifier);
121
+ return { fs: cachedNodeFs, path: cachedNodePath };
122
+ }
123
+ /**
124
+ * Resolve the directory that holds a named content collection. Override
125
+ * via `ZFB_CONTENT_ROOT` so tests / fixtures can point at an arbitrary
126
+ * directory.
127
+ */
128
+ function resolveCollectionDir(name) {
129
+ const { path } = loadNodeModules();
130
+ const envRoot = process.env["ZFB_CONTENT_ROOT"];
131
+ const root = envRoot ? path.resolve(envRoot) : path.resolve(process.cwd(), "content");
132
+ return path.join(root, name);
133
+ }
134
+ /**
135
+ * Build the v0 stub's bridge specifier for an entry. Mirrors the Rust-side
136
+ * convention (`mdx://<collection>/<slug>`) minus the body hash — the JS
137
+ * stub does not compile MDX, so it has no hash to attach. The bridge
138
+ * resolver on the renderer side is responsible for matching either form.
139
+ */
140
+ function buildModuleSpecifier(collection, slug) {
141
+ return `mdx://${collection}/${slug}`;
142
+ }
143
+ /**
144
+ * Build the `Content` component for an entry. Captures `module_specifier`
145
+ * + `body` in the closure so the returned function takes only `props`.
146
+ *
147
+ * The bridge lookup is done lazily on every call (not at entry-construction
148
+ * time) so the renderer can install / swap `globalThis.__zfb.content` at
149
+ * any point before the first render without ordering hazards.
150
+ */
151
+ function buildContentComponent(module_specifier, body) {
152
+ return function Content(props) {
153
+ const bridge = globalThis.__zfb?.content;
154
+ const renderer = bridge?.get(module_specifier);
155
+ if (typeof renderer === "function") {
156
+ // Trust the bridge to return a JSX-element-shaped value — we don't
157
+ // try to validate; both Preact and React JSX runtimes accept any
158
+ // structural `{ type, props, key }` object on either side of the
159
+ // boundary, and the renderer is the source of truth here.
160
+ return renderer(props);
161
+ }
162
+ return renderFallback(body);
163
+ };
164
+ }
165
+ /**
166
+ * Stamp the `constructor: undefined` sentinel on a structural JSX-element
167
+ * shape so `preact-render-to-string` (and Preact's diff path) treat it
168
+ * as a real VNode. Without this, Preact reads `.constructor` as `Object`
169
+ * — the value inherited from the literal — and silently drops the node,
170
+ * which is what produced the empty MDX bodies tracked in zudo-doc#505.
171
+ *
172
+ * Same trick `Island`'s `makeVNode` uses; kept private here so callers
173
+ * keep treating `ContentElement` / `ContentComponentElement` as opaque.
174
+ */
175
+ function stampVNode(shape) {
176
+ shape.constructor = undefined;
177
+ return shape;
178
+ }
179
+ /**
180
+ * Build the structural JSX element returned when the bridge is absent.
181
+ *
182
+ * Shape: `<pre data-zfb-content-fallback>{marker}\n{body}</pre>` — the
183
+ * leading `[zfb fallback render]` marker line is part of the public
184
+ * fallback contract (it's both a visual signal and a grep target). Tests
185
+ * pin both the attribute and the marker line.
186
+ */
187
+ function renderFallback(body) {
188
+ return stampVNode({
189
+ type: "pre",
190
+ props: {
191
+ "data-zfb-content-fallback": "",
192
+ children: `${FALLBACK_MARKER}\n${body}`,
193
+ },
194
+ key: null,
195
+ });
196
+ }
197
+ /** Leading marker line emitted by [`renderFallback`]. Public contract. */
198
+ const FALLBACK_MARKER = "[zfb fallback render]";
199
+ /**
200
+ * Load every `*.md` file in the named collection. Files starting with `.`
201
+ * or that lack a `.md` extension are ignored.
202
+ *
203
+ * **ADR-004 contract: this function is synchronous.** TSX page modules
204
+ * call it from anywhere — top-level, inside a render body, inside a
205
+ * `useMemo` — and SSR completes in a single pass without yielding. The
206
+ * snapshot path returns from memory; the filesystem fallback uses sync
207
+ * `node:fs` APIs so the surface stays unified. (The legacy async
208
+ * implementation was an oversight — the ADR predates it; SSG paths
209
+ * always saw a Promise where ADR-004 says they should see an array,
210
+ * which is why migrations from Astro tripped on `getCollection().filter
211
+ * is not a function`.)
212
+ *
213
+ * @example
214
+ * const posts = getCollection<{ title: string; date: string }>("blog");
215
+ */
216
+ export function getCollection(name) {
217
+ // Snapshot path: installed by `@takazudo/zfb-runtime`'s
218
+ // `createPageRouter` at Worker boot. Worker runtimes have no `fs`, so
219
+ // this branch is the production path under the embedded V8 host.
220
+ if (installedSnapshot !== undefined) {
221
+ const list = installedSnapshot.collections[name] ?? [];
222
+ return list.map((entry) => entryFromSnapshot(entry));
223
+ }
224
+ // Filesystem fallback (v0 path). Used by unit tests and direct Node
225
+ // invocations outside the Worker bundle.
226
+ //
227
+ // BCI-6: traversal is now recursive — subdirectories are walked so a
228
+ // collection rooted at `content/blog/` can contain nested `*.md` files
229
+ // (e.g. `content/blog/2024/hello.md`). Slugs are derived from the
230
+ // relative path so callers get stable, unique identifiers across nesting
231
+ // levels.
232
+ const dir = resolveCollectionDir(name);
233
+ let mdPaths;
234
+ try {
235
+ mdPaths = collectMdFilesSync(dir);
236
+ }
237
+ catch (err) {
238
+ // Guard the `code` access at runtime — a thrown non-`Error` value
239
+ // (rare, but possible) would otherwise crash here. We only swallow
240
+ // a true ENOENT; anything else propagates.
241
+ if (err !== null &&
242
+ typeof err === "object" &&
243
+ "code" in err &&
244
+ err.code === "ENOENT") {
245
+ return [];
246
+ }
247
+ throw err;
248
+ }
249
+ const { fs, path } = loadNodeModules();
250
+ return mdPaths.map((fullPath) => {
251
+ const raw = fs.readFileSync(fullPath, "utf8");
252
+ const { data, body } = parseFrontmatter(raw);
253
+ // Derive a stable slug from the relative path (relative to collection
254
+ // root), stripping the `.md` extension. For top-level files this
255
+ // produces the same value as before; for nested files it produces a
256
+ // path-based slug (e.g. `2024/hello`).
257
+ const rel = path.relative(dir, fullPath);
258
+ const slug = _relPathToSlug(rel);
259
+ const module_specifier = buildModuleSpecifier(name, slug);
260
+ return {
261
+ slug,
262
+ data: data,
263
+ body,
264
+ module_specifier,
265
+ Content: buildContentComponent(module_specifier, body),
266
+ };
267
+ });
268
+ }
269
+ /**
270
+ * Construct a [`CollectionEntry`] from a [`SnapshotEntry`]. The snapshot
271
+ * carries `frontmatter` as a possibly-`null` JSON value (matches the
272
+ * Rust contract for entries with no frontmatter); we normalise `null` /
273
+ * `undefined` to an empty object so consumers' `.data.title` reads
274
+ * never have to deal with `null`.
275
+ *
276
+ * **Type-safety note:** `T` is the caller-supplied frontmatter shape
277
+ * but we do **not** validate it at runtime — if the page declares a
278
+ * shape that the actual frontmatter doesn't match, the cast below
279
+ * lies. Callers are expected to keep their `getCollection<MySchema>()`
280
+ * generic in sync with the actual frontmatter; we acknowledge the
281
+ * unsafety with the explicit `unknown` indirection rather than a
282
+ * direct (and silently lossy) cast.
283
+ */
284
+ function entryFromSnapshot(entry) {
285
+ const data = entry.frontmatter === null || entry.frontmatter === undefined
286
+ ? {}
287
+ : entry.frontmatter;
288
+ return {
289
+ slug: entry.slug,
290
+ data,
291
+ body: entry.body,
292
+ module_specifier: entry.module_specifier,
293
+ Content: buildContentComponent(entry.module_specifier, entry.body),
294
+ };
295
+ }
296
+ /**
297
+ * Recursively collect every `*.md` file under `dir` (synchronous).
298
+ *
299
+ * BCI-6: replaces the old flat `readdir(dir).filter(n => n.endsWith(".md"))`
300
+ * approach. Hidden files (names starting with `.`) and hidden directories
301
+ * are skipped at every nesting level, matching the top-level behaviour of
302
+ * the previous implementation.
303
+ *
304
+ * Returns absolute paths sorted lexicographically so the result order is
305
+ * deterministic across platforms and Node versions.
306
+ *
307
+ * Synchronous to honour ADR-004 — see [`getCollection`].
308
+ */
309
+ function collectMdFilesSync(dir) {
310
+ const result = [];
311
+ const { fs, path } = loadNodeModules();
312
+ walkDirSync(fs, path, dir, result);
313
+ result.sort();
314
+ return result;
315
+ }
316
+ function walkDirSync(fs, path, current, out) {
317
+ const entries = fs.readdirSync(current, { withFileTypes: true });
318
+ for (const entry of entries) {
319
+ if (entry.name.startsWith("."))
320
+ continue;
321
+ const fullPath = path.join(current, entry.name);
322
+ // Skip symlinks to avoid infinite loops caused by cycles (e.g. a symlink
323
+ // pointing at a parent directory). Content files are expected to be plain
324
+ // regular files; following symlinks provides no value here.
325
+ if (entry.isSymbolicLink())
326
+ continue;
327
+ if (entry.isDirectory()) {
328
+ walkDirSync(fs, path, fullPath, out);
329
+ }
330
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
331
+ out.push(fullPath);
332
+ }
333
+ }
334
+ }
335
+ /**
336
+ * @internal
337
+ *
338
+ * Convert a `path.relative()` result into a forward-slash-separated
339
+ * slug with the trailing `.md` extension stripped.
340
+ *
341
+ * Slugs are URL-flavored identifiers, not filesystem paths — they
342
+ * MUST use `/` regardless of the host OS so a nested entry like
343
+ * `2024/hello.md` produces the slug `2024/hello` on both POSIX and
344
+ * Windows. Without this normalisation, Windows callers would see
345
+ * `2024\hello`, which then leaks through to `module_specifier` and
346
+ * any URL the consumer derives from the slug.
347
+ *
348
+ * Exported solely so the unit test suite can pin the Windows
349
+ * behaviour without needing an actual Windows host. Do not depend on
350
+ * this from application code — name and signature may change.
351
+ */
352
+ export function _relPathToSlug(relPath) {
353
+ const { path } = loadNodeModules();
354
+ const posix = path.sep === "/" ? relPath : relPath.split(path.sep).join("/");
355
+ // Some Node versions normalise `\` even when sep is `/`, so be
356
+ // defensive: collapse any straggling backslashes too.
357
+ const normalised = posix.includes("\\") ? posix.split("\\").join("/") : posix;
358
+ return normalised.endsWith(".md") ? normalised.slice(0, -".md".length) : normalised;
359
+ }
360
+ /** Internal helper: build a structural JSX element of the given tag. */
361
+ function buildOverrideElement(tag, props) {
362
+ const { children, ...rest } = props;
363
+ // `stampVNode` sets `constructor: undefined` so Preact recognises the
364
+ // returned object as a VNode rather than foreign data — see the helper's
365
+ // docblock for context (zudo-doc#505).
366
+ return stampVNode({
367
+ type: tag,
368
+ props: { ...rest, children },
369
+ key: null,
370
+ });
371
+ }
372
+ /**
373
+ * `<h2>` passthrough override. Ported from zudo-doc's `HeadingH2`, stripped
374
+ * of styling — v0 ships pass-through behaviour; visual treatment is layered
375
+ * on by the consumer (or by a follow-up enhancement pass).
376
+ */
377
+ export function ContentH2(props) {
378
+ return buildOverrideElement("h2", props);
379
+ }
380
+ /** `<h3>` passthrough override. See [`ContentH2`] for the contract. */
381
+ export function ContentH3(props) {
382
+ return buildOverrideElement("h3", props);
383
+ }
384
+ /** `<h4>` passthrough override. See [`ContentH2`] for the contract. */
385
+ export function ContentH4(props) {
386
+ return buildOverrideElement("h4", props);
387
+ }
388
+ /** `<p>` passthrough override. Mirrors zudo-doc's `ContentParagraph`. */
389
+ export function ContentParagraph(props) {
390
+ return buildOverrideElement("p", props);
391
+ }
392
+ /** `<a>` passthrough override. Mirrors zudo-doc's `ContentLink`. */
393
+ export function ContentLink(props) {
394
+ return buildOverrideElement("a", props);
395
+ }
396
+ /** `<strong>` passthrough override. Mirrors zudo-doc's `ContentStrong`. */
397
+ export function ContentStrong(props) {
398
+ return buildOverrideElement("strong", props);
399
+ }
400
+ /** `<blockquote>` passthrough override. Mirrors zudo-doc's `ContentBlockquote`. */
401
+ export function ContentBlockquote(props) {
402
+ return buildOverrideElement("blockquote", props);
403
+ }
404
+ /** `<ul>` passthrough override. Mirrors zudo-doc's `ContentUl`. */
405
+ export function ContentUl(props) {
406
+ return buildOverrideElement("ul", props);
407
+ }
408
+ /** `<ol>` passthrough override. Mirrors zudo-doc's `ContentOl`. */
409
+ export function ContentOl(props) {
410
+ return buildOverrideElement("ol", props);
411
+ }
412
+ /** `<table>` passthrough override. Mirrors zudo-doc's `ContentTable`. */
413
+ export function ContentTable(props) {
414
+ return buildOverrideElement("table", props);
415
+ }
416
+ /** `<code>` passthrough override. Mirrors zudo-doc's `ContentCode`. */
417
+ export function ContentCode(props) {
418
+ return buildOverrideElement("code", props);
419
+ }
420
+ /**
421
+ * Default per-element override map — eleven entries covering the markdown
422
+ * tags the zudo-doc convention overrides (`h2`, `h3`, `h4`, `p`, `a`,
423
+ * `strong`, `blockquote`, `ul`, `ol`, `table`, `code`).
424
+ *
425
+ * `h1` is intentionally absent: page titles render from frontmatter, per
426
+ * the zudo-doc convention.
427
+ *
428
+ * Spread into a `components` prop to compose with custom overrides:
429
+ *
430
+ * ```tsx
431
+ * import { defaultComponents } from "zfb";
432
+ *
433
+ * <entry.Content components={{ ...defaultComponents, h2: MyFancyH2 }} />
434
+ * ```
435
+ */
436
+ export const defaultComponents = {
437
+ h2: ContentH2,
438
+ h3: ContentH3,
439
+ h4: ContentH4,
440
+ p: ContentParagraph,
441
+ a: ContentLink,
442
+ strong: ContentStrong,
443
+ blockquote: ContentBlockquote,
444
+ ul: ContentUl,
445
+ ol: ContentOl,
446
+ table: ContentTable,
447
+ code: ContentCode,
448
+ };
449
+ //# sourceMappingURL=content.js.map