@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
package/bin/site-runtime.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* brand repo root.
|
|
6
6
|
*
|
|
7
7
|
* site-runtime verify # the gate: fonts, islands, lint, check, tests, build, bespoke siblings, editor bytes
|
|
8
|
+
* site-runtime verify --lint <path...> # the same gate, linting only those paths (none: skip the lint step)
|
|
8
9
|
* site-runtime scan-copy [files...] # hardcoded visible copy in component code
|
|
9
10
|
* site-runtime scan-island-imports [dir] # island-grade ui primitives imported by registered sections
|
|
10
11
|
* site-runtime scan-bespoke-siblings # bespoke locale-sibling hydration contract (after a build)
|
package/docs/runtime-contract.md
CHANGED
|
@@ -50,7 +50,7 @@ runtime and says so.
|
|
|
50
50
|
|
|
51
51
|
<!-- generated: available libraries -->
|
|
52
52
|
|
|
53
|
-
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.13.0._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
|
@@ -590,15 +590,15 @@ reached through one forwarding rule, `example.com/feed/*`, and anything a page
|
|
|
590
590
|
asks for outside that prefix 404s on the customer's site. So with
|
|
591
591
|
`folder: "feed"` set:
|
|
592
592
|
|
|
593
|
-
| What
|
|
594
|
-
|
|
|
595
|
-
| hashed assets (`build.assets`)
|
|
596
|
-
| immutable `Cache-Control` rule
|
|
597
|
-
| sitemap index and its children
|
|
598
|
-
| llms.txt
|
|
599
|
-
| the head's icon link
|
|
600
|
-
| robots.txt
|
|
601
|
-
| the mirrored legacy sitemaps
|
|
593
|
+
| What | Where it lands |
|
|
594
|
+
| ------------------------------ | ------------------------------------------------ |
|
|
595
|
+
| hashed assets (`build.assets`) | `/feed/_astro/*`, fonts included |
|
|
596
|
+
| immutable `Cache-Control` rule | `/feed/_astro/*`, written by the adapter |
|
|
597
|
+
| sitemap index and its children | `/feed/sitemap-index.xml`, `/feed/sitemap-0.xml` |
|
|
598
|
+
| llms.txt | `/feed/llms.txt`, injected by the preset |
|
|
599
|
+
| the head's icon link | `/feed/favicon.ico` |
|
|
600
|
+
| robots.txt | the root, with its `Sitemap:` line in the folder |
|
|
601
|
+
| the mirrored legacy sitemaps | the root, the URLs the old site advertised |
|
|
602
602
|
|
|
603
603
|
**Pages do not move.** A page keeps the public path it is authored at, in every
|
|
604
604
|
serving mode, and every `<loc>` in the sitemap is that same path. The folder is
|
|
@@ -622,6 +622,90 @@ since the preset addresses that route. A repo that keeps the root file still
|
|
|
622
622
|
builds and still serves `/llms.txt`, at a URL the customer's domain does not
|
|
623
623
|
forward; the build warns until the file is deleted.
|
|
624
624
|
|
|
625
|
+
### What 3.13.0 changes in every scope
|
|
626
|
+
|
|
627
|
+
Three things differ from 3.12.0 even with the scope left at its default:
|
|
628
|
+
|
|
629
|
+
- The BreadcrumbList folds the brand folder and the locale into its root crumb
|
|
630
|
+
(`/feed/es` for a translated page under the folder, `/feed` for a plain one),
|
|
631
|
+
so no crumb points at `/` and no crumb is named after the folder. A page that
|
|
632
|
+
is the folder index alone emits no BreadcrumbList.
|
|
633
|
+
- The sitemap lists a route once when a prerendered page and a content entry
|
|
634
|
+
share it; the first one the plugin sees wins.
|
|
635
|
+
- The package root no longer exports `pageLocaleHead`: it reads the virtual
|
|
636
|
+
site module, and the root stays importable outside an Astro build. Pages
|
|
637
|
+
import it from `@iterant/site-runtime/hreflang`, as the starter always did.
|
|
638
|
+
The pure `deriveHreflangAlternates` takes the scope as a required argument.
|
|
639
|
+
- `verify` accepts exactly one option, `--lint <path...>`; any other option,
|
|
640
|
+
including `--lint=path`, is refused with exit 2.
|
|
641
|
+
|
|
642
|
+
### The canonical scope (3.13.0)
|
|
643
|
+
|
|
644
|
+
A production deploy bakes the CUSTOMER's origin into `site`, and in path routing
|
|
645
|
+
the customer forwards one prefix: `/<folder>/*`, translations included, since a
|
|
646
|
+
translated page lives under the folder (`/<folder>/<locale>/x`). Every other
|
|
647
|
+
route of the build (the brand home at `/`, `/under-construction`, anything
|
|
648
|
+
outside the folder) is served on the platform's own host alone. Spelling those
|
|
649
|
+
against `site` points a crawler, and every link preview, at the customer's real
|
|
650
|
+
homepage.
|
|
651
|
+
|
|
652
|
+
The deploy states the scope in the build environment, not in the repo, because
|
|
653
|
+
the same tree builds for both:
|
|
654
|
+
|
|
655
|
+
| Variable | Value |
|
|
656
|
+
| ------------------------- | --------------------------------------- |
|
|
657
|
+
| `ITERANT_CANONICAL_SCOPE` | `host` (the default) or `folder` |
|
|
658
|
+
| `ITERANT_PLATFORM_SITE` | the platform origin, under folder scope |
|
|
659
|
+
|
|
660
|
+
`folder` with no folder, or with no platform origin, fails the build: there is
|
|
661
|
+
no safe reading of a deploy that would spell customer URLs for pages the
|
|
662
|
+
customer never serves. `host` is what every build before this one declared, and
|
|
663
|
+
under it every emitter below writes exactly the bytes it wrote then.
|
|
664
|
+
|
|
665
|
+
Under `folder`, a path is IN scope when its first segment is the folder. The
|
|
666
|
+
folder matches a whole segment, so `/feedback` is out of scope for the folder
|
|
667
|
+
`feed`. Then:
|
|
668
|
+
|
|
669
|
+
| Emitter | In scope | Out of scope |
|
|
670
|
+
| ----------------------------------- | ------------------------------------------- | ------------------------------- |
|
|
671
|
+
| canonical, `og:url`, `og:image` | the `site` origin | the platform origin |
|
|
672
|
+
| `<meta name="robots">` | the page's own setting | `noindex, nofollow` |
|
|
673
|
+
| JSON-LD page `url` and `@id` | the `site` origin | no graph at all (it is noindex) |
|
|
674
|
+
| JSON-LD breadcrumb ancestors | crumbs above the folder are DROPPED | n/a |
|
|
675
|
+
| hreflang alternates | the `site` origin | the platform origin |
|
|
676
|
+
| sitemap `<loc>` | listed | left out |
|
|
677
|
+
| llms.txt links | listed, `site` origin | left out |
|
|
678
|
+
| robots.txt `Sitemap:` index line | always, `{site}/<folder>/sitemap-index.xml` | n/a |
|
|
679
|
+
| robots.txt mirrored `Sitemap:` line | n/a | not advertised |
|
|
680
|
+
|
|
681
|
+
Breadcrumb ancestors are dropped rather than relinked: `/` and the folder's
|
|
682
|
+
locale root on the customer's domain lead to the customer's own homepage, and a
|
|
683
|
+
crumb naming it would put the brand's trail on a page the brand does not own. A
|
|
684
|
+
page whose only surviving crumb is itself emits no `BreadcrumbList`, the way a
|
|
685
|
+
home page does.
|
|
686
|
+
|
|
687
|
+
**llms.txt names no second host.** The file is read on whichever host served it,
|
|
688
|
+
so a link to our origin inside the customer's copy advertises it to every
|
|
689
|
+
assistant that reads the file. Under folder scope it carries only paths the
|
|
690
|
+
customer serves: the pages inside the folder and the generated sitemap index.
|
|
691
|
+
The Home line and the Robots policy line are at the root, so they go, and a
|
|
692
|
+
section left with no lines goes with its heading.
|
|
693
|
+
|
|
694
|
+
**robots.txt does not move.** It is a host-level file, read at the root of
|
|
695
|
+
whichever host serves it. Its index line keeps naming
|
|
696
|
+
`{site}/<folder>/sitemap-index.xml`, which is where the build writes the index.
|
|
697
|
+
The MIRRORED sitemap (`sourceSitemapUrl`) is a root URL, so under folder scope
|
|
698
|
+
it is not advertised at all; host scope advertises it exactly as before.
|
|
699
|
+
|
|
700
|
+
**hreflang reads the scope itself.** `pageLocaleHead`
|
|
701
|
+
(`@iterant/site-runtime/hreflang`) takes no scope argument: it reads it from the
|
|
702
|
+
preset through the same virtual module the layout uses, so a page inside the
|
|
703
|
+
folder advertises its siblings on the customer's domain and a page outside it
|
|
704
|
+
advertises them on ours, with nothing for a repo to pass or get wrong. The pure
|
|
705
|
+
derivation underneath, `deriveHreflangAlternates`, takes the scope as a required
|
|
706
|
+
argument and is re-exported from the package root for callers outside an Astro
|
|
707
|
+
build.
|
|
708
|
+
|
|
625
709
|
### Web fonts (3.8.0)
|
|
626
710
|
|
|
627
711
|
A site names its font families once, in `SITE_CONFIG.fonts`, and passes the same
|
|
@@ -871,7 +955,12 @@ change is broken. In order:
|
|
|
871
955
|
3. **island imports**: no island-grade ui primitive is imported under
|
|
872
956
|
`src/components/sections/`.
|
|
873
957
|
4. **eslint**, then **astro check**, then the repo's own unit tests, then
|
|
874
|
-
**astro build**.
|
|
958
|
+
**astro build**. `site-runtime verify --lint <path...>` lints those paths
|
|
959
|
+
instead of the whole repo, and `--lint` with no paths skips the lint step:
|
|
960
|
+
the runtime bump runs this gate after a mechanical rewrite, and a replica
|
|
961
|
+
carrying a lint error in a file the bump never touched would otherwise block
|
|
962
|
+
every future bump. It narrows the LINT alone, so the check, the tests and the
|
|
963
|
+
build still cover the whole repo.
|
|
875
964
|
5. **bespoke siblings**: every non-draft bespoke page satisfies the two
|
|
876
965
|
hydration conventions, checked against the fresh `dist/`, plus a pin on the
|
|
877
966
|
Astro runtime directive contract they rely on.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
package/scripts/verify.mjs
CHANGED
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
* Run from the repo root via `bun run verify` / `npm run verify` (which map to
|
|
33
33
|
* `site-runtime verify`). Every path below resolves against process.cwd(), the
|
|
34
34
|
* repo being verified — the gates themselves live in node_modules.
|
|
35
|
+
*
|
|
36
|
+
* One option: `--lint <path...>` lints those paths instead of the whole repo,
|
|
37
|
+
* and `--lint` with no paths skips the lint step. See the comment on the step
|
|
38
|
+
* list for why.
|
|
35
39
|
*/
|
|
36
40
|
|
|
37
41
|
import { spawn } from "node:child_process";
|
|
@@ -234,9 +238,29 @@ if (islandProblems.length > 0) {
|
|
|
234
238
|
exit(1);
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
// `--lint <path...>` narrows the lint step to those paths, and `--lint` with
|
|
242
|
+
// none skips it. The runtime bump runs this gate in a brand sandbox after a
|
|
243
|
+
// mechanical rewrite, and a replica that carries a lint error in a file the
|
|
244
|
+
// bump never touched (a vendored analytics snippet, say) would otherwise block
|
|
245
|
+
// every future bump on a fault the bump did not introduce. It narrows the LINT
|
|
246
|
+
// alone: the check, the tests and the build still cover the whole repo, so
|
|
247
|
+
// nothing the rewrite broke can hide behind it.
|
|
248
|
+
const argv = process.argv.slice(2);
|
|
249
|
+
const unknown = argv.find((arg) => arg.startsWith("-") && arg !== "--lint");
|
|
250
|
+
if (unknown !== undefined) {
|
|
251
|
+
process.stderr.write(
|
|
252
|
+
`verify: unknown option ${unknown}; the only option is --lint <path...>\n`,
|
|
253
|
+
);
|
|
254
|
+
exit(2);
|
|
255
|
+
}
|
|
256
|
+
const lintIndex = argv.indexOf("--lint");
|
|
257
|
+
const lintPaths = lintIndex === -1 ? ["."] : argv.slice(lintIndex + 1);
|
|
258
|
+
/** @type {Array<[string, string[]]>} */
|
|
259
|
+
const lintStep = lintPaths.length > 0 ? [["eslint", lintPaths]] : [];
|
|
260
|
+
|
|
237
261
|
/** @type {Array<[string, string[]]>} */
|
|
238
262
|
const steps = [
|
|
239
|
-
|
|
263
|
+
...lintStep,
|
|
240
264
|
["astro", ["check"]],
|
|
241
265
|
// The repo's own unit tests (bun:test). The content grammar's own suite ships
|
|
242
266
|
// with this package; what runs here is whatever the brand repo keeps under
|
package/src/components/seo.tsx
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HOST_SCOPE,
|
|
3
|
+
inCanonicalScope,
|
|
4
|
+
type CanonicalScope,
|
|
5
|
+
} from "../lib/canonical-scope";
|
|
1
6
|
import { localeFromPath } from "../lib/locales";
|
|
2
7
|
import {
|
|
3
8
|
SeoJson,
|
|
@@ -42,6 +47,9 @@ interface SEOProps {
|
|
|
42
47
|
siteName?: string;
|
|
43
48
|
organization?: OrganizationInfo;
|
|
44
49
|
jsonLd?: SeoJsonSchema | SeoJsonSchema[];
|
|
50
|
+
/** Which paths this site's origin serves. Breadcrumb ancestors outside it
|
|
51
|
+
* are dropped rather than linked. Defaults to the whole host. */
|
|
52
|
+
scope?: CanonicalScope;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export function SEO({
|
|
@@ -58,6 +66,7 @@ export function SEO({
|
|
|
58
66
|
siteName,
|
|
59
67
|
organization,
|
|
60
68
|
jsonLd,
|
|
69
|
+
scope = HOST_SCOPE,
|
|
61
70
|
}: SEOProps) {
|
|
62
71
|
const resolvedSiteName = siteName ?? title;
|
|
63
72
|
const fullTitle =
|
|
@@ -82,6 +91,7 @@ export function SEO({
|
|
|
82
91
|
siteName: resolvedSiteName,
|
|
83
92
|
organization,
|
|
84
93
|
extras: jsonLd,
|
|
94
|
+
scope,
|
|
85
95
|
});
|
|
86
96
|
|
|
87
97
|
return (
|
|
@@ -121,6 +131,7 @@ export interface BuildGraphArgs {
|
|
|
121
131
|
siteName: string;
|
|
122
132
|
organization?: OrganizationInfo;
|
|
123
133
|
extras?: SeoJsonSchema | SeoJsonSchema[];
|
|
134
|
+
scope?: CanonicalScope;
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
// Deterministic schema.org graph for every indexable page. Emitted nodes:
|
|
@@ -142,12 +153,13 @@ export function buildJsonLdGraph({
|
|
|
142
153
|
siteName,
|
|
143
154
|
organization,
|
|
144
155
|
extras,
|
|
156
|
+
scope = HOST_SCOPE,
|
|
145
157
|
}: BuildGraphArgs): SeoJsonSchema[] {
|
|
146
158
|
const siteUrl = canonicalUrl ? new URL(canonicalUrl).origin : undefined;
|
|
147
159
|
const organizationId = siteUrl ? `${siteUrl}/#organization` : undefined;
|
|
148
160
|
const websiteId = siteUrl ? `${siteUrl}/#website` : undefined;
|
|
149
161
|
const breadcrumb = canonicalUrl
|
|
150
|
-
? buildBreadcrumbList({ canonicalUrl, title, siteName })
|
|
162
|
+
? buildBreadcrumbList({ canonicalUrl, title, siteName, scope })
|
|
151
163
|
: undefined;
|
|
152
164
|
|
|
153
165
|
const pageNode = {
|
|
@@ -230,39 +242,58 @@ interface BreadcrumbArgs {
|
|
|
230
242
|
canonicalUrl: string;
|
|
231
243
|
title: string;
|
|
232
244
|
siteName: string;
|
|
245
|
+
scope: CanonicalScope;
|
|
233
246
|
}
|
|
234
247
|
|
|
235
248
|
// BreadcrumbList from the canonical path. The root crumb is the site (or the
|
|
236
249
|
// locale home for localized paths), intermediate crumbs are titleized
|
|
237
250
|
// segments, and the last crumb is the page's own title without a link (per
|
|
238
|
-
// Google's guidance the current page needs no item URL).
|
|
239
|
-
//
|
|
240
|
-
//
|
|
251
|
+
// Google's guidance the current page needs no item URL). Neither the brand
|
|
252
|
+
// folder nor the locale is ever a crumb: both are platform addressing, not
|
|
253
|
+
// something a reader navigated through, so they only shift where the root
|
|
254
|
+
// points, to `/feed`, `/es` or `/feed/es`. Home pages (no segments) emit no
|
|
255
|
+
// breadcrumb.
|
|
256
|
+
//
|
|
257
|
+
// An ancestor outside the canonical scope is DROPPED, not relinked (3.13.0):
|
|
258
|
+
// under folder scope the site root and the locale root are the customer's own
|
|
259
|
+
// homepage, and a crumb naming it would put the brand's trail on a page the
|
|
260
|
+
// brand does not own. What is left can be the current page alone, which is no
|
|
261
|
+
// trail at all, so that emits nothing the way a home page does.
|
|
241
262
|
function buildBreadcrumbList({
|
|
242
263
|
canonicalUrl,
|
|
243
264
|
title,
|
|
244
265
|
siteName,
|
|
266
|
+
scope,
|
|
245
267
|
}: BreadcrumbArgs) {
|
|
246
268
|
const url = new URL(canonicalUrl);
|
|
247
269
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
248
|
-
const locale = localeFromPath(url.pathname);
|
|
249
|
-
const
|
|
270
|
+
const locale = localeFromPath(url.pathname, scope.folder);
|
|
271
|
+
const rootDepth =
|
|
272
|
+
(scope.folder && segments[0] === scope.folder ? 1 : 0) + (locale ? 1 : 0);
|
|
273
|
+
const crumbSegments = segments.slice(rootDepth);
|
|
250
274
|
if (crumbSegments.length === 0) return undefined;
|
|
251
275
|
|
|
252
|
-
const rootPath =
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
276
|
+
const rootPath = rootDepth
|
|
277
|
+
? `/${segments.slice(0, rootDepth).join("/")}`
|
|
278
|
+
: "/";
|
|
279
|
+
const ancestors = [
|
|
280
|
+
{ name: siteName, path: rootPath },
|
|
281
|
+
...crumbSegments.slice(0, -1).map((segment, index) => ({
|
|
282
|
+
name: titleizeSegment(segment),
|
|
283
|
+
path: `${rootPath === "/" ? "" : rootPath}/${crumbSegments
|
|
258
284
|
.slice(0, index + 1)
|
|
259
|
-
.join("/")}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
285
|
+
.join("/")}`,
|
|
286
|
+
})),
|
|
287
|
+
].filter((crumb) => inCanonicalScope(crumb.path, scope));
|
|
288
|
+
|
|
289
|
+
const items = [
|
|
290
|
+
...ancestors.map((crumb) => ({
|
|
291
|
+
name: crumb.name,
|
|
292
|
+
item: new URL(crumb.path, url.origin).toString(),
|
|
293
|
+
})),
|
|
294
|
+
{ name: title },
|
|
265
295
|
];
|
|
296
|
+
if (items.length < 2) return undefined;
|
|
266
297
|
|
|
267
298
|
return {
|
|
268
299
|
"@context": SCHEMA_CONTEXT,
|
package/src/config/preset.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
fontCssVariable,
|
|
13
13
|
findFontFamily,
|
|
14
14
|
} from "../fonts/catalog";
|
|
15
|
+
import { readCanonicalScope } from "../lib/canonical-scope";
|
|
15
16
|
import { DEFAULT_CHROME_DIR, DEFAULT_PAGES_DIR } from "../lib/content-paths";
|
|
16
17
|
import { assertFolder } from "../lib/folder";
|
|
17
18
|
import devServerSignals from "../integrations/dev-server-signals.mjs";
|
|
@@ -220,6 +221,11 @@ export function iterantStarter({
|
|
|
220
221
|
overrides,
|
|
221
222
|
}: IterantStarterOptions = {}) {
|
|
222
223
|
const siteFolder = folder === "" ? "" : assertFolder(folder);
|
|
224
|
+
// Which paths the baked `site` origin serves (3.13.0). It is a deploy
|
|
225
|
+
// decision, not a repo one: the same tree builds for the platform host and
|
|
226
|
+
// for a customer domain that forwards the folder alone, so it arrives in the
|
|
227
|
+
// environment rather than in astro.config.mjs.
|
|
228
|
+
const scope = readCanonicalScope(process.env, siteFolder);
|
|
223
229
|
// A repo's own wrangler config, when it has one. The template checks in a
|
|
224
230
|
// wrangler.jsonc and its compatibility_date decides how the adapter's workerd
|
|
225
231
|
// prerender behaves, so passing configPath is not a detail; the lookup stays
|
|
@@ -249,13 +255,14 @@ export function iterantStarter({
|
|
|
249
255
|
// emitted no assets at all would ENOENT here rather than skip the sitemap.
|
|
250
256
|
...sitemapWithCustomPages({
|
|
251
257
|
pagesDir,
|
|
258
|
+
scope,
|
|
252
259
|
...(siteFolder && { filenameBase: `${siteFolder}/sitemap` }),
|
|
253
260
|
}),
|
|
254
261
|
// The folder and the site config, as a module the package's own layout and
|
|
255
262
|
// routes can read. Always registered: those readers do not care whether this
|
|
256
263
|
// site has a folder, and with none they read `""` and address the root.
|
|
257
264
|
// With one, it also injects /<folder>/llms.txt.
|
|
258
|
-
siteModule({ folder: siteFolder, siteConfigPath }),
|
|
265
|
+
siteModule({ folder: siteFolder, scope, siteConfigPath }),
|
|
259
266
|
// Dev-only: platform visual editor, gated on ?editor=1 in an iframe.
|
|
260
267
|
iterantPlugins(),
|
|
261
268
|
// Dev-only: swap 5xx error pages for the branded in-progress shell.
|
|
@@ -2,21 +2,26 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import type { AstroIntegration } from "astro";
|
|
4
4
|
|
|
5
|
+
import type { CanonicalScope } from "../lib/canonical-scope";
|
|
6
|
+
|
|
5
7
|
// The platform's build-time facts, under a fixed module id: the brand's folder
|
|
6
|
-
// as the preset was given it,
|
|
7
|
-
// this, its own files import
|
|
8
|
-
// because the layout, robots.txt
|
|
9
|
-
//
|
|
8
|
+
// as the preset was given it, the canonical scope the deploy chose, and the
|
|
9
|
+
// repo's site config. A repo never imports this, its own files import
|
|
10
|
+
// `@/site-config` directly; package code does, because the layout, robots.txt
|
|
11
|
+
// and the injected llms.txt route all need them and none of them is called from
|
|
12
|
+
// the config.
|
|
10
13
|
//
|
|
11
|
-
// It is what makes
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
+
// It is what makes each of those ONE value. The preset receives or reads it,
|
|
15
|
+
// places what it places, and every other reader takes it from here, so there is
|
|
16
|
+
// no second copy for a repo to set differently.
|
|
14
17
|
const SITE_ID = "virtual:iterant/site";
|
|
15
18
|
const RESOLVED_SITE_ID = `\0${SITE_ID}`;
|
|
16
19
|
|
|
17
20
|
export interface SiteModuleOptions {
|
|
18
21
|
/** The folder, already validated. Empty means the site serves from the root. */
|
|
19
22
|
folder: string;
|
|
23
|
+
/** Which paths the baked `site` origin serves, already validated. */
|
|
24
|
+
scope: CanonicalScope;
|
|
20
25
|
/** The repo's site config module, relative to the project root. */
|
|
21
26
|
siteConfigPath: string;
|
|
22
27
|
}
|
|
@@ -34,6 +39,7 @@ export interface SiteModuleOptions {
|
|
|
34
39
|
*/
|
|
35
40
|
export function siteModule({
|
|
36
41
|
folder,
|
|
42
|
+
scope,
|
|
37
43
|
siteConfigPath,
|
|
38
44
|
}: SiteModuleOptions): AstroIntegration {
|
|
39
45
|
return {
|
|
@@ -52,7 +58,10 @@ export function siteModule({
|
|
|
52
58
|
id === SITE_ID ? RESOLVED_SITE_ID : null,
|
|
53
59
|
load: (id: string) =>
|
|
54
60
|
id === RESOLVED_SITE_ID
|
|
55
|
-
? `export { SITE_CONFIG } from ${JSON.stringify(siteConfigFile)};\
|
|
61
|
+
? `export { SITE_CONFIG } from ${JSON.stringify(siteConfigFile)};\n` +
|
|
62
|
+
`export const FOLDER = ${JSON.stringify(folder)};\n` +
|
|
63
|
+
`export const CANONICAL_SCOPE = ${JSON.stringify(scope.name)};\n` +
|
|
64
|
+
`export const PLATFORM_SITE = ${JSON.stringify(scope.platformSite)};\n`
|
|
56
65
|
: null,
|
|
57
66
|
},
|
|
58
67
|
],
|
|
@@ -13,4 +13,10 @@ declare module "virtual:iterant/site" {
|
|
|
13
13
|
};
|
|
14
14
|
/** The brand's folder, `""` when the site serves from the root. */
|
|
15
15
|
export const FOLDER: string;
|
|
16
|
+
/** Which paths the baked `site` origin serves: the whole host, or the brand
|
|
17
|
+
* folder alone on a customer domain that forwards only that prefix. */
|
|
18
|
+
export const CANONICAL_SCOPE: "host" | "folder";
|
|
19
|
+
/** The origin an out-of-scope path resolves against under `folder` scope.
|
|
20
|
+
* Null under `host`, where every path is in scope. */
|
|
21
|
+
export const PLATFORM_SITE: string | null;
|
|
16
22
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// an Astro build (the platform agent validates brand content through it).
|
|
6
6
|
//
|
|
7
7
|
// Astro-bound surfaces have their own entries: `/content` (collections),
|
|
8
|
-
// `/layout`, `/seo`, `/routes`, `/sitemap`, `/config`, `/integrations
|
|
8
|
+
// `/layout`, `/seo`, `/routes`, `/sitemap`, `/config`, `/integrations/*`, and
|
|
9
|
+
// `/hreflang`, whose `pageLocaleHead` reads the canonical scope off the preset.
|
|
9
10
|
export * from "./content/schema";
|
|
10
11
|
export * from "./fonts/catalog";
|
|
11
12
|
export * from "./layouts/layout-core";
|
|
@@ -14,7 +15,7 @@ export * from "./lib/chrome-schemas";
|
|
|
14
15
|
export * from "./lib/content-paths";
|
|
15
16
|
export * from "./lib/content-values";
|
|
16
17
|
export * from "./lib/database";
|
|
17
|
-
export * from "./lib/hreflang";
|
|
18
|
+
export * from "./lib/hreflang-derive";
|
|
18
19
|
export * from "./lib/locales";
|
|
19
20
|
export * from "./version";
|
|
20
21
|
export type {
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { Font, fontData } from "astro:assets";
|
|
3
|
-
import { FOLDER } from "virtual:iterant/site";
|
|
3
|
+
import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
|
|
4
4
|
import { getCollection, getEntry } from "astro:content";
|
|
5
5
|
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
6
6
|
import RelatedLinks from "../components/RelatedLinks.astro";
|
|
7
7
|
import { SEO, type PageType } from "../components/seo";
|
|
8
8
|
import type { SeoJsonSchema } from "../components/seo-json";
|
|
9
9
|
import { fontCssVariable } from "../fonts/catalog";
|
|
10
|
+
import { inCanonicalScope, originForPath } from "../lib/canonical-scope";
|
|
10
11
|
import { folderPath } from "../lib/folder";
|
|
11
|
-
import type { HreflangAlternate } from "../lib/hreflang";
|
|
12
|
-
import { DEFAULT_LOCALE, localeFromPath } from "../lib/locales";
|
|
12
|
+
import type { HreflangAlternate } from "../lib/hreflang-derive";
|
|
13
|
+
import { DEFAULT_LOCALE, localeFromPath, parseEntryId } from "../lib/locales";
|
|
13
14
|
import { SITE_RUNTIME_VERSION } from "../version";
|
|
14
15
|
import {
|
|
15
16
|
canonicalUrlFor,
|
|
@@ -103,37 +104,53 @@ const {
|
|
|
103
104
|
|
|
104
105
|
const site = Astro.site;
|
|
105
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
|
+
|
|
106
119
|
const canonicalUrl = canonicalUrlFor({
|
|
107
120
|
canonical,
|
|
108
121
|
pathname: Astro.url.pathname,
|
|
109
|
-
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,
|
|
110
134
|
});
|
|
111
|
-
const imageUrl = resolveAbsoluteUrl(image, site);
|
|
112
135
|
|
|
113
136
|
// Site-level chrome (src/content/chrome.json). Resolved by component id so an
|
|
114
137
|
// absent navbar/footer renders NO chrome — the template ships chrome.json
|
|
115
138
|
// empty, so this is a byte-identical no-op until a brand populates it.
|
|
116
139
|
// getEntry (not getChromeProps) because we need to distinguish an absent
|
|
117
140
|
// component from empty props to drive the conditional mount.
|
|
118
|
-
// Locale-aware (starter 2.8.0): a request
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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);
|
|
123
149
|
const chromeEntry =
|
|
124
150
|
(locale ? await getEntry("chrome", `chrome.${locale}`) : undefined) ??
|
|
125
151
|
(await getEntry("chrome", "chrome"));
|
|
126
152
|
const { navbar, footer } = pickChromeComponents(chromeEntry?.data.components);
|
|
127
153
|
|
|
128
|
-
// Structured data (starter 2.17.0): resolve the current page's entry by
|
|
129
|
-
// route, the same match the catch-all makes, so meta.pageType and the
|
|
130
|
-
// article dates reach the JSON-LD graph without every bespoke shell having
|
|
131
|
-
// to thread them. Shells that pass the props explicitly still win. Routes
|
|
132
|
-
// without an entry (404, under-construction) fall back to a plain WebPage.
|
|
133
|
-
const routePath = routePathFromPathname(Astro.url.pathname);
|
|
134
|
-
const pageEntry = findPageEntryByRoute(await getCollection("pages"), routePath, {
|
|
135
|
-
includeDrafts: !import.meta.env.PROD,
|
|
136
|
-
});
|
|
137
154
|
const structuredData = resolveStructuredData(
|
|
138
155
|
{ pageType, datePublished, dateModified },
|
|
139
156
|
pageEntry?.data.meta,
|
|
@@ -149,7 +166,13 @@ const shellId = shell ?? pageEntry?.data.shell;
|
|
|
149
166
|
// Kept out of search, from the same entry, so the page's own noindex reaches
|
|
150
167
|
// <meta robots> through a catch-all that threads nothing. A shell that passes
|
|
151
168
|
// the prop wins, like shell does; no entry and no prop means indexed.
|
|
152
|
-
|
|
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);
|
|
153
176
|
|
|
154
177
|
// Machine-fed related links (ILV-6): the machine-owned src/content/links.json
|
|
155
178
|
// manifest, resolved by this page's own route key. A locale sibling looks up
|
|
@@ -244,6 +267,7 @@ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false
|
|
|
244
267
|
siteName={siteName}
|
|
245
268
|
organization={{ logo: siteConfig.logo, sameAs: siteConfig.sameAs }}
|
|
246
269
|
jsonLd={jsonLd}
|
|
270
|
+
scope={scope}
|
|
247
271
|
/>
|
|
248
272
|
{
|
|
249
273
|
hreflang.map((alt) => (
|