@stratal/inertia 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { t as __decorate } from "./decorate-CzXVx7ZH.mjs";
1
+ import { t as __decorate } from "./decorate-B7nr7eBl.mjs";
2
+ import { n as buildSeoTags, r as descriptorToHtml, t as DATA_SEO_ATTR } from "./build-seo-tags-DBsHKxX9.mjs";
2
3
  import { ApplicationError } from "stratal/errors";
3
4
  import { Module } from "stratal/module";
4
- import { Delete, Get, Patch, Post, Put, ROUTER_TOKENS, Route, RouterContext, SchemaValidationError } from "stratal/router";
5
- import { DI_TOKENS, Request, Singleton, Transient, inject } from "stratal/di";
5
+ import { Delete, Get, Patch, Post, Put, ROUTER_TOKENS, Route, RouterContext, SchemaValidationError, applyTrailingSlash } from "stratal/router";
6
+ import { CONTAINER_TOKEN, DI_TOKENS, Request, Singleton, Transient, inject } from "stratal/di";
6
7
  import { I18N_TOKENS } from "stratal/i18n";
7
8
  import { LOGGER_TOKENS } from "stratal/logger";
8
9
  import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
@@ -39,6 +40,12 @@ function augmentRouterContext(resolveService) {
39
40
  const flashOut = this.c.get("inertiaFlashOut");
40
41
  if (flashOut) flashOut[key] = value;
41
42
  });
43
+ RouterContext.macro("share", function(key, value) {
44
+ resolveService(this).share(key, value);
45
+ });
46
+ RouterContext.macro("seo", function(data) {
47
+ resolveService(this).seo(data);
48
+ });
42
49
  RouterContext.macro("withoutSsr", function() {
43
50
  this.c.set("withoutSsr", true);
44
51
  });
@@ -50,10 +57,12 @@ const INERTIA_TOKENS = {
50
57
  InertiaService: Symbol.for("stratal:inertia:service"),
51
58
  TemplateService: Symbol.for("stratal:inertia:template"),
52
59
  ManifestService: Symbol.for("stratal:inertia:manifest"),
53
- SsrRenderer: Symbol.for("stratal:inertia:ssr-renderer")
60
+ SsrRenderer: Symbol.for("stratal:inertia:ssr-renderer"),
61
+ HreflangService: Symbol.for("stratal:inertia:hreflang"),
62
+ SeoService: Symbol.for("stratal:inertia:seo")
54
63
  };
55
64
  //#endregion
56
- //#region \0@oxc-project+runtime@0.129.0/helpers/decorateParam.js
65
+ //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateParam.js
57
66
  function __decorateParam(paramIndex, decorator) {
58
67
  return function(target, key) {
59
68
  decorator(target, key, paramIndex);
@@ -105,6 +114,60 @@ let InertiaMiddleware = class InertiaMiddleware {
105
114
  };
106
115
  InertiaMiddleware = __decorate([Transient(), __decorateParam(0, inject(INERTIA_TOKENS.Options))], InertiaMiddleware);
107
116
  //#endregion
117
+ //#region src/services/hreflang.service.ts
118
+ let HreflangService = class HreflangService {
119
+ container;
120
+ constructor(container) {
121
+ this.container = container;
122
+ }
123
+ buildLinks(currentUrl) {
124
+ const i18n = this.container.tryResolve(I18N_TOKENS.Options);
125
+ if (!i18n) return [];
126
+ const locales = i18n.locales ?? ["en"];
127
+ if (locales.length < 2) return [];
128
+ const defaultLocale = i18n.defaultLocale ?? "en";
129
+ const trailingSlash = this.container.resolve(DI_TOKENS.Application).config.trailingSlash ?? "ignore";
130
+ const localeUrl = this.container.resolve(ROUTER_TOKENS.LocaleUrlService);
131
+ if (localeUrl.pathEnabled) return this.buildPathLinks(currentUrl, locales, defaultLocale, localeUrl, trailingSlash);
132
+ if ((i18n.detection && "strategy" in i18n.detection ? i18n.detection.strategy : void 0) === "querystring") return this.buildQuerystringLinks(currentUrl, locales, defaultLocale, trailingSlash);
133
+ return [];
134
+ }
135
+ buildPathLinks(url, locales, defaultLocale, localeUrl, trailingSlash) {
136
+ const basePath = localeUrl.stripPrefix(url.pathname);
137
+ const links = locales.map((locale) => this.linkTag(locale, this.compose(url, localeUrl.applyPrefix(basePath, locale), url.search, trailingSlash)));
138
+ links.push(this.linkTag("x-default", this.compose(url, localeUrl.applyPrefix(basePath, defaultLocale), url.search, trailingSlash)));
139
+ return links;
140
+ }
141
+ buildQuerystringLinks(url, locales, defaultLocale, trailingSlash) {
142
+ const params = new URLSearchParams(url.search);
143
+ params.delete("locale");
144
+ const baseQs = params.toString();
145
+ const links = locales.map((locale) => {
146
+ const qs = this.composeQuery(baseQs, locale === defaultLocale ? null : ["locale", locale]);
147
+ return this.linkTag(locale, this.compose(url, url.pathname, qs, trailingSlash));
148
+ });
149
+ const xDefaultQs = baseQs ? `?${baseQs}` : "";
150
+ links.push(this.linkTag("x-default", this.compose(url, url.pathname, xDefaultQs, trailingSlash)));
151
+ return links;
152
+ }
153
+ compose(url, pathname, search, mode) {
154
+ return applyTrailingSlash(url.origin + pathname + search, mode);
155
+ }
156
+ composeQuery(baseQs, extra) {
157
+ if (!extra) return baseQs ? `?${baseQs}` : "";
158
+ const tail = `${extra[0]}=${encodeURIComponent(extra[1])}`;
159
+ return baseQs ? `?${baseQs}&${tail}` : `?${tail}`;
160
+ }
161
+ linkTag(hreflang, href) {
162
+ return {
163
+ rel: "alternate",
164
+ hreflang,
165
+ href
166
+ };
167
+ }
168
+ };
169
+ HreflangService = __decorate([Singleton(), __decorateParam(0, inject(CONTAINER_TOKEN))], HreflangService);
170
+ //#endregion
108
171
  //#region src/types.ts
109
172
  const INERTIA_PROP_OPTIONAL = Symbol.for("stratal:inertia:prop:optional");
110
173
  const INERTIA_PROP_DEFERRED = Symbol.for("stratal:inertia:prop:deferred");
@@ -117,15 +180,20 @@ let InertiaService = class InertiaService {
117
180
  options;
118
181
  template;
119
182
  ssr;
183
+ seoService;
120
184
  sharedData = {};
121
- constructor(options, template, ssr) {
185
+ constructor(options, template, ssr, seoService) {
122
186
  this.options = options;
123
187
  this.template = template;
124
188
  this.ssr = ssr;
189
+ this.seoService = seoService;
125
190
  }
126
191
  share(key, value) {
127
192
  this.sharedData[key] = value;
128
193
  }
194
+ seo(data) {
195
+ this.seoService.set(data);
196
+ }
129
197
  location(url) {
130
198
  return new Response("", {
131
199
  status: 409,
@@ -172,12 +240,18 @@ let InertiaService = class InertiaService {
172
240
  const url = reqUrl.search ? `${reqUrl.pathname}${reqUrl.search}` : reqUrl.pathname;
173
241
  const isInertia = ctx.c.get("inertia");
174
242
  const { shared: resolvedShared, sharedKeys } = await this.resolveSharedData(ctx);
243
+ const resolvedSeo = await this.seoService.resolve(ctx);
175
244
  const allProps = {
176
245
  ...resolvedShared,
177
246
  ...this.sharedData,
247
+ seo: this.always(() => resolvedSeo),
178
248
  ...props
179
249
  };
180
- const allSharedKeys = [...sharedKeys, ...Object.keys(this.sharedData)];
250
+ const allSharedKeys = [
251
+ ...sharedKeys,
252
+ ...Object.keys(this.sharedData),
253
+ "seo"
254
+ ];
181
255
  const result = await this.processProps(allProps, ctx, component, isInertia);
182
256
  const { errors: flashErrors, ...flash } = ctx.c.get("inertiaFlash") ?? {};
183
257
  const errors = flashErrors && typeof flashErrors === "object" && !Array.isArray(flashErrors) ? flashErrors : {};
@@ -217,7 +291,8 @@ let InertiaService = class InertiaService {
217
291
  head: [],
218
292
  body: ""
219
293
  } : await this.ssr.render(page);
220
- const html = this.template.render(page, ssrResult.head, ssrResult.body);
294
+ const seoTags = this.seoService.tagsFor(resolvedSeo);
295
+ const html = this.template.render(page, [...ssrResult.head, ...seoTags], ssrResult.body);
221
296
  return new Response(html, {
222
297
  status,
223
298
  headers: { "Content-Type": "text/html; charset=utf-8" }
@@ -248,6 +323,7 @@ let InertiaService = class InertiaService {
248
323
  const uri = container.resolve(ROUTER_TOKENS.Uri);
249
324
  const name = registry.findNameByRoute(ctx.c.req.method, ctx.c.req.routePath) ?? null;
250
325
  const params = { ...ctx.param() };
326
+ const localePathService = container.resolve(ROUTER_TOKENS.LocalePathService);
251
327
  shared.routes = this.serializeRoutes(registry.named());
252
328
  shared.trailingSlash = application.config.trailingSlash ?? "ignore";
253
329
  shared.route = {
@@ -255,6 +331,10 @@ let InertiaService = class InertiaService {
255
331
  params,
256
332
  defaults: uri.getDefaults()
257
333
  };
334
+ shared.localeConfig = {
335
+ defaultLocale: localePathService.localePathConfig?.defaultLocale ?? null,
336
+ prefixDefaultLocale: localePathService.prefixDefaultLocale
337
+ };
258
338
  }
259
339
  return {
260
340
  shared,
@@ -393,7 +473,8 @@ InertiaService = __decorate([
393
473
  Request(INERTIA_TOKENS.InertiaService),
394
474
  __decorateParam(0, inject(INERTIA_TOKENS.Options)),
395
475
  __decorateParam(1, inject(INERTIA_TOKENS.TemplateService)),
396
- __decorateParam(2, inject(INERTIA_TOKENS.SsrRenderer))
476
+ __decorateParam(2, inject(INERTIA_TOKENS.SsrRenderer)),
477
+ __decorateParam(3, inject(INERTIA_TOKENS.SeoService))
397
478
  ], InertiaService);
398
479
  //#endregion
399
480
  //#region src/services/manifest.service.ts
@@ -437,6 +518,70 @@ hot.on("vite:afterUpdate", () => {
437
518
  };
438
519
  ManifestService = __decorate([Transient(), __decorateParam(0, inject(INERTIA_TOKENS.Options))], ManifestService);
439
520
  //#endregion
521
+ //#region src/services/seo.service.ts
522
+ let SeoService = class SeoService {
523
+ options;
524
+ hreflang;
525
+ accumulated = {};
526
+ constructor(options, hreflang) {
527
+ this.options = options;
528
+ this.hreflang = hreflang;
529
+ }
530
+ /** Merges the given metadata into the request's accumulated SEO data. */
531
+ set(data) {
532
+ this.accumulated = mergeSeo(this.accumulated, data);
533
+ }
534
+ /**
535
+ * Resolves the final SEO data: module defaults (base) merged with the
536
+ * request's accumulated data, then the title template applied. Resolver
537
+ * functions for `defaults`/`titleTemplate` are awaited with the request `ctx`.
538
+ * Locale-aware `hreflang` alternates are appended last so they ride the same
539
+ * head injection and SPA reconciliation as the rest of the SEO tags.
540
+ *
541
+ * The resolved `title` is ALWAYS a string (falling back to `''`). This makes
542
+ * the `<title>` descriptor deterministic: every navigation — including to a
543
+ * page with no SEO — produces a title, so the client head-sync sets
544
+ * `document.title` rather than leaving the previous page's title stale.
545
+ */
546
+ async resolve(ctx) {
547
+ const seo = this.options.seo;
548
+ const resolved = mergeSeo(typeof seo?.defaults === "function" ? await seo.defaults(ctx) : seo?.defaults ?? {}, this.accumulated);
549
+ const template = seo?.titleTemplate;
550
+ if (typeof template === "function") resolved.title = await template(resolved.title, ctx);
551
+ else if (typeof template === "string" && this.accumulated.title != null) resolved.title = template.split("%s").join(this.accumulated.title);
552
+ resolved.title ??= "";
553
+ const hreflang = this.hreflang.buildLinks(new URL(ctx.c.req.url));
554
+ if (hreflang.length > 0) resolved.link = [...resolved.link ?? [], ...hreflang];
555
+ return resolved;
556
+ }
557
+ /** Renders resolved SEO data into a list of head-tag HTML strings. */
558
+ tagsFor(resolved) {
559
+ return buildSeoTags(resolved).map(descriptorToHtml);
560
+ }
561
+ };
562
+ SeoService = __decorate([
563
+ Request(INERTIA_TOKENS.SeoService),
564
+ __decorateParam(0, inject(INERTIA_TOKENS.Options)),
565
+ __decorateParam(1, inject(INERTIA_TOKENS.HreflangService))
566
+ ], SeoService);
567
+ /** Merges `b` over `a`: `openGraph`/`twitter` shallow-merge, `meta`/`link` concat, scalars overwrite. */
568
+ function mergeSeo(a, b) {
569
+ return {
570
+ ...a,
571
+ ...b,
572
+ ...a.openGraph || b.openGraph ? { openGraph: {
573
+ ...a.openGraph,
574
+ ...b.openGraph
575
+ } } : {},
576
+ ...a.twitter || b.twitter ? { twitter: {
577
+ ...a.twitter,
578
+ ...b.twitter
579
+ } } : {},
580
+ ...a.meta || b.meta ? { meta: [...a.meta ?? [], ...b.meta ?? []] } : {},
581
+ ...a.link || b.link ? { link: [...a.link ?? [], ...b.link ?? []] } : {}
582
+ };
583
+ }
584
+ //#endregion
440
585
  //#region src/services/ssr-renderer.service.ts
441
586
  let SsrRendererService = class SsrRendererService {
442
587
  options;
@@ -498,10 +643,10 @@ let TemplateService = class TemplateService {
498
643
  const viteHead = this.manifest.getHeadTags();
499
644
  const viteScripts = this.manifest.getScriptTags();
500
645
  let html = this.options.rootView;
501
- html = html.replace("@inertiaHead", headTags);
502
- html = html.replace("@inertia", appHtml);
503
- html = html.replace("@viteHead", viteHead);
504
- html = html.replace("@viteScripts", viteScripts);
646
+ html = html.replace("@inertiaHead", () => headTags);
647
+ html = html.replace("@inertia", () => appHtml);
648
+ html = html.replace("@viteHead", () => viteHead);
649
+ html = html.replace("@viteScripts", () => viteScripts);
505
650
  return html;
506
651
  }
507
652
  buildClientOnlyBody(page) {
@@ -544,6 +689,7 @@ let InertiaModule = _InertiaModule = class InertiaModule {
544
689
  if (context.type !== "http") return void 0;
545
690
  if (this.isPrecognitionRequest(context)) return this.handlePrecognitionValidationError(error, context);
546
691
  if (!this.isInertiaRequest(context)) return void 0;
692
+ if (this.isReadRequest(context)) return void 0;
547
693
  const issues = error.issues ?? [];
548
694
  const errors = {};
549
695
  for (const issue of issues) errors[issue.path] = issue.message;
@@ -555,6 +701,7 @@ let InertiaModule = _InertiaModule = class InertiaModule {
555
701
  const message = error.message;
556
702
  if (this.isPrecognitionRequest(context)) return this.createPrecognitionErrorResponse({ _form: message });
557
703
  if (!this.isInertiaRequest(context)) return void 0;
704
+ if (this.isReadRequest(context)) return void 0;
558
705
  context.ctx.flash("errors", { _form: message });
559
706
  return this.redirectBack(context);
560
707
  });
@@ -577,6 +724,23 @@ let InertiaModule = _InertiaModule = class InertiaModule {
577
724
  isInertiaRequest(context) {
578
725
  return context.ctx.header("x-inertia") === "true";
579
726
  }
727
+ /**
728
+ * GET/HEAD requests are idempotent navigations — including Inertia deferred
729
+ * partial reloads, which fetch deferred props over a follow-up XHR that still
730
+ * carries `X-Inertia: true`.
731
+ *
732
+ * Such requests must NOT use the flash-errors + redirect-back convention: the
733
+ * redirect points back at the very URL that just threw, so an error raised
734
+ * while resolving a deferred prop would redirect → re-request → throw again
735
+ * in an infinite loop (`ERR_TOO_MANY_REDIRECTS`). For these we fall through to
736
+ * the errorPage pipeline, which renders `Errors/${status}` in place as an
737
+ * Inertia response. Redirect-back stays for mutations (POST/PUT/PATCH/DELETE),
738
+ * where it drives the post-submit form-error flow.
739
+ */
740
+ isReadRequest(context) {
741
+ const method = context.ctx.c.req.method.toUpperCase();
742
+ return method === "GET" || method === "HEAD";
743
+ }
580
744
  isPrecognitionRequest(context) {
581
745
  return context.ctx.header("precognition") === "true";
582
746
  }
@@ -637,6 +801,14 @@ InertiaModule = _InertiaModule = __decorate([Module({ providers: [
637
801
  {
638
802
  provide: INERTIA_TOKENS.SsrRenderer,
639
803
  useClass: SsrRendererService
804
+ },
805
+ {
806
+ provide: INERTIA_TOKENS.HreflangService,
807
+ useClass: HreflangService
808
+ },
809
+ {
810
+ provide: INERTIA_TOKENS.SeoService,
811
+ useClass: SeoService
640
812
  }
641
813
  ] })], InertiaModule);
642
814
  //#endregion
@@ -829,6 +1001,6 @@ let HandlePrecognitiveRequests = class HandlePrecognitiveRequests {
829
1001
  };
830
1002
  HandlePrecognitiveRequests = __decorate([Transient()], HandlePrecognitiveRequests);
831
1003
  //#endregion
832
- export { CookieFlashStore, HandlePrecognitiveRequests, INERTIA_TOKENS, InertiaDelete, InertiaGet, InertiaMiddleware, InertiaModule, InertiaPatch, InertiaPost, InertiaPut, InertiaRoute, InertiaService, ManifestService, SsrRendererService, TemplateService };
1004
+ export { CookieFlashStore, DATA_SEO_ATTR, HandlePrecognitiveRequests, INERTIA_TOKENS, InertiaDelete, InertiaGet, InertiaMiddleware, InertiaModule, InertiaPatch, InertiaPost, InertiaPut, InertiaRoute, InertiaService, ManifestService, SeoService, SsrRendererService, TemplateService, buildSeoTags, descriptorToHtml };
833
1005
 
834
1006
  //# sourceMappingURL=index.mjs.map