@iterant/site-runtime 3.11.2 → 3.13.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.
@@ -1,13 +1,16 @@
1
1
  ---
2
2
  import { Font, fontData } from "astro:assets";
3
+ import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
3
4
  import { getCollection, getEntry } from "astro:content";
4
5
  import type { AstroComponentFactory } from "astro/runtime/server/index.js";
5
6
  import RelatedLinks from "../components/RelatedLinks.astro";
6
7
  import { SEO, type PageType } from "../components/seo";
7
8
  import type { SeoJsonSchema } from "../components/seo-json";
8
9
  import { fontCssVariable } from "../fonts/catalog";
9
- import type { HreflangAlternate } from "../lib/hreflang";
10
- import { DEFAULT_LOCALE, localeFromPath } from "../lib/locales";
10
+ import { inCanonicalScope, originForPath } from "../lib/canonical-scope";
11
+ import { folderPath } from "../lib/folder";
12
+ import type { HreflangAlternate } from "../lib/hreflang-derive";
13
+ import { DEFAULT_LOCALE, localeFromPath, parseEntryId } from "../lib/locales";
11
14
  import { SITE_RUNTIME_VERSION } from "../version";
12
15
  import {
13
16
  canonicalUrlFor,
@@ -101,37 +104,53 @@ const {
101
104
 
102
105
  const site = Astro.site;
103
106
 
107
+ // Which origin this page's absolute URLs name (3.13.0). Under folder scope the
108
+ // baked `site` is the CUSTOMER's domain, and it forwards the brand folder
109
+ // alone: a page outside it is served on the platform's own host, so that is
110
+ // what its canonical, og:url and og:image have to say. Under host scope this is
111
+ // `site` for every path, byte for byte what the head carried before.
112
+ const scope = {
113
+ name: CANONICAL_SCOPE,
114
+ folder: FOLDER,
115
+ platformSite: PLATFORM_SITE,
116
+ };
117
+ const pageOrigin = originForPath(Astro.url.pathname, scope, site);
118
+
104
119
  const canonicalUrl = canonicalUrlFor({
105
120
  canonical,
106
121
  pathname: Astro.url.pathname,
107
- site,
122
+ site: pageOrigin,
123
+ });
124
+ const imageUrl = resolveAbsoluteUrl(image, pageOrigin);
125
+
126
+ // Structured data (starter 2.17.0): resolve the current page's entry by
127
+ // route, the same match the catch-all makes, so meta.pageType and the
128
+ // article dates reach the JSON-LD graph without every bespoke shell having
129
+ // to thread them. Shells that pass the props explicitly still win. Routes
130
+ // without an entry (404, under-construction) fall back to a plain WebPage.
131
+ const routePath = routePathFromPathname(Astro.url.pathname);
132
+ const pageEntry = findPageEntryByRoute(await getCollection("pages"), routePath, {
133
+ includeDrafts: !import.meta.env.PROD,
108
134
  });
109
- const imageUrl = resolveAbsoluteUrl(image, site);
110
135
 
111
136
  // Site-level chrome (src/content/chrome.json). Resolved by component id so an
112
137
  // absent navbar/footer renders NO chrome — the template ships chrome.json
113
138
  // empty, so this is a byte-identical no-op until a brand populates it.
114
139
  // getEntry (not getChromeProps) because we need to distinguish an absent
115
140
  // component from empty props to drive the conditional mount.
116
- // Locale-aware (starter 2.8.0): a request under a locale prefix (/es/…)
117
- // mounts chrome.<locale>.json when that sibling exists, falling back to the
118
- // base chrome — pages translate before chrome does, so a missing sibling
119
- // must never strip the nav.
120
- const locale = localeFromPath(Astro.url.pathname);
141
+ // Locale-aware (starter 2.8.0): a translated request mounts chrome.<locale>.json
142
+ // when that sibling exists, falling back to the base chrome — pages translate
143
+ // before chrome does, so a missing sibling must never strip the nav. The locale
144
+ // is the page entry's OWN (`pricing.es`), so it does not depend on where the
145
+ // locale sits in the path; a route with no entry reads the path instead.
146
+ const locale =
147
+ (pageEntry ? parseEntryId(pageEntry.id).locale : undefined) ??
148
+ localeFromPath(Astro.url.pathname, FOLDER);
121
149
  const chromeEntry =
122
150
  (locale ? await getEntry("chrome", `chrome.${locale}`) : undefined) ??
123
151
  (await getEntry("chrome", "chrome"));
124
152
  const { navbar, footer } = pickChromeComponents(chromeEntry?.data.components);
125
153
 
126
- // Structured data (starter 2.17.0): resolve the current page's entry by
127
- // route, the same match the catch-all makes, so meta.pageType and the
128
- // article dates reach the JSON-LD graph without every bespoke shell having
129
- // to thread them. Shells that pass the props explicitly still win. Routes
130
- // without an entry (404, under-construction) fall back to a plain WebPage.
131
- const routePath = routePathFromPathname(Astro.url.pathname);
132
- const pageEntry = findPageEntryByRoute(await getCollection("pages"), routePath, {
133
- includeDrafts: !import.meta.env.PROD,
134
- });
135
154
  const structuredData = resolveStructuredData(
136
155
  { pageType, datePublished, dateModified },
137
156
  pageEntry?.data.meta,
@@ -147,7 +166,13 @@ const shellId = shell ?? pageEntry?.data.shell;
147
166
  // Kept out of search, from the same entry, so the page's own noindex reaches
148
167
  // <meta robots> through a catch-all that threads nothing. A shell that passes
149
168
  // the prop wins, like shell does; no entry and no prop means indexed.
150
- const noindexResolved = noindex ?? pageEntry?.data.noindex ?? false;
169
+ //
170
+ // A page outside the canonical scope is kept out regardless (3.13.0): it is a
171
+ // platform-only route, reachable on our host and on no customer domain, so
172
+ // indexing it is how the same content ends up in search twice.
173
+ const noindexResolved =
174
+ !inCanonicalScope(Astro.url.pathname, scope) ||
175
+ (noindex ?? pageEntry?.data.noindex ?? false);
151
176
 
152
177
  // Machine-fed related links (ILV-6): the machine-owned src/content/links.json
153
178
  // manifest, resolved by this page's own route key. A locale sibling looks up
@@ -215,7 +240,11 @@ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false
215
240
  <head>
216
241
  <meta charset="UTF-8" />
217
242
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
218
- <link rel="icon" type="image/x-icon" href="/favicon.ico" />
243
+ <link
244
+ rel="icon"
245
+ type="image/x-icon"
246
+ href={folderPath(FOLDER, "favicon.ico")}
247
+ />
219
248
  <meta name="generator" content={Astro.generator} />
220
249
  <meta name="it-site-runtime" content={SITE_RUNTIME_VERSION} />
221
250
  <meta name="it-astro-starter-version" content={SITE_RUNTIME_VERSION} />
@@ -238,6 +267,7 @@ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false
238
267
  siteName={siteName}
239
268
  organization={{ logo: siteConfig.logo, sameAs: siteConfig.sameAs }}
240
269
  jsonLd={jsonLd}
270
+ scope={scope}
241
271
  />
242
272
  {
243
273
  hreflang.map((alt) => (
@@ -0,0 +1,108 @@
1
+ // Which paths the origin in `astro.config.mjs` actually serves.
2
+ //
3
+ // The production deploy bakes the CUSTOMER's origin into `site`, and in path
4
+ // routing the customer forwards one prefix and nothing else: `/<folder>/*`.
5
+ // Translations live under that same prefix (`/<folder>/<locale>/x`), so the one
6
+ // rule covers them too.
7
+ // Every other route of the build (the brand home at `/`, the holding page,
8
+ // anything outside the folder) lives on the platform's own host alone. A
9
+ // canonical, an og:url or a sitemap `<loc>` that names the customer's origin
10
+ // for one of those points at a URL their edge answers with their own site,
11
+ // which is how a link preview sends a reader to the wrong page.
12
+ //
13
+ // `host` is every build that came before this one: the origin serves the whole
14
+ // site and nothing here changes a byte of what it emits.
15
+
16
+ export type CanonicalScopeName = "host" | "folder";
17
+
18
+ export interface CanonicalScope {
19
+ name: CanonicalScopeName;
20
+ /** The one segment the customer forwards. Empty under `host`. */
21
+ folder: string;
22
+ /** The origin an out-of-scope path resolves against. Null under `host`. */
23
+ platformSite: string | null;
24
+ }
25
+
26
+ /** The default, and what every build declared before the scope existed. */
27
+ export const HOST_SCOPE: CanonicalScope = {
28
+ name: "host",
29
+ folder: "",
30
+ platformSite: null,
31
+ };
32
+
33
+ /** The build's environment, where the deploy states the scope. The index
34
+ * signature is what lets `process.env` be passed straight in. */
35
+ export interface CanonicalScopeEnv {
36
+ ITERANT_CANONICAL_SCOPE?: string;
37
+ ITERANT_PLATFORM_SITE?: string;
38
+ [key: string]: string | undefined;
39
+ }
40
+
41
+ /**
42
+ * The scope this build runs under, from the deploy's environment and the
43
+ * folder the preset was given.
44
+ *
45
+ * Folder scope with no folder, or with no platform origin to send the rest of
46
+ * the site to, is a deploy that would emit customer URLs for pages the customer
47
+ * never serves. There is no safe reading of it, so the build stops here rather
48
+ * than at the first crawler.
49
+ */
50
+ export function readCanonicalScope(
51
+ env: CanonicalScopeEnv,
52
+ folder: string,
53
+ ): CanonicalScope {
54
+ const name = env.ITERANT_CANONICAL_SCOPE?.trim() || "host";
55
+ if (name !== "host" && name !== "folder") {
56
+ throw new Error(
57
+ `ITERANT_CANONICAL_SCOPE must be "host" or "folder" (got ${JSON.stringify(name)})`,
58
+ );
59
+ }
60
+ if (name === "host") return HOST_SCOPE;
61
+ const platformSite = env.ITERANT_PLATFORM_SITE?.trim() ?? "";
62
+ if (!folder) {
63
+ throw new Error(
64
+ "ITERANT_CANONICAL_SCOPE=folder needs a folder: pass the brand's folder to iterantStarter({ folder })",
65
+ );
66
+ }
67
+ if (!platformSite) {
68
+ throw new Error(
69
+ "ITERANT_CANONICAL_SCOPE=folder needs ITERANT_PLATFORM_SITE, the origin that serves the routes the customer's domain does not",
70
+ );
71
+ }
72
+ if (!URL.canParse(platformSite)) {
73
+ throw new Error(
74
+ `ITERANT_PLATFORM_SITE must be an absolute origin (got ${JSON.stringify(platformSite)})`,
75
+ );
76
+ }
77
+ return { name, folder, platformSite: new URL(platformSite).origin };
78
+ }
79
+
80
+ /**
81
+ * Whether the build's `site` origin serves this path. Always true under `host`.
82
+ *
83
+ * The folder is matched SEGMENT-wise, never as a string prefix, so `/feedback`
84
+ * is out of scope for the folder `feed`. The locale needs no test of its own:
85
+ * a translation lives under the folder like every other page.
86
+ */
87
+ export function inCanonicalScope(
88
+ pathname: string,
89
+ scope: CanonicalScope,
90
+ ): boolean {
91
+ if (scope.name === "host") return true;
92
+ const segments = pathname.split("/").filter(Boolean);
93
+ return segments[0] === scope.folder;
94
+ }
95
+
96
+ /**
97
+ * The origin a path's absolute URL resolves against: the build's `site` when
98
+ * the path is in scope, the platform origin when it is not. Under `host` it is
99
+ * always `site`, so every emitter that calls this keeps emitting what it did.
100
+ */
101
+ export function originForPath<T extends URL | string | undefined>(
102
+ pathname: string,
103
+ scope: CanonicalScope,
104
+ site: T,
105
+ ): T | string {
106
+ if (!scope.platformSite || inCanonicalScope(pathname, scope)) return site;
107
+ return scope.platformSite;
108
+ }
@@ -0,0 +1,33 @@
1
+ // The brand's folder: the one path segment every platform file a build emits
2
+ // lives under, so the customer's edge forwards `example.com/feed/*` and never
3
+ // needs a second rule. Pages are untouched: they keep their public paths, and a
4
+ // site with no folder emits exactly what it emitted before folders existed.
5
+ //
6
+ // The value is the backend's `brand.iterant_subfolder`, written into the repo's
7
+ // site config beside `fonts` and handed to `iterantStarter({ folder })`. One
8
+ // owner, one copy, never inferred from a path.
9
+
10
+ // The backend's own rule, character for character (backend/brands/folder.py):
11
+ // letters, digits and hyphens, so the folder is one level deep and can never
12
+ // shadow a platform file name. The reserved names and the locale-shape refusal
13
+ // stay at the door in Django, which owns the value; what a build can still be
14
+ // handed is a bad shape, and a bad shape emits asset URLs nobody can fetch and a
15
+ // sitemap nobody can find, so the build refuses it instead.
16
+ const FOLDER_SEGMENT = /^[a-z0-9-]+$/;
17
+
18
+ export function assertFolder(folder: string): string {
19
+ if (!FOLDER_SEGMENT.test(folder)) {
20
+ throw new Error(
21
+ `iterantStarter: folder must be one lower-case segment of letters, digits and hyphens, no slashes (got ${JSON.stringify(folder)})`,
22
+ );
23
+ }
24
+ return folder;
25
+ }
26
+
27
+ /** A platform file's public path: `/feed/favicon.ico` under a folder,
28
+ * `/favicon.ico` without one. The folder is validated here too, because this is
29
+ * where it becomes a URL: a value like `/evil.example` must never get that far,
30
+ * wherever it was read. */
31
+ export function folderPath(folder: string | undefined, file: string): string {
32
+ return folder ? `/${assertFolder(folder)}/${file}` : `/${file}`;
33
+ }
@@ -0,0 +1,118 @@
1
+ import { isAdvertised } from "./advertised";
2
+ import { originForPath, type CanonicalScope } from "./canonical-scope";
3
+ import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
4
+
5
+ // hreflang alternates for locale sibling pages (starter 2.9.0). A page with
6
+ // locale siblings advertises one `<link rel="alternate" hreflang>` per
7
+ // NON-DRAFT sibling in the group, plus exactly one `x-default` pointing at the
8
+ // unsuffixed base entry. A page with no siblings emits nothing. Ported from the
9
+ // legacy `derive_hreflang_set` (backend translation_group.py): published/
10
+ // indexable rows only (starter: non-draft), one x-default = the default-locale
11
+ // row, deduped on a normalized locale key, hreflang values in canonical BCP-47.
12
+ //
13
+ // The DERIVATION alone lives here, so it imports no virtual module and the
14
+ // package's pure entry can re-export it. The scope is an argument rather than a
15
+ // default: a caller that does not know which origin serves which path cannot
16
+ // spell these hrefs, and a default would let it emit customer URLs for pages
17
+ // the customer never serves. ./hreflang.ts is where that caller reads it.
18
+
19
+ /** One reciprocal hreflang alternate. `hreflang` is BCP-47 or "x-default". */
20
+ export interface HreflangAlternate {
21
+ hreflang: string;
22
+ href: string;
23
+ }
24
+
25
+ /** The minimal page-entry shape the derivation reads. */
26
+ export interface HreflangEntry {
27
+ /** Collection entry id (`example`, `example.es`). */
28
+ id: string;
29
+ /** The entry's `route` field (`/example`, `/es/example`). */
30
+ route: string;
31
+ draft: boolean;
32
+ /** Kept out of search; never advertised as an alternate. */
33
+ noindex?: boolean;
34
+ }
35
+
36
+ /** A `pages` collection entry, as `pageLocaleHead` consumes it. */
37
+ export interface PagesCollectionEntry {
38
+ id: string;
39
+ data: { route: string; draft: boolean; noindex?: boolean };
40
+ }
41
+
42
+ /**
43
+ * The `<html lang>` value for a page, from its entry id: base entries are the
44
+ * brand default (`en`); siblings carry their locale in canonical BCP-47
45
+ * casing (`example.pt-br` → `pt-BR`).
46
+ */
47
+ export function pageLang(entryId: string): string {
48
+ const { locale } = parseEntryId(entryId);
49
+ return locale ? normalizeBcp47(locale) : DEFAULT_LOCALE;
50
+ }
51
+
52
+ function absolute(
53
+ route: string,
54
+ site: URL | string | undefined,
55
+ scope: CanonicalScope,
56
+ ): string {
57
+ if (!site) return route; // dev without a configured `site`, relative href
58
+ return new URL(
59
+ route,
60
+ originForPath(route, scope, site).toString(),
61
+ ).toString();
62
+ }
63
+
64
+ /**
65
+ * The reciprocal hreflang set for the group `baseName` belongs to, derived from
66
+ * ALL page entries (the caller passes the whole collection). Every non-draft
67
+ * entry in the group contributes one alternate; the base entry also seeds the
68
+ * single `x-default`. Draft and noindex siblings are never advertised
69
+ * (unpublished translations and private pages must not leak to crawlers). Deduped on the normalized locale key
70
+ * (first entry wins); hrefs absolute via `site` (`Astro.site`), or via the
71
+ * platform origin for a route that site does not serve. Returns `[]`
72
+ * when the group has no non-draft siblings — a lone page emits no hreflang.
73
+ */
74
+ export function deriveHreflangAlternates(params: {
75
+ entries: HreflangEntry[];
76
+ baseName: string;
77
+ site: URL | string | undefined;
78
+ /** Which paths the site origin serves. */
79
+ scope: CanonicalScope;
80
+ }): HreflangAlternate[] {
81
+ const { entries, baseName, site, scope } = params;
82
+
83
+ const byKey = new Map<
84
+ string,
85
+ { tag: string; route: string; isBase: boolean }
86
+ >();
87
+ let base: { route: string } | undefined;
88
+ for (const entry of entries) {
89
+ // Never advertise a draft or a noindex sibling or base.
90
+ if (!isAdvertised(entry, { includeDrafts: false })) continue;
91
+ const { base: entryBase, locale } = parseEntryId(entry.id);
92
+ if (entryBase !== baseName) continue;
93
+ const isBase = !locale;
94
+ const tag = isBase ? DEFAULT_LOCALE : normalizeBcp47(locale);
95
+ const key = tag.toLowerCase();
96
+ if (isBase) base = { route: entry.route };
97
+ if (!byKey.has(key)) byKey.set(key, { tag, route: entry.route, isBase });
98
+ }
99
+
100
+ const hasSibling = [...byKey.values()].some((alt) => !alt.isBase);
101
+ if (!hasSibling) return [];
102
+
103
+ const ordered = [...byKey.values()].sort((a, b) => {
104
+ if (a.isBase !== b.isBase) return a.isBase ? -1 : 1; // base first
105
+ return a.tag.localeCompare(b.tag);
106
+ });
107
+ const alternates: HreflangAlternate[] = ordered.map((alt) => ({
108
+ hreflang: alt.tag,
109
+ href: absolute(alt.route, site, scope),
110
+ }));
111
+ if (base) {
112
+ alternates.push({
113
+ hreflang: "x-default",
114
+ href: absolute(base.route, site, scope),
115
+ });
116
+ }
117
+ return alternates;
118
+ }
@@ -1,46 +1,22 @@
1
- import { isAdvertised } from "./advertised";
2
- import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
3
-
4
- // hreflang alternates for locale sibling pages (starter 2.9.0). A page with
5
- // locale siblings advertises one `<link rel="alternate" hreflang>` per
6
- // NON-DRAFT sibling in the group, plus exactly one `x-default` pointing at the
7
- // unsuffixed base entry. A page with no siblings emits nothing. Ported from the
8
- // legacy `derive_hreflang_set` (backend translation_group.py): published/
9
- // indexable rows only (starter: non-draft), one x-default = the default-locale
10
- // row, deduped on a normalized locale key, hreflang values in canonical BCP-47.
11
-
12
- /** One reciprocal hreflang alternate. `hreflang` is BCP-47 or "x-default". */
13
- export interface HreflangAlternate {
14
- hreflang: string;
15
- href: string;
16
- }
17
-
18
- /** The minimal page-entry shape the derivation reads. */
19
- export interface HreflangEntry {
20
- /** Collection entry id (`example`, `example.es`). */
21
- id: string;
22
- /** The entry's `route` field (`/example`, `/es/example`). */
23
- route: string;
24
- draft: boolean;
25
- /** Kept out of search; never advertised as an alternate. */
26
- noindex?: boolean;
27
- }
28
-
29
- /** A `pages` collection entry, as `pageLocaleHead` consumes it. */
30
- export interface PagesCollectionEntry {
31
- id: string;
32
- data: { route: string; draft: boolean; noindex?: boolean };
33
- }
34
-
35
- /**
36
- * The `<html lang>` value for a page, from its entry id: base entries are the
37
- * brand default (`en`); siblings carry their locale in canonical BCP-47
38
- * casing (`example.pt-br` → `pt-BR`).
39
- */
40
- export function pageLang(entryId: string): string {
41
- const { locale } = parseEntryId(entryId);
42
- return locale ? normalizeBcp47(locale) : DEFAULT_LOCALE;
43
- }
1
+ import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
2
+
3
+ import {
4
+ deriveHreflangAlternates,
5
+ pageLang,
6
+ type HreflangAlternate,
7
+ type PagesCollectionEntry,
8
+ } from "./hreflang-derive";
9
+ import { parseEntryId } from "./locales";
10
+
11
+ // The locale head a page renders, as a repo calls it
12
+ // (`@iterant/site-runtime/hreflang`). It is the derivation in
13
+ // ./hreflang-derive.ts plus the one fact a repo cannot supply: which paths the
14
+ // baked `site` origin actually serves. That comes from the preset, through the
15
+ // virtual module the layout and the platform routes already read, so a page
16
+ // under a customer's forwarded folder advertises its siblings on their domain
17
+ // and a page outside it advertises them on ours. No repo has to know, and none
18
+ // can get it wrong.
19
+ export * from "./hreflang-derive";
44
20
 
45
21
  /**
46
22
  * The locale head pair every page emits (starter 2.10.0): `<html lang>` plus
@@ -65,64 +41,11 @@ export function pageLocaleHead(params: {
65
41
  })),
66
42
  baseName: parseEntryId(params.entryId).base,
67
43
  site: params.site,
44
+ scope: {
45
+ name: CANONICAL_SCOPE,
46
+ folder: FOLDER,
47
+ platformSite: PLATFORM_SITE,
48
+ },
68
49
  }),
69
50
  };
70
51
  }
71
-
72
- function absolute(route: string, site: URL | string | undefined): string {
73
- if (!site) return route; // dev without a configured `site` — relative href
74
- return new URL(route, site.toString()).toString();
75
- }
76
-
77
- /**
78
- * The reciprocal hreflang set for the group `baseName` belongs to, derived from
79
- * ALL page entries (the caller passes the whole collection). Every non-draft
80
- * entry in the group contributes one alternate; the base entry also seeds the
81
- * single `x-default`. Draft and noindex siblings are never advertised
82
- * (unpublished translations and private pages must not leak to crawlers). Deduped on the normalized locale key
83
- * (first entry wins); hrefs absolute via `site` (`Astro.site`). Returns `[]`
84
- * when the group has no non-draft siblings — a lone page emits no hreflang.
85
- */
86
- export function deriveHreflangAlternates(params: {
87
- entries: HreflangEntry[];
88
- baseName: string;
89
- site: URL | string | undefined;
90
- }): HreflangAlternate[] {
91
- const { entries, baseName, site } = params;
92
-
93
- const byKey = new Map<
94
- string,
95
- { tag: string; route: string; isBase: boolean }
96
- >();
97
- let base: { route: string } | undefined;
98
- for (const entry of entries) {
99
- // Never advertise a draft or a noindex sibling or base.
100
- if (!isAdvertised(entry, { includeDrafts: false })) continue;
101
- const { base: entryBase, locale } = parseEntryId(entry.id);
102
- if (entryBase !== baseName) continue;
103
- const isBase = !locale;
104
- const tag = isBase ? DEFAULT_LOCALE : normalizeBcp47(locale);
105
- const key = tag.toLowerCase();
106
- if (isBase) base = { route: entry.route };
107
- if (!byKey.has(key)) byKey.set(key, { tag, route: entry.route, isBase });
108
- }
109
-
110
- const hasSibling = [...byKey.values()].some((alt) => !alt.isBase);
111
- if (!hasSibling) return [];
112
-
113
- const ordered = [...byKey.values()].sort((a, b) => {
114
- if (a.isBase !== b.isBase) return a.isBase ? -1 : 1; // base first
115
- return a.tag.localeCompare(b.tag);
116
- });
117
- const alternates: HreflangAlternate[] = ordered.map((alt) => ({
118
- hreflang: alt.tag,
119
- href: absolute(alt.route, site),
120
- }));
121
- if (base) {
122
- alternates.push({
123
- hreflang: "x-default",
124
- href: absolute(base.route, site),
125
- });
126
- }
127
- return alternates;
128
- }
@@ -2,8 +2,9 @@ import { z } from "astro/zod";
2
2
 
3
3
  // Locale sibling entries (starter 2.8.0): a translated page is a first-class
4
4
  // content entry next to its base — `src/content/pages/<page>.<locale>.json`
5
- // with the lowercased locale as both the filename suffix and the root-level
6
- // route prefix (`/es/pricing`); the unsuffixed base stays the x-default.
5
+ // with the lowercased locale as both the filename suffix and the route segment
6
+ // the translation lives under (`/es/pricing`, or `/feed/es/pricing` under a
7
+ // brand folder); the unsuffixed base stays the x-default.
7
8
  // Site chrome follows the same model (`src/content/chrome.<locale>.json`),
8
9
  // resolved per request path with fallback to the base chrome. `<html lang>`
9
10
  // and hreflang alternates are derived from the entry locale (see
@@ -62,14 +63,20 @@ export function normalizeBcp47(locale: string): string {
62
63
  }
63
64
 
64
65
  /**
65
- * The locale segment a request path is prefixed with, if any: `/es/pricing`
66
- * → `es`, `/pt-br` → `pt-br`, `/pricing` → undefined. Grammar-only the
67
- * caller decides whether a matching sibling (chrome.<locale>.json) actually
68
- * exists and falls back to the base otherwise, so a page that merely LOOKS
69
- * locale-shaped costs nothing.
66
+ * The locale segment a request path carries, if any: `/es/pricing` → `es`,
67
+ * `/pt-br` → `pt-br`, `/pricing` → undefined. Under a brand folder the locale
68
+ * sits inside it, so `localeFromPath("/feed/es/pricing", "feed")` is `es`:
69
+ * that is what lets the customer forward `/feed/*` alone and still reach every
70
+ * translation. Grammar-only the caller decides whether a matching sibling
71
+ * (chrome.<locale>.json) actually exists and falls back to the base otherwise,
72
+ * so a page that merely LOOKS locale-shaped costs nothing.
70
73
  */
71
- export function localeFromPath(pathname: string): string | undefined {
72
- const [first] = pathname.split("/").filter(Boolean);
74
+ export function localeFromPath(
75
+ pathname: string,
76
+ folder?: string,
77
+ ): string | undefined {
78
+ const segments = pathname.split("/").filter(Boolean);
79
+ const first = segments[folder && segments[0] === folder ? 1 : 0];
73
80
  return first && LOCALE_SEGMENT_RE.test(first) ? first : undefined;
74
81
  }
75
82
 
@@ -5,6 +5,7 @@
5
5
  export { DEFAULT_PAGES_DIR } from "../content-paths";
6
6
  export { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
7
7
  export {
8
+ createSitemapFilter,
8
9
  sitemapWithCustomPages,
9
10
  type SitemapWithCustomPagesOptions,
10
11
  } from "./sitemap-with-custom-pages-plugin";