@stratal/inertia 0.0.27 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +158 -14
  3. package/dist/build-seo-tags-DBsHKxX9.mjs.map +1 -1
  4. package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
  5. package/dist/generator/type-generator.worker.d.mts +1 -1
  6. package/dist/generator/type-generator.worker.mjs +1 -1
  7. package/dist/index.d.mts +214 -92
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +390 -130
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/quarry.d.mts +6 -8
  12. package/dist/quarry.d.mts.map +1 -1
  13. package/dist/quarry.mjs +127 -12
  14. package/dist/quarry.mjs.map +1 -1
  15. package/dist/react/access.d.mts +95 -0
  16. package/dist/react/access.d.mts.map +1 -0
  17. package/dist/react/access.mjs +133 -0
  18. package/dist/react/access.mjs.map +1 -0
  19. package/dist/react-dom-server-legacy-stub.d.mts +20 -0
  20. package/dist/react-dom-server-legacy-stub.d.mts.map +1 -0
  21. package/dist/react-dom-server-legacy-stub.mjs +25 -0
  22. package/dist/react-dom-server-legacy-stub.mjs.map +1 -0
  23. package/dist/react.d.mts +4 -6
  24. package/dist/react.d.mts.map +1 -1
  25. package/dist/react.mjs +1 -1
  26. package/dist/react.mjs.map +1 -1
  27. package/dist/seo-runtime.d.mts +1 -1
  28. package/dist/seo-runtime.mjs +8 -6
  29. package/dist/seo-runtime.mjs.map +1 -1
  30. package/dist/services/ssr-exclusion.d.mts +38 -0
  31. package/dist/services/ssr-exclusion.d.mts.map +1 -0
  32. package/dist/services/ssr-exclusion.mjs +0 -0
  33. package/dist/services/ssr-exclusion.mjs.map +1 -0
  34. package/dist/ssr.d.mts +37 -8
  35. package/dist/ssr.d.mts.map +1 -1
  36. package/dist/ssr.mjs +7 -4
  37. package/dist/ssr.mjs.map +1 -1
  38. package/dist/testing.d.mts +3 -2
  39. package/dist/testing.d.mts.map +1 -1
  40. package/dist/testing.mjs +20 -6
  41. package/dist/testing.mjs.map +1 -1
  42. package/dist/{type-generator-DFpha_Fp.mjs → type-generator-BVw8mj1y.mjs} +373 -64
  43. package/dist/type-generator-BVw8mj1y.mjs.map +1 -0
  44. package/dist/types-BltKoOR7.d.mts +193 -0
  45. package/dist/types-BltKoOR7.d.mts.map +1 -0
  46. package/dist/types-D-j_Ee_h.d.mts +52 -0
  47. package/dist/types-D-j_Ee_h.d.mts.map +1 -0
  48. package/dist/types-DzE1pdZs.d.mts.map +1 -1
  49. package/dist/vite.d.mts +19 -6
  50. package/dist/vite.d.mts.map +1 -1
  51. package/dist/vite.mjs +67 -5
  52. package/dist/vite.mjs.map +1 -1
  53. package/package.json +38 -27
  54. package/dist/type-generator-DFpha_Fp.mjs.map +0 -1
  55. package/dist/types-BhgXhWx6.d.mts +0 -82
  56. package/dist/types-BhgXhWx6.d.mts.map +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,359 @@
1
+ # @stratal/inertia
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a753e55: Add build-time SSR exclusion, client-side access control and `ctx.scroll()` for infinite scroll, and make Inertia pages cacheable.
8
+
9
+ ### Build-time SSR exclusion
10
+ - Add `ssrExclude` to the `stratalInertia()` Vite plugin. Client-only pages and their heavy dependencies were previously always bundled into the worker, because the SSR page glob pulled in every page; disabling SSR at runtime skipped rendering but still shipped the code.
11
+
12
+ ```typescript
13
+ stratalInertia({ ssrExclude: ["Admin/**", "Reports/Heavy"] });
14
+ ```
15
+
16
+ Patterns are matched against the page name, where `*` is a single segment and `**` any number. Excluded pages are dropped from the worker bundle and rendered client-only, while the browser bundle still includes them so they hydrate normally.
17
+
18
+ - Apply `ssrExclude` to an array-form page glob such as `import.meta.glob(['./pages/**/*.tsx', '!...'])`, keeping the negative patterns it already had. Only the single-string form worked before, so an array-form resolver silently kept every page in the worker bundle. A glob that cannot be rewritten now emits a build warning naming the file.
19
+ - Rewrite `import.meta.glob` resolvers that pass a second argument, such as `{ eager: true }`, preserving those options.
20
+
21
+ ### Client-side access control
22
+ - Add the `<Can>`, `<Cannot>`, `<HasRole>` and `<HasNoRole>` components plus the `useCan`, `useRole` and `useAccess` hooks, on a new `@stratal/inertia/react/access` entry. They are gated on permissions the server shares automatically once `accessControl` is configured, and permission strings and role names are type-checked against a generated registry.
23
+
24
+ ### Infinite scroll
25
+
26
+ Add **`ctx.scroll(callback, options?)`** for Inertia v3 infinite scroll, which makes `@inertiajs/react`'s `<InfiniteScroll>` work against a Stratal route. Until now the page carried no scroll metadata at all and the component threw before rendering.
27
+
28
+ ```typescript
29
+ return ctx.inertia("notes/Index", {
30
+ notes: ctx.scroll(() => this.service.paginate(page), { matchOn: "id" }),
31
+ });
32
+ ```
33
+
34
+ - The identifiers are derived; there is nothing to restate. Two shapes are recognised directly: the offset shape of `paginatedResponseSchema`, and `@stratal/framework`'s `db.$cursor` result. Any other shape throws `UnrecognizedScrollShapeError` rather than guessing, because a wrong next page reads to the client as "no more pages" and silently truncates the list. Pass `metadata` to name the identifiers for a third-party shape.
35
+ - The prop value keeps its paginator shape and only the rows under `wrapper` (default `data`) accumulate. Options are `wrapper`, `matchOn`, `pageName` and `metadata`.
36
+ - **`matchOn` has never deduplicated anything.** Entries were emitted in a form the client resolved to no prop, so every merge fell back to plain concatenation. A row that changes between two pages of a merged list is now collapsed instead of appearing twice.
37
+ - **`X-Inertia-Reset` is now honoured.** The header was parsed and discarded, so a prop the client named in it was joined to rather than replaced.
38
+ - Adds the `assertInertiaScrollProp(prop, expected?)` assertion and exports `UnrecognizedScrollShapeError` alongside the scroll option and metadata types.
39
+
40
+ ### Caching
41
+ - Cache partial reloads, and with them every `ctx.defer()` prop, by declaring the Inertia protocol headers in `Vary` on every response. Deferred props are delivered by a follow-up partial reload, and those were refused outright, so a page that defers its expensive work kept all of that work uncached and caching bought close to nothing. Adds the `INERTIA_VARY_HEADERS` export naming the set. **`Vary` now lists these names on every Inertia response**, where it previously listed only `X-Inertia`, so anything asserting on that exact header value needs updating.
42
+ - Skip caching for pages that cannot be shared between callers: a page carrying flash data or a `once()` prop is not cached. On a cache hit the SSR render is skipped entirely, so a cached page costs no render.
43
+ - Narrow into a nested prop on a partial reload instead of answering with the whole of its parent. `only: ['auth.user']` asks for one field of `auth`; sending all of `auth` is the payload the partial reload was made to avoid. Prop metadata now names every entry by its full path, so a `defer()` nested under another prop is advertised where the client will look for it.
44
+
45
+ ### Server rendering and dev runtime
46
+ - Add a **`prepare(page)`** hook to `createInertiaSsrApp`, which runs once per `render(page)` call and hands its result to `setup` as `prepared`. It exists so a request-scoped value can reach the tree without a module-level variable — a Workers isolate serves many requests concurrently, so module-level "current request" state is a cross-request leak. Omit it and `prepared` is `undefined`, which the type now enforces.
47
+ - Recycle the dev worker when its memory reaches a threshold, fixing frequent dev-server crashes in large apps. Under sustained HMR the dev isolate's heap grows until it hits the V8 limit and the worker aborts, which the browser shows as "Fetch failed". `quarry inertia:dev` now keeps the dev server alive, with a default threshold of 900 MB configurable through `--heap-limit=<MB>`. Supervision runs on macOS and Linux; elsewhere it is disabled with a warning.
48
+ - Strip react-dom's unused legacy synchronous server renderer from the worker SSR bundle, dropping around 197 KB raw from a minimal app. SSR is streaming-only, so `renderToString` and `renderToStaticMarkup` are not available in the worker.
49
+ - Fix every SSR page returning a 500 with `ReferenceError: require is not defined` or `module is not defined` under the Workers dev and SSR runtime. React 19's server entry, `react-dom/client`, the ORM data layer and the email renderer all reach CommonJS through packages excluded from Vite's optimizer, so their conditional `require` reached the worker runtime unconverted. An app that happened to import `react-dom` elsewhere was unaffected, while a minimal app failed on every request.
50
+ - Fix a guest SSR render failing at app init with `createPoolFactory is not a function` under a linked or portal checkout.
51
+ - Export `DocumentRendererService`, which renders a built `Page` into an HTML document and owns the single decision between streaming SSR and a client-only shell. `InertiaService` and `@stratal/inertia-modal` both delegate to it, so anything rendering an Inertia document outside those paths should inject the token rather than duplicate the branch.
52
+
53
+ ### Fixes
54
+ - Answer a version mismatch with a real 409 instead of a 500. The mismatch branch set a status and headers but returned no response, so configuring `version` turned every stale client into a server error rather than the reload the check exists to trigger.
55
+ - Send the current asset version on that response, so the client can tell "this client is out of date" apart from an ordinary external redirect. The cancelable `location` event now reports `versionChange: true`, and async visits are left alone instead of reloading the page underneath a background request; both were previously unreachable.
56
+ - Reconcile the client head on a visit that only changed the props of the component already on screen. Closing a modal is exactly that shape, so the head previously kept the level's title while the address had moved back to the page's.
57
+ - Type the shared page props of an app that registers Inertia from a config namespace. `inertia:types` read `sharedData` and `accessControl` out of `src/app.module.ts` alone, and only as a literal, so an app composing its modules elsewhere or passing `config.asProvider()` had every shared prop reach pages as `{}` and access control never resolve. Both are now read wherever the registration lives, and a provider argument is followed back to the factory it came from.
58
+ - Pick up `ctx.modal()` calls in the type generator the same way as `ctx.inertia()`. If you hand-wrote prop types for a modal page, remove them and let the generated type be the only source.
59
+ - Fix two type-generator bugs that gave page props the wrong types: `ctx.share()` calls were not detected at all, and shared props wrapped in `always()`, `defer()`, `optional()`, `merge()` or `once()` were typed as the wrapper instead of the value it resolves to.
60
+ - Stop inlining the full i18n message-key union into page-prop types, which can shrink generated declaration files by an order of magnitude on apps with large key sets. Nullable and optional key unions no longer defeat detection, and props covering the full key set reference `MessageKeys` from `stratal/i18n`.
61
+ - Add `SeoService.contributed()`, which reports whether anything has called `ctx.seo()` on this request — what a caller rendering one page over another needs in order to keep the underlying page's metadata instead of overwriting it with the defaults.
62
+ - Add `InertiaService.resolveProps()` and `partialRequestFor()`, so a caller assembling its own page can resolve props with the same semantics `render()` applies.
63
+
64
+ ### Breaking Changes
65
+ - **`ssr.disabled` is removed** from `InertiaModule.forRoot({ ssr })`. Replace it with the Vite plugin's `ssrExclude`, which both skips SSR and drops the excluded pages from the worker bundle: `stratalInertia({ ssrExclude: ['Admin/**'] })`.
66
+ - **`ctx.withoutSsr()` and the `withoutSsr` context variable are removed.** SSR exclusion is now build-time and declarative, so there is no per-request runtime opt-out.
67
+ - **`Vary` now lists every Inertia protocol header on every response**, not just `X-Inertia`. Update anything asserting on that exact value.
68
+ - **The validation API is `zod/mini`.** The `z` re-export is gone from the validation surface this package re-exports. Import schema builders directly from `zod/mini` using named imports and replace classic chaining with the functional API: `z.string().min(1).optional()` becomes `optional(string().check(minLength(1)))`.
69
+ - **OpenAPI documents are generated lazily**, on the first request to the docs endpoint. `OpenAPIService.getSpec()` becomes `getSpec(container)` and is async, and `routeFilter` is now a metadata predicate `(route: RouteSchemaMeta) => boolean` instead of `(path, pathItem)`.
70
+
71
+ ### Patch Changes
72
+
73
+ - Updated dependencies [a753e55]
74
+ - Updated dependencies [a753e55]
75
+ - stratal@0.1.0
76
+ - @stratal/testing@0.1.0
77
+
78
+ ## 0.0.27
79
+
80
+ ### Patch Changes
81
+
82
+ - Updated dependencies [41a9140]
83
+ - stratal@0.0.27
84
+ - @stratal/testing@0.0.27
85
+
86
+ ## 0.0.26
87
+
88
+ ### Patch Changes
89
+
90
+ - ab95f52: Fix flash cookie encoding crashing on non-Latin1 characters
91
+
92
+ ### Details
93
+ - Flash cookies are now encoded with UTF-8-safe base64 — `btoa` alone threw on any character outside Latin1 (em-dashes, smart quotes, non-Latin scripts), which are routine in user-facing flash messages
94
+
95
+ - bb6d3b9: Trailing-slash exclusions: `trailingSlash` accepts `{ mode, exclude }`
96
+
97
+ ### Details
98
+ - `trailingSlash` application config now accepts `{ mode, exclude }` alongside a bare mode. Excluded paths are never redirected (308) and never rewritten by URL generation — for routes whose canonical form is owned externally (e.g. OAuth redirect URIs matched byte-for-byte).
99
+ - String patterns are segment-aware prefixes; RegExp patterns match both slash forms of the pathname regardless of anchoring.
100
+ - Exclusions match in route space: with path-based locale detection, a leading locale segment is stripped before matching, so `'/callback'` also exempts `/fr/callback` — in the redirect middleware, `Uri` helpers, and hreflang link generation.
101
+ - `@stratal/inertia` threads the widened config through hreflang URL generation and shares only the resolved mode with the React client (exclusions are server-side; excluded paths are served in both slash forms, so client-built URLs never redirect).
102
+ - New exports from `stratal/router`: `resolveTrailingSlash`, `isTrailingSlashExcluded`, and the `TrailingSlashConfig` / `TrailingSlashOptions` / `TrailingSlashExclude` types.
103
+
104
+ - Updated dependencies [ab95f52]
105
+ - Updated dependencies [ab95f52]
106
+ - Updated dependencies [bb6d3b9]
107
+ - stratal@0.0.26
108
+ - @stratal/testing@0.0.26
109
+
110
+ ## 0.0.25
111
+
112
+ ### Patch Changes
113
+
114
+ - e93db60: Add `--inspector-port` option to `inertia:dev` for configuring the worker debugger inspector port
115
+
116
+ Set a distinct port per worker to avoid `EADDRINUSE` when running multiple Inertia workers concurrently, or pass `false` to disable the inspector entirely.
117
+
118
+ - Updated dependencies [e93db60]
119
+ - stratal@0.0.25
120
+ - @stratal/testing@0.0.25
121
+
122
+ ## 0.0.24
123
+
124
+ ### Patch Changes
125
+
126
+ - 10cf223: Stream server-side rendering with React 19 for faster TTFB and progressive Suspense rendering
127
+
128
+ The document shell (SEO + CSS) now flushes immediately while the app body streams, and `React.lazy`/`Suspense` boundaries stream in progressively instead of blocking the whole response. A new `createInertiaSsrApp` helper from `@stratal/inertia/ssr` wires this up for you. `quarry inertia:install` scaffolds an `src/inertia/ssr.tsx` using it.
129
+
130
+ `createInertiaSsrApp` is generic over your page props — call `createInertiaSsrApp<MyProps>({ … })` to type the resolver, or omit the type argument to keep the `import.meta.glob` resolver opaque (the default). A downstream cancellation (client disconnect) now propagates to the React render, and an invalid resolver result throws instead of rendering nothing.
131
+
132
+ Also fixes `ssr.disabled` glob matching, which previously compared against the full URL and so missed routes carrying a query string (e.g. `admin/*` vs `/admin/dashboard?tab=users`); it now matches the pathname only. Rerunning `quarry inertia:install` on an existing install now wires the SSR bundle into the current `InertiaModule.forRoot({ … })` instead of leaving SSR silently disabled.
133
+
134
+ ### Breaking Changes
135
+ - The SSR bundle now returns a stream, and there is no longer a silent client-side fallback — if SSR fails to load or render, the error surfaces (500) instead of degrading silently.
136
+ - Migrate your `src/inertia/ssr.tsx` to use the new helper:
137
+
138
+ ```tsx
139
+ import { createInertiaSsrApp } from "@stratal/inertia/ssr";
140
+
141
+ export const { render } = createInertiaSsrApp({
142
+ resolve: async (name) => {
143
+ const pages = import.meta.glob("./pages/**/*.tsx");
144
+ const page = await pages[`./pages/${name}.tsx`]?.();
145
+ if (!page) throw new Error(`Page not found: ${name}`);
146
+ return page;
147
+ },
148
+ });
149
+ ```
150
+
151
+ Replace the previous `createInertiaApp` + `renderToString` setup, which returned `{ head, body }`. App-level providers go in the optional `setup` callback. Document metadata should come from server-side `ctx.seo()` — a `<Head>` inside a suspended boundary is not captured during streaming.
152
+
153
+ - stratal@0.0.24
154
+ - @stratal/testing@0.0.24
155
+
156
+ ## 0.0.23
157
+
158
+ ### Patch Changes
159
+
160
+ - 13b0e8d: Add `@stratal/feature-flags` — Cloudflare Flagship feature flags via the native Worker binding API.
161
+ - `FeatureFlagModule.forRoot({ apps: [{ binding, flags }], default, context })` with a declare-once flag manifest, manifest defaults, a per-request evaluation-context resolver, and multi-app support via `FeatureFlagService.use(binding)`.
162
+ - `FeatureFlagShareMiddleware` shares evaluated flags to Inertia pages as the `featureFlags` prop; register it yourself (scoped to page controllers via `router.middleware(...)` or app-wide via `router.use(...)`) so a stalled Flagship binding can't block unrelated routes. Typed `useFlag` / `useFeatureFlags` hooks on `@stratal/feature-flags/react`. No runtime dependency on `@stratal/inertia`.
163
+ - `@stratal/inertia`: expose a generic `ctx.share(key, value)` macro on `RouterContext` so middleware and packages can contribute per-request shared props.
164
+ - `@stratal/framework`: add a `ctx.user()` macro on `RouterContext` (shorthand for `AuthContext.requireUser()`).
165
+
166
+ - 13b0e8d: Fix correctness and security issues found in review.
167
+
168
+ Queue:
169
+ - Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job.
170
+ - Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured).
171
+ - Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly.
172
+ - `queue:retry --all` / `queue:purge --all --queue` collect matching keys before deleting, so cursor pagination no longer skips jobs; `queue:failed --queue --limit` now counts matching jobs rather than scanned keys.
173
+ - Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent.
174
+
175
+ Email (SMTP):
176
+ - Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector).
177
+ - Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected.
178
+ - AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords.
179
+ - Add a response timeout so a hung SMTP server can't wedge the worker; QUIT/socket close are now best-effort and never mask a successful send.
180
+ - MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync).
181
+
182
+ Inertia SEO:
183
+ - `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally.
184
+ - Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output.
185
+ - Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes).
186
+
187
+ Feature flags:
188
+ - `FeatureFlagService.use()` binds the target app exactly once.
189
+
190
+ Database (framework):
191
+ - The reentrant `$transaction` proxy forwards the receiver for non-transaction property access.
192
+
193
+ Testing:
194
+ - `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match.
195
+
196
+ DI:
197
+ - Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly.
198
+ - Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack.
199
+ - `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`.
200
+ - Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary.
201
+
202
+ Quarry dev runtime:
203
+ - Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env.<env>.local` into `process.env` for full parity.
204
+ - The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface.
205
+
206
+ - 13b0e8d: Add backend-driven SEO metadata management with hreflang and automatic client-side head synchronization
207
+ - Configure app-wide SEO defaults and a title template via `InertiaModule.forRoot({ seo: { ... } })`.
208
+ - Set per-page metadata from controllers or middleware with `ctx.seo({ ... })` — title, description, Open Graph, Twitter card, canonical URL, and arbitrary meta/link tags.
209
+ - Locale alternates (`rel="alternate" hreflang="…"`) are generated automatically for path-prefixed and querystring locale strategies and merged into the rendered tags.
210
+ - Server-rendered SEO tags are kept in sync with the document head across SPA navigations automatically — no app wiring required.
211
+ - New `useSeo()` React hook to read the resolved SEO data in components.
212
+ - New `@stratal/inertia/seo` entry point exporting SEO types and tag-building utilities.
213
+ - Fix: error responses for idempotent GET/HEAD navigations (e.g. deferred partial reloads) now render in place instead of using flash + redirect, preventing redirect loops.
214
+
215
+ - Updated dependencies [13b0e8d]
216
+ - Updated dependencies [13b0e8d]
217
+ - Updated dependencies [13b0e8d]
218
+ - Updated dependencies [13b0e8d]
219
+ - Updated dependencies [13b0e8d]
220
+ - Updated dependencies [13b0e8d]
221
+ - Updated dependencies [13b0e8d]
222
+ - Updated dependencies [13b0e8d]
223
+ - Updated dependencies [13b0e8d]
224
+ - Updated dependencies [13b0e8d]
225
+ - Updated dependencies [13b0e8d]
226
+ - Updated dependencies [be813bc]
227
+ - Updated dependencies [be813bc]
228
+ - @stratal/testing@0.0.23
229
+ - stratal@0.0.23
230
+
231
+ ## 0.0.22
232
+
233
+ ### Patch Changes
234
+
235
+ - 1658945: Add `createClientViteConfig` helper, client manifest injection, sourcemap option, and `InertiaQuarryModule` for CLI integration
236
+ - New `createClientViteConfig()` produces a ready-made Vite config for the client bundle with automatic reflect-metadata invocation for tsyringe compatibility.
237
+ - Inertia build command now injects the client manifest into the SSR bundle for asset resolution.
238
+ - Type generator enhanced to extract controller page prop types with promise unwrapping.
239
+ - New `@stratal/inertia/quarry` export provides `InertiaQuarryModule` for registering Inertia CLI commands.
240
+
241
+ - 4b273ea: Replace @intlify/core-base with intl-messageformat in `useI18n` hook, add eager deferred prop resolution, and remove tsyringe/reflect-metadata dependencies
242
+ - `useI18n()` now uses `intl-messageformat` for ICU message formatting. The hook API is unchanged.
243
+ - New `x-inertia-resolve-deferred` request header causes all deferred props to be resolved eagerly in the response, skipping client-side lazy loading.
244
+ - The `invokeReflectMetadataBeforeTsyringeCheck` Vite plugin is removed (no longer needed).
245
+ - `reflect-metadata` and `@intlify/core-base` are no longer peer dependencies.
246
+
247
+ - Updated dependencies [1658945]
248
+ - Updated dependencies [1658945]
249
+ - Updated dependencies [4b273ea]
250
+ - Updated dependencies [4b273ea]
251
+ - @stratal/testing@0.0.22
252
+ - stratal@0.0.22
253
+
254
+ ## 0.0.21
255
+
256
+ ### Patch Changes
257
+
258
+ - 3489cfd: Dedupe React and Inertia in the Vite resolver to prevent duplicate-copy bugs
259
+
260
+ `stratalInertia()` now adds the React ecosystem (`react`, `react-dom`, `react-is`, `scheduler`, `use-sync-external-store`) and `@inertiajs/core` / `@inertiajs/react` to `resolve.dedupe` and `resolve.noExternal`. React 19's main entry is CJS and must run through the optimizer, but when Vite re-runs optimization after auto-discovering a new dep it would mint a second `?v=<hash>` copy, breaking React identity (`Invalid hook call`, dispatcher mismatch). Forcing a single physical copy through `dedupe`/`noExternal` keeps hooks, contexts, and Inertia internals working across re-optimizations.
261
+
262
+ - 3489cfd: Run Inertia type generation in a worker thread and cache dev CSS per HMR cycle
263
+ - The Vite types plugin now offloads `runTypeGeneration` to a debounced (250ms) worker via `node:worker_threads`, so HMR no longer blocks on ts-morph parsing. A second edit while a worker is in flight queues exactly one follow-up run, and the dispatcher is torn down on `closeBundle`.
264
+ - `writeInertiaTypes` skips the write when the on-disk content already matches and otherwise writes via a temp-file rename, so the file is never observed half-written.
265
+ - `stratalInertiaDevCss` caches the collected SSR CSS and invalidates it on CSS-module HMR, eliminating duplicate scans when the SSR endpoint and the virtual module are both requested.
266
+ - Component names with `-`, `_`, or whitespace now PascalCase correctly when forming `<Name>PageProps` (e.g. `user-profile/edit` → `UserProfileEditPageProps`).
267
+
268
+ - Updated dependencies [3489cfd]
269
+ - Updated dependencies [3489cfd]
270
+ - Updated dependencies [3489cfd]
271
+ - stratal@0.0.21
272
+ - @stratal/testing@0.0.21
273
+
274
+ ## 0.0.20
275
+
276
+ ### Patch Changes
277
+
278
+ - f8c61e1: Expose the matched route on `useRoute()` and apply trailing-slash + sticky params
279
+
280
+ The `routes` Inertia shared prop now also carries a `route` snapshot for the current request (`{ name, params, defaults }`) and the application's `trailingSlash` mode, enabling several `useRoute()` enhancements:
281
+ - `currentRoute` is returned alongside `route` and `current`, so components can read the matched route name and params directly (e.g. `currentRoute.params.id`).
282
+ - `current(name)` now accepts dotted wildcard patterns derived from real route names (e.g. `current('users.*')`), strictly typed against `StratalRouteMap`.
283
+ - `route(name, params)` merges sticky defaults from `Uri.defaults()` and any current-route params declared by the target route, so values like `tenantId` carry over without the caller passing them. Explicit params still win.
284
+ - Generated URLs respect the server's `trailingSlash` mode.
285
+ - Catch-all path params (e.g. `:slug{.+}`) preserve forward slashes when encoded, matching the server-side behavior.
286
+
287
+ Also exports `resolveUrl`, `matchCurrent`, and `applyTrailingSlash` as pure helpers for non-React callers and tests.
288
+
289
+ - f8c61e1: Skip response mutation for non-cloneable status codes
290
+
291
+ The Inertia middleware would crash with a `RangeError` when the downstream handler returned a response whose status fell outside `200-599` (e.g. WebSocket upgrades using `101`, or `Response.error()`'s status `0`), because adding the `Vary` header forces Hono to re-construct the `Response` and the constructor rejects those statuses. The middleware now passes such responses through untouched. The `302 → 303` rewrite for non-GET/HEAD Inertia requests is now scoped to only run when the status is exactly `302`.
292
+
293
+ - f8c61e1: Loosen peer dependency ranges for broader compatibility
294
+
295
+ Peer dependencies (`@inertiajs/*`, `hono`, `react`, `react-dom`, `vite`, `vitest`, `@intlify/core-base`, `reflect-metadata`, `stratal`) now use `>=` ranges instead of pinned `^` ranges, so apps can adopt newer majors of these packages without waiting for a coordinated bump.
296
+
297
+ - f8c61e1: Exclude `hono`, `stratal`, and Hono OpenAPI plugins from Vite pre-bundling
298
+
299
+ `stratalInertia()` now adds `stratal`, `hono`, `@hono/zod-openapi`, and `@hono/swagger-ui` to `optimizeDeps.exclude`. Pre-bundling those packages produced duplicate copies in `.vite/deps_<env>/`, so Response objects from one instance flowed into a Hono Context from the other and crashed inside the `set res` setter (`this.#res.headers.entries is not a function`). Excluding them keeps a single shared instance.
300
+
301
+ - Updated dependencies [f8c61e1]
302
+ - Updated dependencies [f8c61e1]
303
+ - Updated dependencies [f8c61e1]
304
+ - Updated dependencies [f8c61e1]
305
+ - Updated dependencies [f8c61e1]
306
+ - stratal@0.0.20
307
+ - @stratal/testing@0.0.20
308
+
309
+ ## 0.0.19
310
+
311
+ ### Patch Changes
312
+
313
+ - 5d26c24: Add `--persist-to` option to `inertia:dev` for shared emulator state
314
+
315
+ The `inertia:dev` command now accepts a `--persist-to=<dir>` flag that is forwarded to `@cloudflare/vite-plugin` as `persistState.path`. This lets multiple workers running in development share the same R2, KV, and cache emulator state.
316
+
317
+ - Updated dependencies [3b16f5b]
318
+ - Updated dependencies [5d26c24]
319
+ - Updated dependencies [3b16f5b]
320
+ - Updated dependencies [3b16f5b]
321
+ - Updated dependencies [3b16f5b]
322
+ - Updated dependencies [5d26c24]
323
+ - Updated dependencies [5d26c24]
324
+ - Updated dependencies [3b16f5b]
325
+ - stratal@0.0.19
326
+ - @stratal/testing@0.0.19
327
+
328
+ ## 0.0.18
329
+
330
+ ### Patch Changes
331
+
332
+ - c9176ea: Add precognition support, i18n integration, flash messages, React hooks, and testing utilities
333
+
334
+ ### Details
335
+ - Add precognition middleware for form validation without full submission
336
+ - Add i18n integration with automatic locale and translation sharing to Inertia pages
337
+ - Add flash message support via cookie-based flash store
338
+ - Add `useRoute` and `useI18n` React hooks (`@stratal/inertia/react`)
339
+ - Add `@stratal/inertia/testing` subpath with TestResponse assertion augments for Inertia responses
340
+ - Enhance Vite configuration with Cloudflare Vite plugin support
341
+
342
+ - 17f8675: Add Inertia.js v3 server adapter for building server-driven React SPAs with Stratal
343
+
344
+ ### Details
345
+ - `InertiaModule` with `forRoot()` / `forRootAsync()` configuration
346
+ - `InertiaService` for rendering pages with shared data, deferred props, and partial reload support
347
+ - `@InertiaRoute()` decorator for Inertia-specific controller routes
348
+ - Inertia middleware for handling `X-Inertia` protocol (version checking, 409 conflict responses)
349
+ - Vite integration with dev CSS injection and automatic type generation plugins
350
+ - SSR rendering support via `@inertiajs/react/server`
351
+ - Quarry CLI commands: `inertia:dev`, `inertia:build`, `inertia:install`, `inertia:types`
352
+
353
+ - Updated dependencies [fcb71c4]
354
+ - Updated dependencies [17f8675]
355
+ - Updated dependencies [c9176ea]
356
+ - Updated dependencies [c9176ea]
357
+ - Updated dependencies [c9176ea]
358
+ - stratal@0.0.18
359
+ - @stratal/testing@0.0.18
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @stratal/inertia
2
2
 
3
- Inertia.js v3 server adapter for [Stratal](https://github.com/strataljs/stratal) framework — build server-driven React SPAs on Cloudflare Workers.
3
+ Inertia.js v3 server adapter for [Stratal](https://stratal.dev) — build server-driven React SPAs on Cloudflare Workers.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@stratal/inertia)](https://www.npmjs.com/package/@stratal/inertia)
6
6
  [![CI](https://github.com/strataljs/stratal/actions/workflows/ci.yml/badge.svg)](https://github.com/strataljs/stratal/actions/workflows/ci.yml)
@@ -16,11 +16,17 @@ Inertia.js v3 server adapter for [Stratal](https://github.com/strataljs/stratal)
16
16
  ## Features
17
17
 
18
18
  - **InertiaModule** — Drop-in Stratal module with `forRoot()` / `forRootAsync()` configuration
19
- - **Streaming SSR** — React 19 `renderToReadableStream` streaming via `createInertiaSsrApp`, with configurable per-route disabling
20
- - **Shared Data** — Global shared props with static values or request-scoped resolvers
21
- - **@InertiaRoute Decorator** — Convention-based Inertia page routes with auto-applied response schema
22
- - **Partial Reloads** — Optional, deferred, and merge props for efficient data loading
23
- - **Quarry CLI Commands** — `inertia:install`, `inertia:dev`, `inertia:build`, and `inertia:types`
19
+ - **Streaming SSR** — React 19 `renderToReadableStream` streaming via `createInertiaSsrApp`
20
+ - **Shared Data** — Global shared props with static values or request-scoped resolvers, plus `ctx.share()` from middleware
21
+ - **Route Decorators** — `@InertiaRoute` and `@InertiaGet` / `@InertiaPost` / `@InertiaPut` / `@InertiaPatch` / `@InertiaDelete`
22
+ - **Partial Reloads** — `defer`, `optional`, `merge`, `once`, `always` and `scroll` props
23
+ - **Backend-driven SEO** — `ctx.seo()` with app-wide defaults and a title template; tags are injected into `<head>` and kept in sync across client navigations
24
+ - **i18n Sharing** — Auto-share backend messages as `locale` + `translations`, read with `useI18n()`
25
+ - **Named Routes** — Serialize named routes to the client for Ziggy-like URL generation with `useRoute()`
26
+ - **Flash Messages** — `ctx.flash()` with a pluggable store (cookie store included)
27
+ - **Vite Plugin** — `stratalInertia()` handles dev/build wiring, asset manifests, and SSR page exclusion
28
+ - **Quarry CLI** — `inertia:install`, `inertia:dev`, `inertia:build`, and `inertia:types`
29
+ - **Test Assertions** — `assertInertia()` and friends via `@stratal/inertia/testing`
24
30
 
25
31
  ## Installation
26
32
 
@@ -30,6 +36,12 @@ npm install @stratal/inertia
30
36
  yarn add @stratal/inertia
31
37
  ```
32
38
 
39
+ Then scaffold the frontend:
40
+
41
+ ```bash
42
+ npx quarry inertia:install
43
+ ```
44
+
33
45
  ### AI Agent Skills
34
46
 
35
47
  Stratal provides [Agent Skills](https://agentskills.io) for AI coding assistants like Claude Code and Cursor. Install to give your AI agent knowledge of Stratal patterns, conventions, and APIs:
@@ -40,7 +52,7 @@ npx skills add strataljs/stratal
40
52
 
41
53
  | Skill | Description |
42
54
  |---|---|
43
- | `stratal` | Build Cloudflare Workers apps with the Stratal framework — modules, DI, controllers, routing, OpenAPI, queues, cron, events, seeders, CLI, auth, database, RBAC, testing, and more |
55
+ | `stratal` | Build Cloudflare Workers apps with the Stratal framework — modules, DI, controllers, routing, OpenAPI, queues, cron, events, seeders, CLI, auth, database, access control, testing, and more |
44
56
 
45
57
  ## Quick Start
46
58
 
@@ -67,6 +79,19 @@ class AppModule {}
67
79
  export default new Stratal({ module: AppModule })
68
80
  ```
69
81
 
82
+ `rootView` is the only required option. The rest are optional: `version`, `ssr`, `flash`, `sharedData`, `i18n`, `routes`, `seo` and `entryClientPath` (defaults to `src/inertia/app.tsx`).
83
+
84
+ ### Vite setup
85
+
86
+ ```typescript
87
+ // vite.config.ts
88
+ import { stratalInertia } from '@stratal/inertia/vite'
89
+
90
+ export default defineConfig({
91
+ plugins: [stratalInertia()],
92
+ })
93
+ ```
94
+
70
95
  ### Controller with @InertiaRoute
71
96
 
72
97
  ```typescript
@@ -82,7 +107,85 @@ export class NotesController {
82
107
  }
83
108
  ```
84
109
 
85
- ### Streaming SSR
110
+ ## Props
111
+
112
+ `InertiaModule` augments `RouterContext` with prop helpers that control what is sent and when:
113
+
114
+ ```typescript
115
+ async index(ctx: RouterContext) {
116
+ return ctx.inertia('notes/Index', {
117
+ // Sent on every response
118
+ notes: await this.notes.all(),
119
+
120
+ // Resolved after the initial render, optionally in a named group
121
+ stats: ctx.defer(() => this.notes.stats(), 'sidebar'),
122
+
123
+ // Only when the client explicitly asks for it
124
+ audit: ctx.optional(() => this.notes.audit()),
125
+
126
+ // Merged into existing client-side data instead of replacing it
127
+ feed: ctx.merge(() => this.notes.page(), { matchOn: 'id' }),
128
+
129
+ // Sent once, then cached by the client
130
+ countries: ctx.once(() => this.geo.countries()),
131
+
132
+ // Always evaluated, even on a partial reload
133
+ unread: ctx.always(() => this.notes.unreadCount()),
134
+
135
+ // A merge prop that also publishes what <InfiniteScroll> needs
136
+ items: ctx.scroll(() => this.notes.paginate()),
137
+ })
138
+ }
139
+ ```
140
+
141
+ `ctx.share(key, value)` adds a shared prop for the current request — useful from middleware — and `ctx.flash(key, value)` sets flash data for the next visit.
142
+
143
+ ## SEO
144
+
145
+ Set `seo` on the module for app-wide defaults, then contribute per-page metadata from a controller. The resolved tags are injected into `<head>`, shared as the `seo` prop, and kept in sync across client navigations by the runtime the Vite plugin injects.
146
+
147
+ ```typescript
148
+ InertiaModule.forRoot({
149
+ rootView: 'app',
150
+ seo: {
151
+ defaults: { openGraph: { siteName: 'Acme' }, twitter: { card: 'summary_large_image' } },
152
+ titleTemplate: '%s — Acme',
153
+ },
154
+ })
155
+ ```
156
+
157
+ ```typescript
158
+ async show(ctx: RouterContext) {
159
+ const note = await this.notes.find(ctx.param('id'))
160
+ ctx.seo({ title: note.title, description: note.excerpt })
161
+ return ctx.inertia('notes/Show', { note })
162
+ }
163
+ ```
164
+
165
+ Read it in a component with `useSeo()` from `@stratal/inertia/react`.
166
+
167
+ ## i18n and named routes
168
+
169
+ ```typescript
170
+ InertiaModule.forRoot({
171
+ rootView: 'app',
172
+ i18n: { only: ['common', 'nav'] }, // shares `locale` + `translations`
173
+ routes: true, // shares named routes
174
+ })
175
+ ```
176
+
177
+ ```tsx
178
+ import { useI18n, useRoute } from '@stratal/inertia/react'
179
+
180
+ const { t } = useI18n()
181
+ const { route, current } = useRoute()
182
+
183
+ <a href={route('notes.show', { id })} aria-current={current('notes.show') ? 'page' : undefined}>
184
+ {t('common.view')}
185
+ </a>
186
+ ```
187
+
188
+ ## Streaming SSR
86
189
 
87
190
  Enable SSR by pointing the module at a bundle that exports a streaming `render`:
88
191
 
@@ -93,9 +196,7 @@ InertiaModule.forRoot({
93
196
  })
94
197
  ```
95
198
 
96
- `src/inertia/ssr.tsx` (scaffolded by `quarry inertia:install`) uses
97
- `createInertiaSsrApp`, which wires Inertia's `App`, head collection, and React 19's
98
- `renderToReadableStream` — the shell flushes early and the body streams progressively:
199
+ `src/inertia/ssr.tsx` (scaffolded by `quarry inertia:install`) uses `createInertiaSsrApp`, which wires Inertia's `App`, head collection, and React 19's `renderToReadableStream` — the shell flushes early and the body streams progressively:
99
200
 
100
201
  ```tsx
101
202
  import { createInertiaSsrApp } from '@stratal/inertia/ssr'
@@ -110,14 +211,57 @@ export const { render } = createInertiaSsrApp({
110
211
  })
111
212
  ```
112
213
 
113
- There is no client-side fallback — an SSR failure surfaces as an error rather than
114
- silently degrading. Skip SSR per route with `ctx.withoutSsr()` or globally with
115
- `ssr.disabled: ['admin/*']`.
214
+ There is no client-side fallback — an SSR failure surfaces as an error rather than silently degrading.
215
+
216
+ ### Excluding pages from SSR
217
+
218
+ Heavy pages that don't need to be in first paint can skip the server entirely. Pass `ssrExclude` to the Vite plugin with page-component globs:
219
+
220
+ ```typescript
221
+ stratalInertia({
222
+ ssrExclude: ['Admin/**', 'Reports/Heavy'],
223
+ })
224
+ ```
225
+
226
+ Patterns match Inertia component names (`*` matches one path segment, `**` matches any number). Excluded pages are dropped from the worker bundle entirely — a smaller cold start — and rendered client-only at runtime. The browser bundle still includes them, so they hydrate normally.
227
+
228
+ ## Testing
229
+
230
+ ```typescript
231
+ // vitest.setup.ts
232
+ import '@stratal/inertia/testing' // augments TestResponse with Inertia assertions
233
+ ```
234
+
235
+ ```typescript
236
+ const response = await module.http
237
+ .get('/notes')
238
+ .withHeaders({ 'X-Inertia': 'true', 'X-Inertia-Version': '1' })
239
+ .send()
240
+
241
+ await response.assertInertia()
242
+ ```
243
+
244
+ ## Quarry commands
245
+
246
+ | Command | Description |
247
+ |---|---|
248
+ | `inertia:install` | Scaffold Inertia.js files for a Stratal project |
249
+ | `inertia:dev` | Start the Inertia Vite development server |
250
+ | `inertia:build` | Build the Inertia frontend for production |
251
+ | `inertia:types` | Generate Inertia page type definitions |
116
252
 
117
253
  ## Documentation
118
254
 
119
255
  Full guides and examples are available at **[stratal.dev](https://stratal.dev)**.
120
256
 
257
+ ## Support the project
258
+
259
+ If Stratal is useful to you, **[star the repository](https://github.com/strataljs/stratal)** — it is the simplest way to help others find it.
260
+
261
+ ## Maintainer
262
+
263
+ Built and maintained by **Temitayo Fadojutimi** — [@adesege_](https://x.com/adesege_).
264
+
121
265
  ## License
122
266
 
123
267
  MIT
@@ -1 +1 @@
1
- {"version":3,"file":"build-seo-tags-DBsHKxX9.mjs","names":[],"sources":["../src/seo/build-seo-tags.ts"],"sourcesContent":["import type { SeoData, SeoTagDescriptor } from './types'\n\n/**\n * Marker attribute stamped on every SEO-managed head element. The server emits\n * it on injected tags and the client head-sync runtime uses it to find and\n * reconcile the same tags across SPA navigations.\n */\nexport const DATA_SEO_ATTR = 'data-seo'\n\n/**\n * Maps resolved {@link SeoData} into a flat list of {@link SeoTagDescriptor}s.\n *\n * Pure and framework-free: used server-side to render HTML strings and\n * client-side to build DOM nodes, so the two never drift. Every descriptor\n * carries the {@link DATA_SEO_ATTR} marker.\n */\nexport function buildSeoTags(data: SeoData): SeoTagDescriptor[] {\n const tags: SeoTagDescriptor[] = []\n\n if (data.title != null) {\n tags.push({ tag: 'title', attrs: {}, content: data.title })\n }\n\n meta(tags, { name: 'description' }, data.description)\n meta(tags, { name: 'keywords' }, Array.isArray(data.keywords) ? data.keywords.join(', ') : data.keywords)\n meta(tags, { name: 'author' }, data.author)\n meta(tags, { name: 'robots' }, data.robots)\n\n if (data.canonical != null) {\n tags.push({ tag: 'link', attrs: { rel: 'canonical', href: data.canonical } })\n }\n\n const og = data.openGraph\n if (og) {\n metaProp(tags, 'og:title', og.title)\n metaProp(tags, 'og:description', og.description)\n metaProp(tags, 'og:image', og.image)\n metaProp(tags, 'og:type', og.type)\n metaProp(tags, 'og:url', og.url)\n metaProp(tags, 'og:site_name', og.siteName)\n }\n\n const tw = data.twitter\n if (tw) {\n meta(tags, { name: 'twitter:card' }, tw.card)\n meta(tags, { name: 'twitter:title' }, tw.title)\n meta(tags, { name: 'twitter:description' }, tw.description)\n meta(tags, { name: 'twitter:image' }, tw.image)\n meta(tags, { name: 'twitter:site' }, tw.site)\n meta(tags, { name: 'twitter:creator' }, tw.creator)\n }\n\n if (data.meta) {\n for (const entry of data.meta) {\n const attrs: Record<string, string> = {}\n if (entry.name != null) attrs.name = entry.name\n if (entry.property != null) attrs.property = entry.property\n attrs.content = entry.content\n tags.push({ tag: 'meta', attrs })\n }\n }\n\n if (data.link) {\n for (const entry of data.link) {\n const attrs: Record<string, string> = {}\n // Custom link entries carry arbitrary keys; drop any whose name isn't a\n // safe attribute so a crafted key can't break out of the tag (server),\n // throw from `setAttribute` (client head-sync), or smuggle in an inline\n // event handler (`<link rel=… onload=…>` fires for some rel values).\n for (const [key, value] of Object.entries(entry)) {\n if (isSafeAttrName(key)) attrs[key] = value\n }\n tags.push({ tag: 'link', attrs })\n }\n }\n\n // Stamp the marker on every descriptor.\n for (const t of tags) {\n t.attrs[DATA_SEO_ATTR] = ''\n }\n\n return tags\n}\n\n/**\n * Valid HTML attribute name. Used to drop any attribute whose name (e.g. a key\n * spread from a user-supplied custom `meta`/`link` entry) could otherwise break\n * out of the tag and inject markup — attribute values are escaped, but names are\n * emitted verbatim, so an unsafe name like `x onload=…` must be rejected.\n */\nconst VALID_ATTR_NAME = /^[A-Za-z_:][\\w.:-]*$/\n\n/**\n * A valid attribute name that is also not an inline event handler. Even with a\n * well-formed name and an escaped value, `on*` attributes execute JS, so a\n * user-supplied `onload`/`onerror`/… key must never be emitted.\n */\nfunction isSafeAttrName(name: string): boolean {\n return VALID_ATTR_NAME.test(name) && !/^on/i.test(name)\n}\n\n/** Renders a descriptor to an HTML string with attribute/text escaping (server-side). */\nexport function descriptorToHtml(d: SeoTagDescriptor): string {\n const attrs = Object.entries(d.attrs)\n .filter(([key]) => isSafeAttrName(key))\n .map(([key, value]) => (value === '' ? key : `${key}=\"${escapeAttr(value)}\"`))\n .join(' ')\n const open = attrs ? `${d.tag} ${attrs}` : d.tag\n\n if (d.tag === 'title') {\n return `<title ${attrs}>${escapeText(d.content ?? '')}</title>`\n }\n return `<${open} />`\n}\n\nfunction meta(tags: SeoTagDescriptor[], attrs: Record<string, string>, content: string | undefined): void {\n if (content == null) return\n tags.push({ tag: 'meta', attrs: { ...attrs, content } })\n}\n\nfunction metaProp(tags: SeoTagDescriptor[], property: string, content: string | undefined): void {\n if (content == null) return\n tags.push({ tag: 'meta', attrs: { property, content } })\n}\n\nfunction escapeAttr(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n\nfunction escapeText(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"build-seo-tags-DBsHKxX9.mjs","names":[],"sources":["../src/seo/build-seo-tags.ts"],"sourcesContent":["import type { SeoData, SeoTagDescriptor } from './types'\n\n/**\n * Marker attribute stamped on every SEO-managed head element. The server emits\n * it on injected tags and the client head-sync runtime uses it to find and\n * reconcile the same tags across SPA navigations.\n */\nexport const DATA_SEO_ATTR = 'data-seo'\n\n/**\n * Maps resolved {@link SeoData} into a flat list of {@link SeoTagDescriptor}s.\n *\n * Pure and framework-free: used server-side to render HTML strings and\n * client-side to build DOM nodes, so the two never drift. Every descriptor\n * carries the {@link DATA_SEO_ATTR} marker.\n */\nexport function buildSeoTags(data: SeoData): SeoTagDescriptor[] {\n const tags: SeoTagDescriptor[] = []\n\n if (data.title != null) {\n tags.push({ tag: 'title', attrs: {}, content: data.title })\n }\n\n meta(tags, { name: 'description' }, data.description)\n meta(tags, { name: 'keywords' }, Array.isArray(data.keywords) ? data.keywords.join(', ') : data.keywords)\n meta(tags, { name: 'author' }, data.author)\n meta(tags, { name: 'robots' }, data.robots)\n\n if (data.canonical != null) {\n tags.push({ tag: 'link', attrs: { rel: 'canonical', href: data.canonical } })\n }\n\n const og = data.openGraph\n if (og) {\n metaProp(tags, 'og:title', og.title)\n metaProp(tags, 'og:description', og.description)\n metaProp(tags, 'og:image', og.image)\n metaProp(tags, 'og:type', og.type)\n metaProp(tags, 'og:url', og.url)\n metaProp(tags, 'og:site_name', og.siteName)\n }\n\n const tw = data.twitter\n if (tw) {\n meta(tags, { name: 'twitter:card' }, tw.card)\n meta(tags, { name: 'twitter:title' }, tw.title)\n meta(tags, { name: 'twitter:description' }, tw.description)\n meta(tags, { name: 'twitter:image' }, tw.image)\n meta(tags, { name: 'twitter:site' }, tw.site)\n meta(tags, { name: 'twitter:creator' }, tw.creator)\n }\n\n if (data.meta) {\n for (const entry of data.meta) {\n const attrs: Record<string, string> = {}\n if (entry.name != null) attrs.name = entry.name\n if (entry.property != null) attrs.property = entry.property\n attrs.content = entry.content\n tags.push({ tag: 'meta', attrs })\n }\n }\n\n if (data.link) {\n for (const entry of data.link) {\n const attrs: Record<string, string> = {}\n // Custom link entries carry arbitrary keys; drop any whose name isn't a\n // safe attribute so a crafted key can't break out of the tag (server),\n // throw from `setAttribute` (client head-sync), or smuggle in an inline\n // event handler (`<link rel=… onload=…>` fires for some rel values).\n for (const [key, value] of Object.entries(entry)) {\n if (isSafeAttrName(key)) attrs[key] = value\n }\n tags.push({ tag: 'link', attrs })\n }\n }\n\n // Stamp the marker on every descriptor.\n for (const t of tags) {\n t.attrs[DATA_SEO_ATTR] = ''\n }\n\n return tags\n}\n\n/**\n * Valid HTML attribute name. Used to drop any attribute whose name (e.g. a key\n * spread from a user-supplied custom `meta`/`link` entry) could otherwise break\n * out of the tag and inject markup — attribute values are escaped, but names are\n * emitted verbatim, so an unsafe name like `x onload=…` must be rejected.\n */\nconst VALID_ATTR_NAME = /^[A-Za-z_:][\\w.:-]*$/\n\n/**\n * A valid attribute name that is also not an inline event handler. Even with a\n * well-formed name and an escaped value, `on*` attributes execute JS, so a\n * user-supplied `onload`/`onerror`/… key must never be emitted.\n */\nfunction isSafeAttrName(name: string): boolean {\n return VALID_ATTR_NAME.test(name) && !/^on/i.test(name)\n}\n\n/** Renders a descriptor to an HTML string with attribute/text escaping (server-side). */\nexport function descriptorToHtml(d: SeoTagDescriptor): string {\n const attrs = Object.entries(d.attrs)\n .filter(([key]) => isSafeAttrName(key))\n .map(([key, value]) => (value === '' ? key : `${key}=\"${escapeAttr(value)}\"`))\n .join(' ')\n const open = attrs ? `${d.tag} ${attrs}` : d.tag\n\n if (d.tag === 'title') {\n return `<title ${attrs}>${escapeText(d.content ?? '')}</title>`\n }\n return `<${open} />`\n}\n\nfunction meta(tags: SeoTagDescriptor[], attrs: Record<string, string>, content: string | undefined): void {\n if (content == null) return\n tags.push({ tag: 'meta', attrs: { ...attrs, content } })\n}\n\nfunction metaProp(tags: SeoTagDescriptor[], property: string, content: string | undefined): void {\n if (content == null) return\n tags.push({ tag: 'meta', attrs: { property, content } })\n}\n\nfunction escapeAttr(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n\nfunction escapeText(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1 +1 @@
1
- export { };
1
+ export {}