@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.
@@ -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)
@@ -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.12.0._
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 | 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 |
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.12.0",
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": {
@@ -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
- ["eslint", ["."]],
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
@@ -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). The locale segment
239
- // itself is never a crumb; it only shifts where the root points. Home pages
240
- // (no segments) emit no breadcrumb.
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 crumbSegments = locale ? segments.slice(1) : segments;
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 = locale ? `/${locale}` : "/";
253
- const items = [
254
- { name: siteName, item: new URL(rootPath, url.origin).toString() },
255
- ...crumbSegments.map((segment, index) => {
256
- const isLast = index === crumbSegments.length - 1;
257
- const path = `${rootPath === "/" ? "" : rootPath}/${crumbSegments
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
- return {
261
- name: isLast ? title : titleizeSegment(segment),
262
- ...(isLast ? {} : { item: new URL(path, url.origin).toString() }),
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,
@@ -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, and the repo's site config. A repo never imports
7
- // this, its own files import `@/site-config` directly; package code does,
8
- // because the layout, robots.txt and the injected llms.txt route all need the
9
- // folder and none of them is called from the config.
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 the folder ONE value. The preset receives it, the assets and
12
- // the sitemap go under it, and every other reader takes it from here, so there
13
- // is no second copy for a repo to set differently.
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)};\nexport const FOLDER = ${JSON.stringify(folder)};\n`
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 under a locale prefix (/es/…)
119
- // mounts chrome.<locale>.json when that sibling exists, falling back to the
120
- // base chrome — pages translate before chrome does, so a missing sibling
121
- // must never strip the nav.
122
- 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);
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
- 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);
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) => (