@takazudo/zfb 0.1.0-next.9 → 0.1.0-next.91

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 (50) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +81 -23
  3. package/bin/detect-musl.mjs +55 -0
  4. package/bin/zfb.mjs +67 -14
  5. package/dist/client-script.d.ts +54 -0
  6. package/dist/client-script.js +67 -0
  7. package/dist/client-script.js.map +1 -0
  8. package/dist/config.d.ts +682 -30
  9. package/dist/config.js +43 -3
  10. package/dist/config.js.map +1 -1
  11. package/dist/content.d.ts +43 -2
  12. package/dist/content.js +101 -27
  13. package/dist/content.js.map +1 -1
  14. package/dist/frontmatter.d.ts +0 -1
  15. package/dist/frontmatter.js.map +1 -1
  16. package/dist/index.d.ts +6 -3
  17. package/dist/index.js +6 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/island.d.ts +10 -1
  20. package/dist/island.js +67 -28
  21. package/dist/island.js.map +1 -1
  22. package/dist/jsx-types.d.ts +16 -7
  23. package/dist/jsx-types.js.map +1 -1
  24. package/dist/paginate.d.ts +0 -1
  25. package/dist/paginate.js.map +1 -1
  26. package/dist/plugins.d.ts +140 -22
  27. package/dist/plugins.js.map +1 -1
  28. package/dist/runtime.d.ts +17 -3
  29. package/dist/runtime.js +279 -30
  30. package/dist/runtime.js.map +1 -1
  31. package/dist/slugify.d.ts +68 -0
  32. package/dist/slugify.js +197 -0
  33. package/dist/slugify.js.map +1 -0
  34. package/dist/types.d.ts +4 -2
  35. package/dist/types.js +2 -2
  36. package/dist/types.js.map +1 -1
  37. package/dist/wasm.d.ts +4 -0
  38. package/dist/wasm.js +2 -0
  39. package/dist/wasm.js.map +1 -0
  40. package/package.json +22 -7
  41. package/dist/config.d.ts.map +0 -1
  42. package/dist/content.d.ts.map +0 -1
  43. package/dist/frontmatter.d.ts.map +0 -1
  44. package/dist/index.d.ts.map +0 -1
  45. package/dist/island.d.ts.map +0 -1
  46. package/dist/jsx-types.d.ts.map +0 -1
  47. package/dist/paginate.d.ts.map +0 -1
  48. package/dist/plugins.d.ts.map +0 -1
  49. package/dist/runtime.d.ts.map +0 -1
  50. package/dist/types.d.ts.map +0 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  > **Newer releases:** see https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/ for v0.1.0-next.5 and later. Entries below are historical (kept for npm readers).
4
4
 
5
+ ## Unreleased
6
+
7
+ ### New features
8
+
9
+ **`VNode`, `VNodeArray`, `VNodeObject` exported from `"@takazudo/zfb"`** (#972):
10
+
11
+ The structural JSX-node types are now part of the public API:
12
+
13
+ ```ts
14
+ import type { VNode, VNodeArray, VNodeObject } from "@takazudo/zfb";
15
+ ```
16
+
17
+ `VNode` now includes a bare `object` member (matching Preact's own `ComponentChild` design), making Preact's `ComponentChildren`, `VNode<Props>`, `JSX.Element`, and `JSX.Element[]` all assignable at `Island` input boundaries (`children` and `ssrFallback`) with zero `as unknown as` casts.
18
+
19
+ **Name-collision caveat for Preact consumers:** if a consumer file already has `import { VNode } from "preact"`, use a qualified import to avoid the clash:
20
+
21
+ ```ts
22
+ import type { VNode as ZfbVNode } from "@takazudo/zfb";
23
+ ```
24
+
25
+ ### Breaking changes (pre-1.0)
26
+
27
+ **`linkValidation.allowExternal` removed** (#925):
28
+
29
+ The `allowExternal` config knob has been removed. It was accepted but never did anything — external URL network validation is out of scope. Migration: delete `allowExternal` from your `linkValidation` config; external URLs continue to be silently skipped (unchanged runtime behaviour).
30
+
5
31
  ## 0.1.0-next.4
6
32
 
7
33
  ### Bug fixes
package/README.md CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  The public SDK module for [zfb][zfb-site]: islands, content collections,
6
6
  pagination, config, plugins, and frontmatter helpers. User pages reach this
7
- package through the bare specifier `"zfb"` — the `zfb-render` runtime loader
8
- registers the source under that name at build time so user TSX can write:
7
+ package through the bare specifier `"zfb"` — the build pipeline aliases that
8
+ specifier to `@takazudo/zfb` so user TSX can write:
9
9
 
10
10
  ```tsx
11
11
  import { Island } from "zfb";
@@ -29,16 +29,22 @@ npm install @takazudo/zfb
29
29
  This package is the canonical TypeScript source for the `zfb` SDK
30
30
  surface. Today it covers:
31
31
 
32
- - `<Island when="visible|idle|load">` — JSX wrapper that marks a region
33
- for client-side hydration.
32
+ - `<Island when="visible|idle|load|media" media="...">` — JSX wrapper
33
+ that marks a region for client-side hydration. Passing `ssrFallback`
34
+ switches to the SSR-skip marker (`data-zfb-island-skip-ssr`).
34
35
  - `scheduleHydrate(target, when, fire)` — the runtime branching helper
35
36
  consumed by the hydration runtime.
37
+ - `mountIslands(manifest)`, `mountNewIslands()`,
38
+ `cancelPendingIslands()`, and `unmountIslands(...)` — public island
39
+ lifecycle helpers used by the generated islands runtime and by the
40
+ client router after body swaps.
36
41
  - `When`, `WHEN_VALUES`, `DEFAULT_WHEN`, `isWhen`, `resolveWhen` — type
37
- and runtime utilities pinning the spelling of the three modes.
38
- - `getCollection(name)`, `parseFrontmatter(raw)` — content collection
39
- helpers exported from `zfb/content`. `parseFrontmatter` is part of the
40
- public SDK surface so consumers can write custom content loaders that
41
- reuse the v0 frontmatter parser without re-implementing it.
42
+ and runtime utilities pinning the spelling of the four modes.
43
+ - `getCollection(name)`, `getEntry(name, slug)`, and
44
+ `parseFrontmatter(raw)` content collection helpers exported from
45
+ `zfb/content`. `parseFrontmatter` is part of the public SDK surface so
46
+ consumers can write custom content loaders that reuse the v0
47
+ frontmatter parser without re-implementing it.
42
48
  - `defaultComponents` — eleven-entry per-element override map (`h2`, `h3`,
43
49
  `h4`, `p`, `a`, `strong`, `blockquote`, `ul`, `ol`, `table`, `code`)
44
50
  ported from zudo-doc's `htmlOverrides` convention. **`h1` is deliberately
@@ -53,12 +59,23 @@ surface. Today it covers:
53
59
 
54
60
  <entry.Content components={{ ...defaultComponents, h2: MyFancyH2 }} />
55
61
  ```
62
+ - `mergeMdxComponents(globalSlot, perCall)` — precedence merge helper for
63
+ the MDX component map (`defaultComponents` < global slot < per-call
64
+ overrides).
56
65
  - `paginate(items, opts)`, plus `PaginatedPage<T>` / `PaginateRoute<T>` —
57
66
  exported from `zfb/paginate`.
58
67
  - `defineConfig(config)` — exported from `zfb/config` for the
59
68
  `zfb.config.ts` form (the recommended way to author a zfb project's
60
69
  configuration; the back-compat `zfb.config.json` form is still
61
70
  supported).
71
+ - `definePlugin(plugin)` — identity helper exported from `zfb/plugins`
72
+ and the root barrel so plugin authors get typed lifecycle hooks without
73
+ changing runtime behavior.
74
+ - `clientScript(name)` — SSR helper that returns the stable URL for a
75
+ named client-script asset.
76
+ - `slugify(input)` and `SlugAllocator` — exported from the root barrel and
77
+ the `zfb/slugify` subpath for heading-id parity with the Rust content
78
+ pipeline.
62
79
 
63
80
  The package is JSX-runtime-agnostic: the `Island` component does not
64
81
  import preact or react, so it works under either framework adapter
@@ -89,35 +106,61 @@ export default function Page() {
89
106
  <Island when="visible">
90
107
  <Counter />
91
108
  </Island>
109
+
110
+ {/* Hydrate when a CSS media query first matches. */}
111
+ <Island when="media" media="(max-width: 720px)">
112
+ <Counter />
113
+ </Island>
114
+
115
+ {/* Skip SSR for the heavy child and render a placeholder instead. */}
116
+ <Island when="idle" ssrFallback={<div>Loading…</div>}>
117
+ <Counter />
118
+ </Island>
92
119
  </>
93
120
  );
94
121
  }
95
122
  ```
96
123
 
97
- ## The three `when=` modes
124
+ ## The four `when=` modes
98
125
 
99
- | `when` | Trigger | Fallback |
100
- | ----------- | --------------------------------------------------------------- | ---------------------------------------------- |
101
- | `"load"` | Synchronous, immediate fire after registration. **Default.** | n/a |
102
- | `"idle"` | `requestIdleCallback` | `setTimeout(0)` when not available |
103
- | `"visible"` | `IntersectionObserver`, threshold 0, first intersection only | Immediate fire when `IntersectionObserver` is missing |
126
+ | `when` | Trigger | Fallback |
127
+ | ----------- | -------------------------------------------------------------- | -------------------------------------------------- |
128
+ | `"load"` | Synchronous, immediate fire after registration. **Default.** | n/a |
129
+ | `"idle"` | `requestIdleCallback` | `setTimeout(0)` when not available |
130
+ | `"visible"` | `IntersectionObserver`, threshold 0, first intersection only | Immediate fire when `IntersectionObserver` is missing |
131
+ | `"media"` | `matchMedia(media)`, first matching change only | Immediate fire when `matchMedia` or `media` is missing |
104
132
 
105
133
  Unknown values produce a `console.warn` in development builds and fall
106
134
  back to `"load"`.
107
135
 
136
+ `when="media"` requires a `media` prop containing a CSS media query
137
+ string. Supplying `media` with any other `when` value is ignored and
138
+ warns in development builds.
139
+
108
140
  ## Build-time output
109
141
 
110
- The wrapper is intentionally type-erased at the JSX boundary. At the
111
- call site, `<Island when="visible">{children}</Island>` renders as:
142
+ The wrapper is intentionally type-erased at the JSX boundary. It reads
143
+ the wrapped child's JSX type identity (`displayName`, then `name`, then
144
+ host tag name) and writes the component name immediately. At the call
145
+ site, `<Island when="visible"><Counter count={1} /></Island>` renders as:
112
146
 
113
147
  ```html
114
- <div data-zfb-island data-when="visible"><!-- children --></div>
148
+ <div data-zfb-island="Counter" data-when="visible" data-props='{"count":1}'>
149
+ <!-- rendered child output -->
150
+ </div>
115
151
  ```
116
152
 
117
- The `data-zfb-island` attribute is empty here. The hydration emit step
118
- in the `zfb-render` runtime walks rendered HTML and replaces it with
119
- `data-zfb-island="ComponentName"` so the client-side hydration runtime
120
- can look up the right module to call.
153
+ `data-props` carries the wrapped child's serializable own props across
154
+ the SSR-to-hydration boundary. The wrapper omits `children`, omits the
155
+ attribute entirely when there is no useful props payload, and the runtime
156
+ falls back to `{}` when the attribute is missing or malformed.
157
+
158
+ When `ssrFallback` is supplied, the heavy child is not rendered at SSR
159
+ time. The wrapper writes `data-zfb-island-skip-ssr="ComponentName"`
160
+ instead of `data-zfb-island`, still includes `data-when` / `data-media`
161
+ and `data-props` when applicable, and renders the fallback markup inside
162
+ the wrapper. The client runtime treats that marker as a render target
163
+ rather than a hydration target.
121
164
 
122
165
  ## Runtime helper
123
166
 
@@ -125,7 +168,13 @@ The hydration runtime imports (or inlines) `scheduleHydrate` from this
125
168
  package:
126
169
 
127
170
  ```ts
128
- import { scheduleHydrate } from "@takazudo/zfb/runtime";
171
+ import {
172
+ scheduleHydrate,
173
+ mountIslands,
174
+ mountNewIslands,
175
+ cancelPendingIslands,
176
+ unmountIslands,
177
+ } from "@takazudo/zfb/runtime";
129
178
 
130
179
  for (const el of document.querySelectorAll<HTMLElement>("[data-zfb-island]")) {
131
180
  const when = el.getAttribute("data-when") ?? "load";
@@ -137,6 +186,15 @@ for (const el of document.querySelectorAll<HTMLElement>("[data-zfb-island]")) {
137
186
  if hydration has not fired yet. After firing, calling `cancel` is a
138
187
  no-op.
139
188
 
189
+ `mountIslands(manifest)` captures the generated island manifest and
190
+ mounts both hydrated markers (`data-zfb-island`) and SSR-skip markers
191
+ (`data-zfb-island-skip-ssr`). `mountNewIslands()` re-walks the current
192
+ document after a client-router body swap using that captured manifest.
193
+ `cancelPendingIslands()` cancels deferred `idle` / `visible` / `media`
194
+ schedules before a swap, and `unmountIslands(root, incomingBody)` runs
195
+ framework cleanup for discarded islands while preserving matching
196
+ `data-zfb-transition-persist` islands.
197
+
140
198
  ## Markdown / GFM config
141
199
 
142
200
  `ZfbConfig.markdown.gfm` controls which GitHub-Flavored-Markdown
@@ -0,0 +1,55 @@
1
+ // Extracted from zfb.mjs so the detection decision can be unit-tested
2
+ // without triggering zfb.mjs's top-level exec/spawn side effects. Pure and
3
+ // dependency-injectable: production callers can omit every option.
4
+ import { readdirSync } from "node:fs";
5
+
6
+ const MUSL_LOADER_DIR = "/lib";
7
+ const MUSL_LOADER_PREFIX = "ld-musl-";
8
+
9
+ // True if a musl dynamic loader file is present (e.g. Alpine's
10
+ // /lib/ld-musl-x86_64.so.1). Reading the directory and matching the prefix
11
+ // covers every arch's loader filename with one probe instead of hardcoding
12
+ // an existsSync path per arch. Mirrors the loader-file probe used by the
13
+ // widely-adopted `detect-libc` npm package.
14
+ function hasMuslLoaderFile(readdirSyncFn) {
15
+ try {
16
+ return readdirSyncFn(MUSL_LOADER_DIR).some((name) => name.startsWith(MUSL_LOADER_PREFIX));
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function defaultGetReport() {
23
+ return process.report?.getReport?.();
24
+ }
25
+
26
+ // True if Node's process.report exposes a glibc runtime version. glibc-built
27
+ // Node populates header.glibcVersionRuntime; musl-built Node does not. Used
28
+ // only as a fallback signal when the loader-file probe above is inconclusive.
29
+ function hasGlibcVersionRuntime(getReportFn) {
30
+ try {
31
+ return Boolean(getReportFn()?.header?.glibcVersionRuntime);
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Detects musl libc (e.g. Alpine) on Linux, where the gnu-built platform
39
+ * binary would otherwise fail with an opaque dynamic-loader exec error
40
+ * instead of a clear "not supported" message. Always returns false on
41
+ * non-linux platforms (darwin, win32) — zero behavior change there.
42
+ */
43
+ export function detectMuslLinux({
44
+ platform = process.platform,
45
+ readdirSyncFn = readdirSync,
46
+ getReportFn = defaultGetReport,
47
+ } = {}) {
48
+ if (platform !== "linux") return false;
49
+ // The glibc runtime report is authoritative when present: a Node process
50
+ // that reports a glibc version is definitely glibc-linked and will work,
51
+ // regardless of an unrelated musl loader file sitting on the filesystem
52
+ // (e.g. a `musl` package installed on Debian/Ubuntu for other reasons).
53
+ if (hasGlibcVersionRuntime(getReportFn)) return false;
54
+ return hasMuslLoaderFile(readdirSyncFn);
55
+ }
package/bin/zfb.mjs CHANGED
@@ -1,10 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // Followed biome's pattern: pure os/cpu lookup → resolve platform package → spawn binary.
3
3
  // See: https://github.com/biomejs/biome (packages/js/biome/bin/biome.mjs reference)
4
+ // Divergence from biome: async spawn + signal forwarding instead of spawnSync.
5
+ // biome is a short-lived CLI, but `zfb dev` is a long-running server — without
6
+ // forwarding, SIGTERM to the wrapper orphans the native binary (PPID 1) and
7
+ // leaves the dev-server port bound (issue #873).
4
8
  import { existsSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
9
+ import { spawn } from "node:child_process";
6
10
  import { createRequire } from "node:module";
11
+ import { constants as osConstants } from "node:os";
7
12
  import { join } from "node:path";
13
+ import { detectMuslLinux } from "./detect-musl.mjs";
8
14
 
9
15
  const require = createRequire(import.meta.url);
10
16
 
@@ -27,6 +33,18 @@ if (!pkg) {
27
33
  process.exit(1);
28
34
  }
29
35
 
36
+ // The gnu-built binary above matches on os/cpu but not on libc: an
37
+ // Alpine/musl-libc Linux install passes the os/cpu check yet would still
38
+ // fail to exec, with a raw, confusing dynamic-loader error. Detect and fail
39
+ // with a clear message before attempting to resolve/spawn the binary.
40
+ if (detectMuslLinux()) {
41
+ console.error(
42
+ "[zfb] musl/Alpine is not supported yet — no @takazudo/zfb-linux-*-musl package exists.\n" +
43
+ " Use a glibc-based Linux image (e.g. Debian/Ubuntu) instead of Alpine.",
44
+ );
45
+ process.exit(1);
46
+ }
47
+
30
48
  let binPath;
31
49
  try {
32
50
  // Resolve the platform package's package.json to get the install directory.
@@ -57,26 +75,61 @@ if (!existsSync(binPath)) {
57
75
  process.exit(1);
58
76
  }
59
77
 
60
- const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
78
+ const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit" });
61
79
 
62
- // Surface spawn errors that spawnSync stores in result.error rather than
63
- // propagating to stderr. Without this, a 0644 binary (EACCES) is silently
64
- // swallowed and the process exits with code 1 and no message — making it
65
- // impossible for the user to diagnose a corrupted/incomplete npm install.
66
- // (Issue #447 / #441 pnpm publish strips the executable bit.)
67
- if (result.error) {
68
- if (result.error.code === "EACCES") {
80
+ // Forward termination signals to the child. Supervisors (concurrently
81
+ // --kill-others, Playwright webServer teardown, timeout(1), CI runners)
82
+ // signal only the wrapper; without forwarding, the native server survives
83
+ // with PPID 1 and keeps its port bound. Terminal Ctrl+C already signals the
84
+ // whole foreground group, so the child may receive SIGINT twice — the native
85
+ // binary treats repeated signals as the same shutdown request.
86
+ // SIGHUP is POSIX-only: on Windows child.kill("SIGHUP") throws (libuv only
87
+ // emulates SIGINT/SIGTERM/SIGKILL/SIGQUIT), and the OS tears down console
88
+ // processes on window close anyway.
89
+ const forwardedSignals =
90
+ process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
91
+ const signalForwarders = new Map();
92
+ for (const sig of forwardedSignals) {
93
+ const forward = () => child.kill(sig);
94
+ signalForwarders.set(sig, forward);
95
+ process.on(sig, forward);
96
+ }
97
+
98
+ // Surface spawn failures clearly. Without this, a 0644 binary (EACCES) is
99
+ // silently swallowed and the process exits with code 1 and no message —
100
+ // making it impossible for the user to diagnose a corrupted/incomplete npm
101
+ // install. (Issue #447 / #441 — pnpm publish strips the executable bit.)
102
+ child.on("error", (error) => {
103
+ if (error.code === "EACCES") {
69
104
  process.stderr.write(
70
105
  `[zfb] binary is not executable; was the install corrupt?\n` +
71
106
  ` ${binPath}\n` +
72
107
  ` Try reinstalling: npm install --include=optional\n`,
73
108
  );
74
109
  } else {
75
- process.stderr.write(
76
- `[zfb] failed to spawn binary: ${result.error.message}\n` + ` ${binPath}\n`,
77
- );
110
+ process.stderr.write(`[zfb] failed to spawn binary: ${error.message}\n` + ` ${binPath}\n`);
78
111
  }
79
112
  process.exit(1);
80
- }
113
+ });
81
114
 
82
- process.exit(result.status ?? 1);
115
+ child.on("exit", (code, signal) => {
116
+ if (signal) {
117
+ // Re-raise the child's termination signal on ourselves so the caller
118
+ // sees the real cause of death (e.g. WIFSIGNALED), not a plain exit code.
119
+ // Remove our forwarding listener first or it would swallow the re-raise.
120
+ const forward = signalForwarders.get(signal);
121
+ if (forward) {
122
+ process.off(signal, forward);
123
+ }
124
+ try {
125
+ process.kill(process.pid, signal);
126
+ } catch {
127
+ // Signal cannot be re-raised on this platform (Windows emulation).
128
+ }
129
+ // Reached only if the re-raised signal did not terminate us — fall back
130
+ // to the shell's 128+n convention for death-by-signal.
131
+ const signum = osConstants.signals[signal];
132
+ process.exit(signum ? 128 + signum : 1);
133
+ }
134
+ process.exit(code ?? 1);
135
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SSR helper for referencing a client-script asset URL in a page or layout.
3
+ *
4
+ * ## Usage
5
+ *
6
+ * ```tsx
7
+ * import { clientScript } from "@takazudo/zfb";
8
+ *
9
+ * export default function MyPage() {
10
+ * return (
11
+ * <html>
12
+ * <head>
13
+ * <script type="module" src={clientScript("search-widget")} />
14
+ * </head>
15
+ * <body>…</body>
16
+ * </html>
17
+ * );
18
+ * }
19
+ * ```
20
+ *
21
+ * ## How it works
22
+ *
23
+ * `clientScript(name)` returns the **stable URL** for the named client-script
24
+ * entry: `${base}/assets/client/<name>.js`. The production build pipeline
25
+ * (`ProductionAssetPipeline`) rewrites every occurrence of this stable URL in
26
+ * rendered HTML to the content-hashed equivalent
27
+ * (`/assets/client/<name>-<hash>.js`), so the hash never needs to be known at
28
+ * SSR time.
29
+ *
30
+ * The `base` prefix is read from `globalThis.__zfb?.base` (emitted by the
31
+ * bundler when at least one `*.client.*` file exists in the project). For
32
+ * root-mounted sites (`base` is absent or `"/"`), the prefix is the empty
33
+ * string and `clientScript("search-widget")` returns
34
+ * `"/assets/client/search-widget.js"`. For a sub-path deploy
35
+ * (`base="/foo/"`), it returns `"/foo/assets/client/search-widget.js"`.
36
+ *
37
+ * ## SSR-only note (v1)
38
+ *
39
+ * This is an SSR-time helper. Calling it in browser-executed code works but
40
+ * the base prefix (`globalThis.__zfb.base`) is currently not shipped to the
41
+ * browser — `clientScript()` will return the unprefixed stable URL
42
+ * (`/assets/client/<name>.js`) in that context. For the common use-case of
43
+ * rendering a `<script src>` tag at SSR time this is not a problem.
44
+ */
45
+ /**
46
+ * Returns the base-prefixed stable public URL for the named client-script entry.
47
+ *
48
+ * `name` is the entry name (file stem minus `.client`, e.g. `"search-widget"`
49
+ * for `search-widget.client.ts`).
50
+ *
51
+ * The returned URL is `${base}/assets/client/<name>.js`. The production
52
+ * pipeline rewrites it to the hashed URL; dev serves the stable URL directly.
53
+ */
54
+ export declare function clientScript(name: string): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * SSR helper for referencing a client-script asset URL in a page or layout.
3
+ *
4
+ * ## Usage
5
+ *
6
+ * ```tsx
7
+ * import { clientScript } from "@takazudo/zfb";
8
+ *
9
+ * export default function MyPage() {
10
+ * return (
11
+ * <html>
12
+ * <head>
13
+ * <script type="module" src={clientScript("search-widget")} />
14
+ * </head>
15
+ * <body>…</body>
16
+ * </html>
17
+ * );
18
+ * }
19
+ * ```
20
+ *
21
+ * ## How it works
22
+ *
23
+ * `clientScript(name)` returns the **stable URL** for the named client-script
24
+ * entry: `${base}/assets/client/<name>.js`. The production build pipeline
25
+ * (`ProductionAssetPipeline`) rewrites every occurrence of this stable URL in
26
+ * rendered HTML to the content-hashed equivalent
27
+ * (`/assets/client/<name>-<hash>.js`), so the hash never needs to be known at
28
+ * SSR time.
29
+ *
30
+ * The `base` prefix is read from `globalThis.__zfb?.base` (emitted by the
31
+ * bundler when at least one `*.client.*` file exists in the project). For
32
+ * root-mounted sites (`base` is absent or `"/"`), the prefix is the empty
33
+ * string and `clientScript("search-widget")` returns
34
+ * `"/assets/client/search-widget.js"`. For a sub-path deploy
35
+ * (`base="/foo/"`), it returns `"/foo/assets/client/search-widget.js"`.
36
+ *
37
+ * ## SSR-only note (v1)
38
+ *
39
+ * This is an SSR-time helper. Calling it in browser-executed code works but
40
+ * the base prefix (`globalThis.__zfb.base`) is currently not shipped to the
41
+ * browser — `clientScript()` will return the unprefixed stable URL
42
+ * (`/assets/client/<name>.js`) in that context. For the common use-case of
43
+ * rendering a `<script src>` tag at SSR time this is not a problem.
44
+ */
45
+ /** Stable public-URL prefix for client-script entries, matching the Rust constant. */
46
+ const CLIENT_SCRIPTS_URL_PREFIX = "/assets/client/";
47
+ /**
48
+ * Returns the base-prefixed stable public URL for the named client-script entry.
49
+ *
50
+ * `name` is the entry name (file stem minus `.client`, e.g. `"search-widget"`
51
+ * for `search-widget.client.ts`).
52
+ *
53
+ * The returned URL is `${base}/assets/client/<name>.js`. The production
54
+ * pipeline rewrites it to the hashed URL; dev serves the stable URL directly.
55
+ */
56
+ export function clientScript(name) {
57
+ // `globalThis.__zfb?.base` is set to the resolved base prefix by the
58
+ // bundler when the project has at least one `*.client.*` entry (#978).
59
+ // For root-mounted or no-base builds the value is `""`, yielding
60
+ // `/assets/client/<name>.js` as expected. The `?? ""` fallback handles
61
+ // the edge case where __zfb.base was not emitted (zero-script project,
62
+ // or a browser context where the slot was never populated).
63
+ const base = globalThis.__zfb;
64
+ const prefix = base?.base ?? "";
65
+ return `${prefix}${CLIENT_SCRIPTS_URL_PREFIX}${name}.js`;
66
+ }
67
+ //# sourceMappingURL=client-script.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-script.js","sourceRoot":"","sources":["../src/client-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,sFAAsF;AACtF,MAAM,yBAAyB,GAAG,iBAAiB,CAAC;AAEpD;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,qEAAqE;IACrE,uEAAuE;IACvE,iEAAiE;IACjE,uEAAuE;IACvE,uEAAuE;IACvE,4DAA4D;IAC5D,MAAM,IAAI,GAAI,UAAsC,CAAC,KAAsC,CAAC;IAC5F,MAAM,MAAM,GAAG,IAAI,EAAE,IAAI,IAAI,EAAE,CAAC;IAChC,OAAO,GAAG,MAAM,GAAG,yBAAyB,GAAG,IAAI,KAAK,CAAC;AAC3D,CAAC","sourcesContent":["/**\n * SSR helper for referencing a client-script asset URL in a page or layout.\n *\n * ## Usage\n *\n * ```tsx\n * import { clientScript } from \"@takazudo/zfb\";\n *\n * export default function MyPage() {\n * return (\n * <html>\n * <head>\n * <script type=\"module\" src={clientScript(\"search-widget\")} />\n * </head>\n * <body>…</body>\n * </html>\n * );\n * }\n * ```\n *\n * ## How it works\n *\n * `clientScript(name)` returns the **stable URL** for the named client-script\n * entry: `${base}/assets/client/<name>.js`. The production build pipeline\n * (`ProductionAssetPipeline`) rewrites every occurrence of this stable URL in\n * rendered HTML to the content-hashed equivalent\n * (`/assets/client/<name>-<hash>.js`), so the hash never needs to be known at\n * SSR time.\n *\n * The `base` prefix is read from `globalThis.__zfb?.base` (emitted by the\n * bundler when at least one `*.client.*` file exists in the project). For\n * root-mounted sites (`base` is absent or `\"/\"`), the prefix is the empty\n * string and `clientScript(\"search-widget\")` returns\n * `\"/assets/client/search-widget.js\"`. For a sub-path deploy\n * (`base=\"/foo/\"`), it returns `\"/foo/assets/client/search-widget.js\"`.\n *\n * ## SSR-only note (v1)\n *\n * This is an SSR-time helper. Calling it in browser-executed code works but\n * the base prefix (`globalThis.__zfb.base`) is currently not shipped to the\n * browser — `clientScript()` will return the unprefixed stable URL\n * (`/assets/client/<name>.js`) in that context. For the common use-case of\n * rendering a `<script src>` tag at SSR time this is not a problem.\n */\n\n/** Stable public-URL prefix for client-script entries, matching the Rust constant. */\nconst CLIENT_SCRIPTS_URL_PREFIX = \"/assets/client/\";\n\n/**\n * Returns the base-prefixed stable public URL for the named client-script entry.\n *\n * `name` is the entry name (file stem minus `.client`, e.g. `\"search-widget\"`\n * for `search-widget.client.ts`).\n *\n * The returned URL is `${base}/assets/client/<name>.js`. The production\n * pipeline rewrites it to the hashed URL; dev serves the stable URL directly.\n */\nexport function clientScript(name: string): string {\n // `globalThis.__zfb?.base` is set to the resolved base prefix by the\n // bundler when the project has at least one `*.client.*` entry (#978).\n // For root-mounted or no-base builds the value is `\"\"`, yielding\n // `/assets/client/<name>.js` as expected. The `?? \"\"` fallback handles\n // the edge case where __zfb.base was not emitted (zero-script project,\n // or a browser context where the slot was never populated).\n const base = (globalThis as Record<string, unknown>).__zfb as { base?: string } | undefined;\n const prefix = base?.base ?? \"\";\n return `${prefix}${CLIENT_SCRIPTS_URL_PREFIX}${name}.js`;\n}\n"]}