@stratal/inertia 0.0.27 → 0.1.1

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 +379 -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/dist/index.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { t as __decorate } from "./decorate-B7nr7eBl.mjs";
1
+ import { t as __decorate } from "./decorate-RQD1h28J.mjs";
2
+ import { getSsrExcludeMatchers, isSsrExcluded } from "./services/ssr-exclusion.mjs";
2
3
  import { n as buildSeoTags, r as descriptorToHtml, t as DATA_SEO_ATTR } from "./build-seo-tags-DBsHKxX9.mjs";
3
4
  import { ApplicationError } from "stratal/errors";
4
5
  import { Module } from "stratal/module";
5
- import { Delete, Get, Patch, Post, Put, ROUTER_TOKENS, Route, RouterContext, SchemaValidationError, applyTrailingSlash, resolveTrailingSlash } from "stratal/router";
6
+ import { Delete, Get, Patch, Post, Put, ROUTER_CONTEXT_KEYS, ROUTER_TOKENS, Route, RouterContext, SchemaValidationError, applyTrailingSlash, resolveTrailingSlash } from "stratal/router";
6
7
  import { CONTAINER_TOKEN, DI_TOKENS, Request, Singleton, Transient, inject } from "stratal/di";
7
- import { I18N_TOKENS } from "stratal/i18n";
8
+ import { I18N_TOKENS, resolveDetectionForPath } from "stratal/i18n";
8
9
  import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
9
- import { z } from "stratal/validation";
10
+ import { array, boolean, nullable, number, object, optional, record, string, unknown } from "zod/mini";
10
11
  //#region src/augment/router-context.ts
11
12
  function augmentRouterContext(resolveService) {
12
13
  const originalRedirect = RouterContext.prototype.redirect;
@@ -29,6 +30,9 @@ function augmentRouterContext(resolveService) {
29
30
  RouterContext.macro("merge", function(callback, options) {
30
31
  return resolveService(this).merge(callback, options);
31
32
  });
33
+ RouterContext.macro("scroll", function(callback, options) {
34
+ return resolveService(this).scroll(callback, options);
35
+ });
32
36
  RouterContext.macro("once", function(callback, options) {
33
37
  return resolveService(this).once(callback, options);
34
38
  });
@@ -45,9 +49,6 @@ function augmentRouterContext(resolveService) {
45
49
  RouterContext.macro("seo", function(data) {
46
50
  resolveService(this).seo(data);
47
51
  });
48
- RouterContext.macro("withoutSsr", function() {
49
- this.c.set("withoutSsr", true);
50
- });
51
52
  }
52
53
  //#endregion
53
54
  //#region src/inertia.tokens.ts
@@ -57,11 +58,53 @@ const INERTIA_TOKENS = {
57
58
  TemplateService: Symbol.for("stratal:inertia:template"),
58
59
  ManifestService: Symbol.for("stratal:inertia:manifest"),
59
60
  SsrRenderer: Symbol.for("stratal:inertia:ssr-renderer"),
61
+ DocumentRenderer: Symbol.for("stratal:inertia:document-renderer"),
60
62
  HreflangService: Symbol.for("stratal:inertia:hreflang"),
61
63
  SeoService: Symbol.for("stratal:inertia:seo")
62
64
  };
63
65
  //#endregion
64
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateParam.js
66
+ //#region src/types.ts
67
+ const INERTIA_PROP_OPTIONAL = Symbol.for("stratal:inertia:prop:optional");
68
+ const INERTIA_PROP_DEFERRED = Symbol.for("stratal:inertia:prop:deferred");
69
+ const INERTIA_PROP_MERGE = Symbol.for("stratal:inertia:prop:merge");
70
+ const INERTIA_PROP_SCROLL = Symbol.for("stratal:inertia:prop:scroll");
71
+ const INERTIA_PROP_ONCE = Symbol.for("stratal:inertia:prop:once");
72
+ const INERTIA_PROP_ALWAYS = Symbol.for("stratal:inertia:prop:always");
73
+ /**
74
+ * Header the client sends on every infinite-scroll fetch to say which end of the
75
+ * accumulated list the response should be joined to. Absent on the first render,
76
+ * which is not a scroll fetch.
77
+ */
78
+ const INERTIA_SCROLL_MERGE_INTENT_HEADER = "x-inertia-infinite-scroll-merge-intent";
79
+ /**
80
+ * Every request header an Inertia response body is a function of, beyond
81
+ * `X-Inertia` itself. This is the set `partialRequestFor` reads to decide which
82
+ * props to resolve, so two requests to one URL differing in any of them receive
83
+ * different bodies.
84
+ *
85
+ * They are declared in `Vary` on EVERY Inertia response, not only on partial
86
+ * reloads, and that is the load-bearing part: a cache matches a stored response
87
+ * against the `Vary` IT was stored with. A full page response stored under
88
+ * `Vary: X-Inertia` alone would be served to a partial reload, which sends the
89
+ * same `X-Inertia: true` and would receive the whole page in place of the props
90
+ * it asked for.
91
+ *
92
+ * `X-Inertia-Version` is deliberately absent. A mismatch answers 409, which is
93
+ * never cached, and the asset version only moves with a deploy — which moves the
94
+ * Worker version that Workers Caching already keys on, so a stale client cannot
95
+ * reach an entry stored before it.
96
+ */
97
+ const INERTIA_VARY_HEADERS = [
98
+ "X-Inertia",
99
+ "X-Inertia-Partial-Component",
100
+ "X-Inertia-Partial-Data",
101
+ "X-Inertia-Partial-Except",
102
+ "X-Inertia-Reset",
103
+ "X-Inertia-Resolve-Deferred",
104
+ INERTIA_SCROLL_MERGE_INTENT_HEADER
105
+ ];
106
+ //#endregion
107
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateParam.js
65
108
  function __decorateParam(paramIndex, decorator) {
66
109
  return function(target, key) {
67
110
  decorator(target, key, paramIndex);
@@ -79,7 +122,6 @@ let InertiaMiddleware = class InertiaMiddleware {
79
122
  const isPrefetch = ctx.header("purpose") === "prefetch";
80
123
  ctx.c.set("inertia", isInertia);
81
124
  ctx.c.set("inertiaPrefetch", isPrefetch);
82
- ctx.c.set("withoutSsr", false);
83
125
  ctx.c.set("inertiaFlashOut", {});
84
126
  let hadFlash = false;
85
127
  if (this.options.flash) {
@@ -91,8 +133,13 @@ let InertiaMiddleware = class InertiaMiddleware {
91
133
  const clientVersion = ctx.header("x-inertia-version");
92
134
  const serverVersion = this.options.version ?? "";
93
135
  if (clientVersion && serverVersion && clientVersion !== serverVersion) {
94
- ctx.c.header("X-Inertia-Location", ctx.c.req.url);
95
- ctx.c.status(409);
136
+ ctx.c.res = new Response(null, {
137
+ status: 409,
138
+ headers: {
139
+ "X-Inertia-Location": ctx.c.req.url,
140
+ "X-Inertia-Version": serverVersion
141
+ }
142
+ });
96
143
  return;
97
144
  }
98
145
  }
@@ -104,7 +151,10 @@ let InertiaMiddleware = class InertiaMiddleware {
104
151
  }
105
152
  const status = ctx.c.res?.status;
106
153
  if (typeof status !== "number" || status < 200 || status > 599) return;
107
- ctx.c.header("Vary", "X-Inertia");
154
+ const declaredVary = (ctx.c.res.headers.get("Vary") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
155
+ const seen = new Set(declaredVary.map((name) => name.toLowerCase()));
156
+ const missing = INERTIA_VARY_HEADERS.filter((name) => !seen.has(name.toLowerCase()));
157
+ ctx.c.header("Vary", [...missing, ...declaredVary].join(", "));
108
158
  if (isInertia && status === 302) {
109
159
  const method = ctx.c.req.method;
110
160
  if (method !== "GET" && method !== "HEAD") ctx.c.status(303);
@@ -113,6 +163,45 @@ let InertiaMiddleware = class InertiaMiddleware {
113
163
  };
114
164
  InertiaMiddleware = __decorate([Transient(), __decorateParam(0, inject(INERTIA_TOKENS.Options))], InertiaMiddleware);
115
165
  //#endregion
166
+ //#region src/services/document-renderer.service.ts
167
+ let DocumentRendererService = class DocumentRendererService {
168
+ options;
169
+ ssr;
170
+ template;
171
+ ssrExcludeMatchers = getSsrExcludeMatchers();
172
+ constructor(options, ssr, template) {
173
+ this.options = options;
174
+ this.ssr = ssr;
175
+ this.template = template;
176
+ }
177
+ /**
178
+ * Render `page` to an HTML document Response. `extraHead` tags are appended to
179
+ * `<head>` — after the SSR-rendered Inertia `<Head>` when streaming, or as the
180
+ * only head tags in the client-only shell.
181
+ */
182
+ async render(page, status = 200, extraHead = []) {
183
+ if (!this.options.ssr || isSsrExcluded(page.component, this.ssrExcludeMatchers)) {
184
+ const html = this.template.renderClientOnly(page, extraHead);
185
+ return new Response(html, {
186
+ status,
187
+ headers: { "Content-Type": "text/html; charset=utf-8" }
188
+ });
189
+ }
190
+ const { head, stream } = await this.ssr.render(page);
191
+ const body = this.template.renderStream(page, [...head, ...extraHead], stream);
192
+ return new Response(body, {
193
+ status,
194
+ headers: { "Content-Type": "text/html; charset=utf-8" }
195
+ });
196
+ }
197
+ };
198
+ DocumentRendererService = __decorate([
199
+ Singleton(),
200
+ __decorateParam(0, inject(INERTIA_TOKENS.Options)),
201
+ __decorateParam(1, inject(INERTIA_TOKENS.SsrRenderer)),
202
+ __decorateParam(2, inject(INERTIA_TOKENS.TemplateService))
203
+ ], DocumentRendererService);
204
+ //#endregion
116
205
  //#region src/services/hreflang.service.ts
117
206
  let HreflangService = class HreflangService {
118
207
  container;
@@ -127,8 +216,9 @@ let HreflangService = class HreflangService {
127
216
  const defaultLocale = i18n.defaultLocale ?? "en";
128
217
  const trailingSlash = this.container.resolve(DI_TOKENS.Application).config.trailingSlash ?? "ignore";
129
218
  const localeUrl = this.container.resolve(ROUTER_TOKENS.LocaleUrlService);
130
- if (localeUrl.pathEnabled) return this.buildPathLinks(currentUrl, locales, defaultLocale, localeUrl, trailingSlash);
131
- if ((i18n.detection && "strategy" in i18n.detection ? i18n.detection.strategy : void 0) === "querystring") return this.buildQuerystringLinks(currentUrl, locales, defaultLocale, trailingSlash);
219
+ if (localeUrl.isPathLocalized(currentUrl.pathname)) return this.buildPathLinks(currentUrl, locales, defaultLocale, localeUrl, trailingSlash);
220
+ const detection = resolveDetectionForPath(i18n.detection, currentUrl.pathname);
221
+ if (detection.enabled && detection.strategy === "querystring") return this.buildQuerystringLinks(currentUrl, locales, defaultLocale, trailingSlash);
132
222
  return [];
133
223
  }
134
224
  buildPathLinks(url, locales, defaultLocale, localeUrl, trailingSlash) {
@@ -167,24 +257,95 @@ let HreflangService = class HreflangService {
167
257
  };
168
258
  HreflangService = __decorate([Singleton(), __decorateParam(0, inject(CONTAINER_TOKEN))], HreflangService);
169
259
  //#endregion
170
- //#region src/types.ts
171
- const INERTIA_PROP_OPTIONAL = Symbol.for("stratal:inertia:prop:optional");
172
- const INERTIA_PROP_DEFERRED = Symbol.for("stratal:inertia:prop:deferred");
173
- const INERTIA_PROP_MERGE = Symbol.for("stratal:inertia:prop:merge");
174
- const INERTIA_PROP_ONCE = Symbol.for("stratal:inertia:prop:once");
175
- const INERTIA_PROP_ALWAYS = Symbol.for("stratal:inertia:prop:always");
260
+ //#region src/services/inertia-cache-signals.ts
261
+ /**
262
+ * Report the conditions that make an Inertia page unsafe to cache, and the
263
+ * request headers a safe one must be keyed on.
264
+ *
265
+ * A `once()` prop is the subtle one: it is contractually sent a single time,
266
+ * so replaying it from cache to every client breaks the guarantee the API makes.
267
+ *
268
+ * A partial reload is NOT among them. It is a deterministic function of the URL
269
+ * and of `INERTIA_VARY_HEADERS`, so it caches correctly once keyed on those —
270
+ * and refusing it outright made every deferred prop permanently uncacheable,
271
+ * which on a page that defers its expensive work is the whole of the cost.
272
+ */
273
+ function buildInertiaCacheSignals(input) {
274
+ return {
275
+ hasFlash: Object.keys(input.flash).length > 0,
276
+ isPartial: input.isPartial,
277
+ hasOnceProps: Object.keys(input.onceProps).length > 0,
278
+ varyHeaders: INERTIA_VARY_HEADERS
279
+ };
280
+ }
281
+ //#endregion
282
+ //#region src/errors/unrecognized-scroll-shape.error.ts
283
+ /**
284
+ * Raised when `ctx.scroll()` is handed a value whose paging identifiers it
285
+ * cannot derive: neither the offset shape `{ data, pagination: { page,
286
+ * totalPages } }` nor the cursor shape `{ data, cursorName, cursor, nextCursor,
287
+ * prevCursor }`.
288
+ *
289
+ * A **programming error**, and deliberately loud: guessing an identifier is
290
+ * worse than failing, because a wrong `nextPage` reads to the client as "no
291
+ * more pages" and silently truncates the list. No request recovers from it and
292
+ * it carries no HTTP status. The fix is in the route — return one of the two
293
+ * recognized shapes, or pass `metadata` to `ctx.scroll()` to extract
294
+ * `pageName`, `currentPage`, `previousPage` and `nextPage` from this one.
295
+ */
296
+ var UnrecognizedScrollShapeError = class extends ApplicationError {};
297
+ //#endregion
298
+ //#region src/services/scroll-metadata.ts
299
+ function isOffsetPaginatedResult(value) {
300
+ if (typeof value !== "object" || value === null || !("pagination" in value)) return false;
301
+ const { pagination } = value;
302
+ if (typeof pagination !== "object" || pagination === null) return false;
303
+ const { page, totalPages } = pagination;
304
+ return Number.isInteger(page) && Number.isInteger(totalPages);
305
+ }
306
+ function isCursorPageShape(value) {
307
+ if (typeof value !== "object" || value === null) return false;
308
+ const { cursorName, cursor, nextCursor, prevCursor } = value;
309
+ return typeof cursorName === "string" && (typeof cursor === "string" || cursor === null) && (typeof nextCursor === "string" || nextCursor === null) && (typeof prevCursor === "string" || prevCursor === null);
310
+ }
311
+ /**
312
+ * Derives the infinite-scroll identifiers from a paginated result.
313
+ *
314
+ * Recognises both of the framework's own paginated shapes — the offset one
315
+ * `paginatedResponseSchema` describes, and the cursor one `db.$cursor`
316
+ * returns. Anything else throws rather than guessing: a wrong `nextPage` reads
317
+ * to the client as "no more pages" and silently truncates a list, so an
318
+ * unrecognised shape has to be named at the call site with
319
+ * `ctx.scroll(..., { metadata })`.
320
+ */
321
+ function deriveScrollMetadata(value) {
322
+ if (isOffsetPaginatedResult(value)) {
323
+ const { page, totalPages } = value.pagination;
324
+ return {
325
+ pageName: "page",
326
+ currentPage: page,
327
+ previousPage: page > 1 ? page - 1 : null,
328
+ nextPage: page < totalPages ? page + 1 : null
329
+ };
330
+ }
331
+ if (isCursorPageShape(value)) return {
332
+ pageName: value.cursorName,
333
+ currentPage: value.cursor ?? 1,
334
+ previousPage: value.prevCursor,
335
+ nextPage: value.nextCursor
336
+ };
337
+ throw new UnrecognizedScrollShapeError("[stratal:inertia] Cannot derive infinite-scroll metadata: the value is neither an offset-paginated result (`{ data, pagination: { page, totalPages } }`) nor a cursor-paginated one (`{ data, cursorName, cursor, nextCursor, prevCursor }`). Pass `metadata` to `ctx.scroll()` to extract `pageName`, `currentPage`, `previousPage` and `nextPage` from this shape yourself.");
338
+ }
176
339
  //#endregion
177
340
  //#region src/services/inertia.service.ts
178
341
  let InertiaService = class InertiaService {
179
342
  options;
180
- template;
181
- ssr;
343
+ documentRenderer;
182
344
  seoService;
183
345
  sharedData = {};
184
- constructor(options, template, ssr, seoService) {
346
+ constructor(options, documentRenderer, seoService) {
185
347
  this.options = options;
186
- this.template = template;
187
- this.ssr = ssr;
348
+ this.documentRenderer = documentRenderer;
188
349
  this.seoService = seoService;
189
350
  }
190
351
  share(key, value) {
@@ -220,6 +381,16 @@ let InertiaService = class InertiaService {
220
381
  matchOn: options?.matchOn
221
382
  };
222
383
  }
384
+ scroll(callback, options) {
385
+ return {
386
+ [INERTIA_PROP_SCROLL]: true,
387
+ callback,
388
+ wrapper: options?.wrapper ?? "data",
389
+ matchOn: options?.matchOn,
390
+ pageName: options?.pageName,
391
+ metadata: options?.metadata
392
+ };
393
+ }
223
394
  once(callback, options) {
224
395
  return {
225
396
  [INERTIA_PROP_ONCE]: true,
@@ -237,7 +408,6 @@ let InertiaService = class InertiaService {
237
408
  async render(ctx, component, props = {}, renderOptions = {}) {
238
409
  const reqUrl = new URL(ctx.c.req.url);
239
410
  const url = reqUrl.search ? `${reqUrl.pathname}${reqUrl.search}` : reqUrl.pathname;
240
- const pathname = reqUrl.pathname;
241
411
  const isInertia = ctx.c.get("inertia");
242
412
  const { shared: resolvedShared, sharedKeys } = await this.resolveSharedData(ctx);
243
413
  const resolvedSeo = await this.seoService.resolve(ctx);
@@ -252,9 +422,19 @@ let InertiaService = class InertiaService {
252
422
  ...Object.keys(this.sharedData),
253
423
  "seo"
254
424
  ];
255
- const result = await this.processProps(allProps, ctx, component, isInertia);
256
- const { errors: flashErrors, ...flash } = ctx.c.get("inertiaFlash") ?? {};
425
+ const result = await this.processProps(allProps, this.partialRequestFor(ctx, component, isInertia));
426
+ const rawFlash = ctx.c.get("inertiaFlash") ?? {};
427
+ const { errors: flashErrors, ...flash } = rawFlash;
257
428
  const errors = flashErrors && typeof flashErrors === "object" && !Array.isArray(flashErrors) ? flashErrors : {};
429
+ ctx.c.set(ROUTER_CONTEXT_KEYS.RESPONSE_PAYLOAD, {
430
+ ...result.resolvedProps,
431
+ errors
432
+ });
433
+ ctx.c.set("inertiaCacheSignals", buildInertiaCacheSignals({
434
+ flash: rawFlash,
435
+ isPartial: this.isPartialReload(ctx, component),
436
+ onceProps: result.onceProps
437
+ }));
258
438
  const page = {
259
439
  component,
260
440
  props: {
@@ -270,6 +450,7 @@ let InertiaService = class InertiaService {
270
450
  ...result.prependProps.length > 0 ? { prependProps: result.prependProps } : {},
271
451
  ...result.deepMergeProps.length > 0 ? { deepMergeProps: result.deepMergeProps } : {},
272
452
  ...result.matchPropsOn.length > 0 ? { matchPropsOn: result.matchPropsOn } : {},
453
+ ...Object.keys(result.scrollProps).length > 0 ? { scrollProps: result.scrollProps } : {},
273
454
  ...Object.keys(result.deferredProps).length > 0 ? { deferredProps: result.deferredProps } : {},
274
455
  ...Object.keys(result.deferredProps).length > 0 && !this.isPartialReload(ctx, component) ? { initialDeferredProps: result.deferredProps } : {},
275
456
  ...Object.keys(result.onceProps).length > 0 ? { onceProps: result.onceProps } : {},
@@ -288,19 +469,7 @@ let InertiaService = class InertiaService {
288
469
  }
289
470
  });
290
471
  const seoTags = this.seoService.tagsFor(resolvedSeo);
291
- if (ctx.c.get("withoutSsr") || this.isSsrDisabled(pathname) || !this.options.ssr) {
292
- const html = this.template.renderClientOnly(page, seoTags);
293
- return new Response(html, {
294
- status,
295
- headers: { "Content-Type": "text/html; charset=utf-8" }
296
- });
297
- }
298
- const { head, stream } = await this.ssr.render(page);
299
- const body = this.template.renderStream(page, [...head, ...seoTags], stream);
300
- return new Response(body, {
301
- status,
302
- headers: { "Content-Type": "text/html; charset=utf-8" }
303
- });
472
+ return this.documentRenderer.render(page, status, seoTags);
304
473
  }
305
474
  /**
306
475
  * Resolve shared data from module options and i18n configuration.
@@ -351,89 +520,172 @@ let InertiaService = class InertiaService {
351
520
  const partialDataHeader = ctx.header("x-inertia-partial-data");
352
521
  return !!(isInertia && partialComponent === component && partialDataHeader);
353
522
  }
354
- async processProps(allProps, ctx, component, isInertia) {
355
- const resolvedProps = {};
356
- const mergeProps = [];
357
- const prependProps = [];
358
- const deepMergeProps = [];
359
- const matchPropsOn = [];
360
- const deferredProps = {};
361
- const onceProps = {};
523
+ /**
524
+ * Reads everything about the incoming request that changes how props resolve.
525
+ *
526
+ * Split out so a caller assembling its own page — a modal level, whose props
527
+ * sit under a path rather than at the page root — can resolve props with the
528
+ * same semantics while supplying its own notion of which ones were asked for.
529
+ */
530
+ partialRequestFor(ctx, component, isInertia) {
362
531
  const partialComponent = ctx.header("x-inertia-partial-component");
363
532
  const partialDataHeader = ctx.header("x-inertia-partial-data");
364
533
  const partialExceptHeader = ctx.header("x-inertia-partial-except");
365
534
  const resetHeader = ctx.header("x-inertia-reset");
366
- const shouldResolveDeferred = ctx.header("x-inertia-resolve-deferred") === "true";
367
- const isPartialReload = isInertia && partialComponent === component && partialDataHeader;
368
- const requestedProps = partialDataHeader?.split(",").map((s) => s.trim()) ?? [];
369
- const exceptProps = partialExceptHeader?.split(",").map((s) => s.trim()) ?? [];
370
- resetHeader?.split(",").map((s) => s.trim());
371
- for (const [key, value] of Object.entries(allProps)) {
535
+ return {
536
+ isPartial: !!(isInertia && partialComponent === component && partialDataHeader),
537
+ requested: partialDataHeader?.split(",").map((s) => s.trim()) ?? [],
538
+ except: partialExceptHeader?.split(",").map((s) => s.trim()) ?? [],
539
+ reset: resetHeader?.split(",").map((s) => s.trim()) ?? [],
540
+ prependIntent: ctx.header(INERTIA_SCROLL_MERGE_INTENT_HEADER) === "prepend",
541
+ resolveDeferred: ctx.header("x-inertia-resolve-deferred") === "true"
542
+ };
543
+ }
544
+ /**
545
+ * Resolves a prop record with the helper semantics `render()` applies, for a
546
+ * caller that owns its own page assembly.
547
+ *
548
+ * The returned paths are relative to the record passed in; a caller whose
549
+ * props live under a page path re-anchors them before they reach the page
550
+ * object.
551
+ */
552
+ async resolveProps(props, request) {
553
+ return this.processProps(props, request);
554
+ }
555
+ async processProps(allProps, request) {
556
+ const resolution = {
557
+ resolvedProps: {},
558
+ mergeProps: [],
559
+ prependProps: [],
560
+ deepMergeProps: [],
561
+ matchPropsOn: [],
562
+ scrollProps: {},
563
+ deferredProps: {},
564
+ onceProps: {}
565
+ };
566
+ await this.collectProps(allProps, request, resolution, resolution.resolvedProps, "", false);
567
+ return resolution;
568
+ }
569
+ /**
570
+ * Resolve one record of props into `into`, naming each by its full path.
571
+ *
572
+ * Recursive because the protocol addresses a nested prop by its dot path: `only: ['auth.user']`
573
+ * asks for one field of `auth`, not for the whole of it. Answering with all of `auth` is the
574
+ * payload the partial reload was sent to avoid.
575
+ *
576
+ * Three things make a prop survive a partial reload, mirroring Laravel's resolver: the request
577
+ * is not partial, its own path was asked for, or an ancestor of it resolved. The last is why a
578
+ * `defer()`/`optional()` prop comes back whole when a path inside it is asked for — nothing
579
+ * inside a callback's result can be narrowed without running the callback, so once it runs, all
580
+ * of it is included.
581
+ *
582
+ * Only plain objects are descended into. An array is a value: the client reads `items.0.name`
583
+ * as a path, but a partial reload asking for one element of a list it does not hold yet has
584
+ * nothing to merge that element into.
585
+ */
586
+ async collectProps(props, request, resolution, into, parentPath, parentWasResolved) {
587
+ const { isPartial: isPartialReload, requested: requestedProps, except: exceptProps, reset: resetProps, prependIntent, resolveDeferred: shouldResolveDeferred } = request;
588
+ for (const [key, value] of Object.entries(props)) {
589
+ const path = parentPath === "" ? key : `${parentPath}.${key}`;
590
+ const named = !isPartialReload || parentWasResolved || this.isNamed(path, requestedProps);
591
+ const encloses = isPartialReload && this.enclosesNamed(path, requestedProps);
372
592
  if (this.isAlwaysProp(value)) {
373
- resolvedProps[key] = await value.callback();
593
+ into[key] = await value.callback();
374
594
  continue;
375
595
  }
376
596
  if (this.isOnceProp(value)) {
377
- if (isPartialReload && this.isRequested(key, requestedProps)) resolvedProps[key] = await value.callback();
597
+ if (isPartialReload && (named || encloses)) into[key] = await value.callback();
378
598
  else if (!isPartialReload) {
379
- resolvedProps[key] = await value.callback();
380
- onceProps[key] = {
381
- prop: value.key ?? key,
599
+ into[key] = await value.callback();
600
+ resolution.onceProps[path] = {
601
+ prop: value.key ?? path,
382
602
  ...value.expiresAt != null ? { expiresAt: value.expiresAt } : {}
383
603
  };
384
604
  }
385
605
  continue;
386
606
  }
387
607
  if (this.isDeferredProp(value)) {
388
- if (isPartialReload && this.isRequested(key, requestedProps)) resolvedProps[key] = await value.callback();
389
- else if (!isPartialReload) if (shouldResolveDeferred) resolvedProps[key] = await value.callback();
390
- else {
391
- deferredProps[value.group] ??= [];
392
- deferredProps[value.group].push(key);
608
+ if (isPartialReload && (named || encloses)) into[key] = await value.callback();
609
+ else if (!isPartialReload) {
610
+ if (shouldResolveDeferred) into[key] = await value.callback();
611
+ else {
612
+ resolution.deferredProps[value.group] ??= [];
613
+ resolution.deferredProps[value.group].push(path);
614
+ }
393
615
  }
394
616
  continue;
395
617
  }
396
618
  if (this.isMergeProp(value)) {
397
- if (isPartialReload && !this.isRequested(key, requestedProps)) continue;
398
- switch (value.strategy) {
399
- case "prepend":
400
- prependProps.push(key);
401
- break;
402
- case "deep":
403
- deepMergeProps.push(key);
404
- break;
405
- default:
406
- mergeProps.push(key);
407
- break;
619
+ if (isPartialReload && !named && !encloses) continue;
620
+ if (!resetProps.includes(path)) {
621
+ switch (value.strategy) {
622
+ case "prepend":
623
+ resolution.prependProps.push(path);
624
+ break;
625
+ case "deep":
626
+ resolution.deepMergeProps.push(path);
627
+ break;
628
+ default: resolution.mergeProps.push(path);
629
+ }
630
+ if (value.matchOn) resolution.matchPropsOn.push(`${path}.${value.matchOn}`);
631
+ }
632
+ into[key] = await value.callback();
633
+ continue;
634
+ }
635
+ if (this.isScrollProp(value)) {
636
+ if (isPartialReload && !named && !encloses) continue;
637
+ const resolved = await value.callback();
638
+ const isReset = resetProps.includes(path);
639
+ if (!isReset) {
640
+ const mergePath = `${path}.${value.wrapper}`;
641
+ if (prependIntent) resolution.prependProps.push(mergePath);
642
+ else resolution.mergeProps.push(mergePath);
643
+ if (value.matchOn) resolution.matchPropsOn.push(`${mergePath}.${value.matchOn}`);
408
644
  }
409
- if (value.matchOn) matchPropsOn.push(`${key}:${value.matchOn}`);
410
- resolvedProps[key] = await value.callback();
645
+ const metadata = value.metadata ? value.metadata(resolved) : deriveScrollMetadata(resolved);
646
+ resolution.scrollProps[path] = {
647
+ pageName: value.pageName ?? metadata.pageName,
648
+ currentPage: metadata.currentPage,
649
+ previousPage: metadata.previousPage,
650
+ nextPage: metadata.nextPage,
651
+ reset: isReset
652
+ };
653
+ into[key] = resolved;
411
654
  continue;
412
655
  }
413
656
  if (this.isOptionalProp(value)) {
414
- if (isPartialReload && this.isRequested(key, requestedProps)) resolvedProps[key] = await value.callback();
657
+ if (isPartialReload && (named || encloses)) into[key] = await value.callback();
658
+ continue;
659
+ }
660
+ if (!named && encloses && this.isPlainRecord(value)) {
661
+ const child = {};
662
+ await this.collectProps(value, request, resolution, child, path, false);
663
+ if (Object.keys(child).length > 0) into[key] = child;
664
+ continue;
665
+ }
666
+ if (!named || this.isExcepted(path, exceptProps)) continue;
667
+ if (this.isPlainRecord(value)) {
668
+ const child = {};
669
+ await this.collectProps(value, request, resolution, child, path, true);
670
+ into[key] = child;
415
671
  continue;
416
672
  }
417
- if (isPartialReload) {
418
- if (this.isRequested(key, requestedProps) && !this.isExcepted(key, exceptProps)) resolvedProps[key] = value;
419
- } else resolvedProps[key] = value;
673
+ into[key] = value;
420
674
  }
421
- return {
422
- resolvedProps,
423
- mergeProps,
424
- prependProps,
425
- deepMergeProps,
426
- matchPropsOn,
427
- deferredProps,
428
- onceProps
429
- };
430
675
  }
431
- /**
432
- * Check if a prop key is requested — supports dot-notation (e.g., `user.permissions`
433
- * matches the top-level `user` key).
434
- */
435
- isRequested(key, requestedProps) {
436
- return requestedProps.some((prop) => prop === key || prop.startsWith(`${key}.`));
676
+ /** Whether a plain `{}` record, as opposed to an array, a class instance or a prop marker. */
677
+ isPlainRecord(value) {
678
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
679
+ const prototype = Object.getPrototypeOf(value);
680
+ return prototype === Object.prototype || prototype === null;
681
+ }
682
+ /** Whether this path was asked for, or sits inside a path that was. */
683
+ isNamed(path, requestedProps) {
684
+ return requestedProps.some((prop) => prop === path || path.startsWith(`${prop}.`));
685
+ }
686
+ /** Whether something asked for sits inside this path. */
687
+ enclosesNamed(path, requestedProps) {
688
+ return requestedProps.some((prop) => prop.startsWith(`${path}.`));
437
689
  }
438
690
  isExcepted(key, exceptProps) {
439
691
  return exceptProps.some((prop) => prop === key || prop.startsWith(`${key}.`));
@@ -447,6 +699,9 @@ let InertiaService = class InertiaService {
447
699
  isMergeProp(value) {
448
700
  return typeof value === "object" && value !== null && INERTIA_PROP_MERGE in value;
449
701
  }
702
+ isScrollProp(value) {
703
+ return typeof value === "object" && value !== null && INERTIA_PROP_SCROLL in value;
704
+ }
450
705
  isOnceProp(value) {
451
706
  return typeof value === "object" && value !== null && INERTIA_PROP_ONCE in value;
452
707
  }
@@ -464,21 +719,12 @@ let InertiaService = class InertiaService {
464
719
  };
465
720
  return serialized;
466
721
  }
467
- isSsrDisabled(pathname) {
468
- const patterns = this.options.ssr?.disabled;
469
- if (!patterns || patterns.length === 0) return false;
470
- return patterns.some((pattern) => {
471
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
472
- return new RegExp(`^/${escaped.replace(/\*/g, "[^/]*")}$`).test(pathname);
473
- });
474
- }
475
722
  };
476
723
  InertiaService = __decorate([
477
724
  Request(INERTIA_TOKENS.InertiaService),
478
725
  __decorateParam(0, inject(INERTIA_TOKENS.Options)),
479
- __decorateParam(1, inject(INERTIA_TOKENS.TemplateService)),
480
- __decorateParam(2, inject(INERTIA_TOKENS.SsrRenderer)),
481
- __decorateParam(3, inject(INERTIA_TOKENS.SeoService))
726
+ __decorateParam(1, inject(INERTIA_TOKENS.DocumentRenderer)),
727
+ __decorateParam(2, inject(INERTIA_TOKENS.SeoService))
482
728
  ], InertiaService);
483
729
  //#endregion
484
730
  //#region src/services/manifest.service.ts
@@ -544,6 +790,16 @@ let SeoService = class SeoService {
544
790
  this.accumulated = mergeSeo(this.accumulated, data);
545
791
  }
546
792
  /**
793
+ * Whether anything has contributed SEO to this request.
794
+ *
795
+ * Told apart from "resolves to the defaults" so a caller rendering one page over another can
796
+ * keep the underlying page's metadata when the page on top named none of its own. Resolving
797
+ * regardless would answer with the module defaults and overwrite it.
798
+ */
799
+ contributed() {
800
+ return Object.keys(this.accumulated).length > 0;
801
+ }
802
+ /**
547
803
  * Resolves the final SEO data: module defaults (base) merged with the
548
804
  * request's accumulated data, then the title template applied. Resolver
549
805
  * functions for `defaults`/`titleTemplate` are awaited with the request `ctx`.
@@ -844,6 +1100,10 @@ InertiaModule = _InertiaModule = __decorate([Module({ providers: [
844
1100
  provide: INERTIA_TOKENS.SsrRenderer,
845
1101
  useClass: SsrRendererService
846
1102
  },
1103
+ {
1104
+ provide: INERTIA_TOKENS.DocumentRenderer,
1105
+ useClass: DocumentRendererService
1106
+ },
847
1107
  {
848
1108
  provide: INERTIA_TOKENS.HreflangService,
849
1109
  useClass: HreflangService
@@ -892,27 +1152,27 @@ var CookieFlashStore = class {
892
1152
  }
893
1153
  };
894
1154
  const inertiaResponse = {
895
- schema: z.object({
896
- component: z.string(),
897
- props: z.record(z.string(), z.unknown()),
898
- url: z.string(),
899
- version: z.string().nullable(),
900
- flash: z.record(z.string(), z.unknown()),
901
- rememberedState: z.record(z.string(), z.unknown()),
902
- mergeProps: z.array(z.string()).optional(),
903
- prependProps: z.array(z.string()).optional(),
904
- deepMergeProps: z.array(z.string()).optional(),
905
- matchPropsOn: z.array(z.string()).optional(),
906
- deferredProps: z.record(z.string(), z.array(z.string())).optional(),
907
- initialDeferredProps: z.record(z.string(), z.array(z.string())).optional(),
908
- onceProps: z.record(z.string(), z.object({
909
- prop: z.string(),
910
- expiresAt: z.number().nullable().optional()
911
- })).optional(),
912
- sharedProps: z.array(z.string()).optional(),
913
- encryptHistory: z.boolean().optional(),
914
- clearHistory: z.boolean().optional(),
915
- preserveFragment: z.boolean().optional()
1155
+ schema: object({
1156
+ component: string(),
1157
+ props: record(string(), unknown()),
1158
+ url: string(),
1159
+ version: nullable(string()),
1160
+ flash: record(string(), unknown()),
1161
+ rememberedState: record(string(), unknown()),
1162
+ mergeProps: optional(array(string())),
1163
+ prependProps: optional(array(string())),
1164
+ deepMergeProps: optional(array(string())),
1165
+ matchPropsOn: optional(array(string())),
1166
+ deferredProps: optional(record(string(), array(string()))),
1167
+ initialDeferredProps: optional(record(string(), array(string()))),
1168
+ onceProps: optional(record(string(), object({
1169
+ prop: string(),
1170
+ expiresAt: optional(nullable(number()))
1171
+ }))),
1172
+ sharedProps: optional(array(string())),
1173
+ encryptHistory: optional(boolean()),
1174
+ clearHistory: optional(boolean()),
1175
+ preserveFragment: optional(boolean())
916
1176
  }),
917
1177
  description: "Inertia page response",
918
1178
  contentType: "text/html"
@@ -1047,6 +1307,6 @@ let HandlePrecognitiveRequests = class HandlePrecognitiveRequests {
1047
1307
  };
1048
1308
  HandlePrecognitiveRequests = __decorate([Transient()], HandlePrecognitiveRequests);
1049
1309
  //#endregion
1050
- export { CookieFlashStore, DATA_SEO_ATTR, HandlePrecognitiveRequests, INERTIA_TOKENS, InertiaDelete, InertiaGet, InertiaMiddleware, InertiaModule, InertiaPatch, InertiaPost, InertiaPut, InertiaRoute, InertiaService, ManifestService, SeoService, SsrRendererService, TemplateService, buildSeoTags, descriptorToHtml };
1310
+ export { CookieFlashStore, DATA_SEO_ATTR, DocumentRendererService, HandlePrecognitiveRequests, INERTIA_SCROLL_MERGE_INTENT_HEADER, INERTIA_TOKENS, INERTIA_VARY_HEADERS, InertiaDelete, InertiaGet, InertiaMiddleware, InertiaModule, InertiaPatch, InertiaPost, InertiaPut, InertiaRoute, InertiaService, ManifestService, SeoService, SsrRendererService, TemplateService, UnrecognizedScrollShapeError, buildSeoTags, deriveScrollMetadata, descriptorToHtml };
1051
1311
 
1052
1312
  //# sourceMappingURL=index.mjs.map