@iterant/site-runtime 3.12.0 → 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 +100 -11
- package/package.json +1 -1
- package/scripts/verify.mjs +25 -1
- package/src/components/seo.tsx +49 -18
- package/src/config/preset.ts +8 -1
- package/src/config/site-module.ts +17 -8
- package/src/config/virtual-modules.d.ts +6 -0
- package/src/index.ts +3 -2
- package/src/layouts/LayoutCore.astro +44 -20
- package/src/lib/canonical-scope.ts +108 -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 +57 -12
- package/src/routes/index.ts +6 -1
- package/src/routes/llms-txt.ts +98 -39
- package/src/routes/robots-txt.ts +23 -7
|
@@ -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,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";
|
|
@@ -2,6 +2,11 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import sitemap, { type SitemapOptions } from "@astrojs/sitemap";
|
|
3
3
|
import type { AstroIntegration } from "astro";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
HOST_SCOPE,
|
|
7
|
+
inCanonicalScope,
|
|
8
|
+
type CanonicalScope,
|
|
9
|
+
} from "../canonical-scope";
|
|
5
10
|
import { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
|
|
6
11
|
|
|
7
12
|
// The platform patches the `site:` literal at deploy time, so we emit URLs against a
|
|
@@ -14,29 +19,69 @@ const PLACEHOLDER = "https://starter.invalid";
|
|
|
14
19
|
// names them here.
|
|
15
20
|
const PLATFORM_DENY = new Set(["/under-construction"]);
|
|
16
21
|
|
|
17
|
-
/** Whether a sitemap URL may be listed:
|
|
18
|
-
* entry route (a draft or a noindex page),
|
|
19
|
-
* found it.
|
|
22
|
+
/** Whether a sitemap URL may be listed: in the canonical scope, and neither a
|
|
23
|
+
* platform route nor a denied entry route (a draft or a noindex page),
|
|
24
|
+
* whichever way the integration found it. An out-of-scope path is not served
|
|
25
|
+
* on the origin the `<loc>` would name, so listing it points a crawler at the
|
|
26
|
+
* customer's own site. */
|
|
20
27
|
export function sitemapPageAllowed(
|
|
21
28
|
url: string,
|
|
22
29
|
deny: ReadonlySet<string> = new Set(),
|
|
30
|
+
scope: CanonicalScope = HOST_SCOPE,
|
|
23
31
|
): boolean {
|
|
32
|
+
const pathname = sitemapPath(url);
|
|
33
|
+
if (pathname === undefined) return true;
|
|
34
|
+
return (
|
|
35
|
+
!PLATFORM_DENY.has(pathname) &&
|
|
36
|
+
!deny.has(pathname) &&
|
|
37
|
+
inCanonicalScope(pathname, scope)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A candidate's path in entry-route form, or undefined when it is not a URL
|
|
42
|
+
* the integration can read. */
|
|
43
|
+
function sitemapPath(url: string): string | undefined {
|
|
24
44
|
try {
|
|
25
|
-
|
|
26
|
-
return !PLATFORM_DENY.has(pathname) && !deny.has(pathname);
|
|
45
|
+
return new URL(url).pathname.replace(/\/+$/, "") || "/";
|
|
27
46
|
} catch {
|
|
28
|
-
return
|
|
47
|
+
return undefined;
|
|
29
48
|
}
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The integration's page filter: the rule above, plus first-one-wins on a
|
|
53
|
+
* repeated path.
|
|
54
|
+
*
|
|
55
|
+
* @astrojs/sitemap discovers the prerendered pages itself AND is handed the
|
|
56
|
+
* entry routes as customPages, so a bespoke page that also has a page entry
|
|
57
|
+
* arrives twice and is listed twice. One pass over the candidates, so the state
|
|
58
|
+
* is the filter's own and a new build gets a new one.
|
|
59
|
+
*/
|
|
60
|
+
export function createSitemapFilter(
|
|
61
|
+
deny: ReadonlySet<string> = new Set(),
|
|
62
|
+
scope: CanonicalScope = HOST_SCOPE,
|
|
63
|
+
): (url: string) => boolean {
|
|
64
|
+
const listed = new Set<string>();
|
|
65
|
+
return (url) => {
|
|
66
|
+
if (!sitemapPageAllowed(url, deny, scope)) return false;
|
|
67
|
+
const pathname = sitemapPath(url) ?? url;
|
|
68
|
+
if (listed.has(pathname)) return false;
|
|
69
|
+
listed.add(pathname);
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
export type SitemapWithCustomPagesOptions = SitemapOptions &
|
|
33
|
-
SitemapPathsOptions
|
|
75
|
+
SitemapPathsOptions & {
|
|
76
|
+
/** Which paths the site origin serves. Defaults to the whole host. */
|
|
77
|
+
scope?: CanonicalScope;
|
|
78
|
+
};
|
|
34
79
|
|
|
35
80
|
export function sitemapWithCustomPages(
|
|
36
81
|
options: SitemapWithCustomPagesOptions = {},
|
|
37
82
|
): AstroIntegration[] {
|
|
38
83
|
let resolvedSite = "";
|
|
39
|
-
const { pagesDir, root, ...sitemapOptions } = options;
|
|
84
|
+
const { pagesDir, root, scope = HOST_SCOPE, ...sitemapOptions } = options;
|
|
40
85
|
const userSerialize = sitemapOptions.serialize;
|
|
41
86
|
const userFilter = sitemapOptions.filter;
|
|
42
87
|
|
|
@@ -47,8 +92,10 @@ export function sitemapWithCustomPages(
|
|
|
47
92
|
// populated before anything reads it.
|
|
48
93
|
const customPages: string[] = [...(sitemapOptions.customPages ?? [])];
|
|
49
94
|
// Filled beside customPages: the entry routes the integration must not list
|
|
50
|
-
// from its own crawl of prerendered pages.
|
|
95
|
+
// from its own crawl of prerendered pages. The filter reads it by reference,
|
|
96
|
+
// so it sees whatever astro:config:setup put there.
|
|
51
97
|
const denied = new Set<string>();
|
|
98
|
+
const allowed = createSitemapFilter(denied, scope);
|
|
52
99
|
|
|
53
100
|
return [
|
|
54
101
|
{
|
|
@@ -96,9 +143,7 @@ export function sitemapWithCustomPages(
|
|
|
96
143
|
sitemap({
|
|
97
144
|
...sitemapOptions,
|
|
98
145
|
customPages,
|
|
99
|
-
filter: (page) =>
|
|
100
|
-
sitemapPageAllowed(page, denied) &&
|
|
101
|
-
(userFilter ? userFilter(page) : true),
|
|
146
|
+
filter: (page) => (userFilter ? userFilter(page) : true) && allowed(page),
|
|
102
147
|
serialize(item) {
|
|
103
148
|
if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
|
|
104
149
|
item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
|
package/src/routes/index.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
// (site-runtime 3.12.0): the folder is per-brand data, so no file name the
|
|
8
8
|
// template ships can carry it, and the preset injects the same handler at
|
|
9
9
|
// /<folder>/llms.txt (../config/site-module.ts).
|
|
10
|
-
export {
|
|
10
|
+
export {
|
|
11
|
+
createLlmsTxtRoute,
|
|
12
|
+
renderLlmsTxt,
|
|
13
|
+
type LlmsTxtOptions,
|
|
14
|
+
type LlmsTxtPage,
|
|
15
|
+
} from "./llms-txt";
|
|
11
16
|
export {
|
|
12
17
|
AI_ANSWER_CRAWLERS,
|
|
13
18
|
AI_POLICY_PRESETS,
|