@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.
- package/bin/site-runtime.mjs +1 -0
- package/docs/runtime-contract.md +147 -5
- package/package.json +1 -1
- package/scripts/verify.mjs +25 -1
- package/src/components/seo.tsx +49 -18
- package/src/config/preset.ts +43 -1
- package/src/config/site-module.ts +106 -0
- package/src/config/virtual-modules.d.ts +22 -0
- package/src/index.ts +3 -2
- package/src/layouts/LayoutCore.astro +50 -20
- package/src/lib/canonical-scope.ts +108 -0
- package/src/lib/folder.ts +33 -0
- package/src/lib/hreflang-derive.ts +118 -0
- package/src/lib/hreflang.ts +24 -101
- package/src/lib/locales.ts +16 -9
- package/src/lib/sitemap/index.ts +1 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +58 -36
- package/src/routes/index.ts +14 -4
- package/src/routes/llms-txt-injected.ts +11 -0
- package/src/routes/llms-txt.ts +103 -38
- package/src/routes/robots-txt.ts +35 -8
|
@@ -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
|
|
10
|
-
import {
|
|
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
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/lib/hreflang.ts
CHANGED
|
@@ -1,46 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export
|
|
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
|
-
}
|
package/src/lib/locales.ts
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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(
|
|
72
|
-
|
|
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
|
|
package/src/lib/sitemap/index.ts
CHANGED
|
@@ -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";
|