@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.
@@ -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.11.2._
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
 
@@ -526,9 +526,11 @@ platform and must remain a string literal, so the sitemap helper emits against a
526
526
  placeholder host and swaps it at emit time.
527
527
 
528
528
  Platform routes stay VISIBLE files in `src/pages/`, each a shim over a package
529
- handler. Nothing is injected: existence and addressing stay repo-owned so the
530
- `src/pages/` mental model holds, while the behavior rides the bump. A repo
531
- carries FOUR of them, all four unconditionally:
529
+ handler: existence and addressing stay repo-owned so the `src/pages/` mental
530
+ model holds, while the behavior rides the bump. One route is addressed by the
531
+ platform instead, llms.txt under a brand folder (see The brand folder), because
532
+ no file name a template ships can carry a per-brand path segment. A repo carries
533
+ FOUR of them, all four unconditionally:
532
534
 
533
535
  | File | Handler | `prerender` |
534
536
  | -------------------------------------- | --------------------------- | ----------- |
@@ -569,6 +571,141 @@ extends rather than ejects: extra integrations and vite plugins append through
569
571
  `overrides`, and any other key it needs it writes in its own `defineConfig`
570
572
  object after the spread.
571
573
 
574
+ ### The brand folder (3.12.0)
575
+
576
+ Every platform file a build emits can live under one path segment, the brand's
577
+ folder:
578
+
579
+ ```js
580
+ import { SITE_CONFIG } from "./src/site-config";
581
+
582
+ export default defineConfig({
583
+ site: "https://example.com",
584
+ ...iterantStarter({ folder: SITE_CONFIG.folder }),
585
+ });
586
+ ```
587
+
588
+ It exists for the customer's own domain. A brand served in a subfolder is
589
+ reached through one forwarding rule, `example.com/feed/*`, and anything a page
590
+ asks for outside that prefix 404s on the customer's site. So with
591
+ `folder: "feed"` set:
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 |
602
+
603
+ **Pages do not move.** A page keeps the public path it is authored at, in every
604
+ serving mode, and every `<loc>` in the sitemap is that same path. The folder is
605
+ where the platform's own files live, not a prefix for the site.
606
+
607
+ The value is the backend's `brand.iterant_subfolder`, written into
608
+ `SITE_CONFIG.folder` beside the fonts, and `astro.config.mjs` hands it to the
609
+ preset. The PRESET is then the only source: it places the hashed assets and the
610
+ sitemap, and it publishes the folder to the package's own code through a virtual
611
+ module, which is where the layout's icon link, `robots.txt` and llms.txt read
612
+ it. Nothing reads `SITE_CONFIG.folder` a second time, so the value cannot
613
+ disagree with itself. It must be one lower-case segment of letters, digits and
614
+ hyphens, the shape Django validates (`backend/brands/folder.py`), which also
615
+ refuses the reserved names and anything shaped like a language code. Absent or
616
+ empty, everything stays at the root, byte-identical to a build before this
617
+ option existed.
618
+
619
+ Two repo-side consequences: `public/favicon.ico` moves to
620
+ `public/<folder>/favicon.ico`, and the root `src/pages/llms.txt.ts` goes away,
621
+ since the preset addresses that route. A repo that keeps the root file still
622
+ builds and still serves `/llms.txt`, at a URL the customer's domain does not
623
+ forward; the build warns until the file is deleted.
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
+
572
709
  ### Web fonts (3.8.0)
573
710
 
574
711
  A site names its font families once, in `SITE_CONFIG.fonts`, and passes the same
@@ -818,7 +955,12 @@ change is broken. In order:
818
955
  3. **island imports**: no island-grade ui primitive is imported under
819
956
  `src/components/sections/`.
820
957
  4. **eslint**, then **astro check**, then the repo's own unit tests, then
821
- **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.
822
964
  5. **bespoke siblings**: every non-draft bespoke page satisfies the two
823
965
  hydration conventions, checked against the fresh `dist/`, plus a pin on the
824
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.11.2",
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,13 +12,16 @@ 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";
17
+ import { assertFolder } from "../lib/folder";
16
18
  import devServerSignals from "../integrations/dev-server-signals.mjs";
17
19
  import iterantPlugins from "../integrations/iterant-plugins.mjs";
18
20
  import newFileReload from "../integrations/new-file-reload.mjs";
19
21
  import previewErrorShell from "../integrations/preview-error-shell.mjs";
20
22
  import siteConfigWatch from "../integrations/site-config-watch.mjs";
21
23
  import { sitemapWithCustomPages } from "../lib/sitemap";
24
+ import { siteModule } from "./site-module";
22
25
 
23
26
  /**
24
27
  * The browser floor every brand site's CSS is compiled for, stated rather than
@@ -167,6 +170,19 @@ export interface IterantStarterOptions {
167
170
  * `catalogFontFamilies` measures.
168
171
  */
169
172
  fonts?: readonly string[] | "catalog";
173
+ /**
174
+ * The one path segment this brand's platform files live under (`"feed"`), the
175
+ * backend's `brand.iterant_subfolder` as the repo's site config carries it.
176
+ * A repo passes `SITE_CONFIG.folder`, the same way it passes the fonts.
177
+ *
178
+ * Set, the hashed assets, the sitemap index and llms.txt all move under it,
179
+ * so the customer's edge forwards `example.com/feed/*` and every file a page
180
+ * needs arrives. PAGES DO NOT MOVE: they keep the public paths they are
181
+ * authored at, here and in every serving mode. Absent or empty is the root,
182
+ * byte-identical to a build before folders existed, so a repo that has not
183
+ * taken the upgrade keeps building.
184
+ */
185
+ folder?: string;
170
186
  /**
171
187
  * The repo's site config module, relative to the project root. In list mode
172
188
  * it is watched, so a change to `SITE_CONFIG.fonts` restarts the dev server
@@ -200,9 +216,16 @@ export function iterantStarter({
200
216
  pagesDir = DEFAULT_PAGES_DIR,
201
217
  chromeDir = DEFAULT_CHROME_DIR,
202
218
  fonts = [],
219
+ folder = "",
203
220
  siteConfigPath = "src/site-config.ts",
204
221
  overrides,
205
222
  }: IterantStarterOptions = {}) {
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);
206
229
  // A repo's own wrangler config, when it has one. The template checks in a
207
230
  // wrangler.jsonc and its compatibility_date decides how the adapter's workerd
208
231
  // prerender behaves, so passing configPath is not a detail; the lookup stays
@@ -225,7 +248,21 @@ export function iterantStarter({
225
248
  const integrations: NonNullable<AstroUserConfig["integrations"]> = [
226
249
  react(),
227
250
  // For SSR-only dynamic routes, the page entries' routes are read from disk.
228
- ...sitemapWithCustomPages({ pagesDir }),
251
+ // Under a folder the index and its children land in it; every <loc> stays a
252
+ // public path, because the pages did not move. @astrojs/sitemap creates
253
+ // dist/client and nothing below it, so the folder directory is there because
254
+ // `build.assets` below wrote the hashed assets into it first: a site that
255
+ // emitted no assets at all would ENOENT here rather than skip the sitemap.
256
+ ...sitemapWithCustomPages({
257
+ pagesDir,
258
+ scope,
259
+ ...(siteFolder && { filenameBase: `${siteFolder}/sitemap` }),
260
+ }),
261
+ // The folder and the site config, as a module the package's own layout and
262
+ // routes can read. Always registered: those readers do not care whether this
263
+ // site has a folder, and with none they read `""` and address the root.
264
+ // With one, it also injects /<folder>/llms.txt.
265
+ siteModule({ folder: siteFolder, scope, siteConfigPath }),
229
266
  // Dev-only: platform visual editor, gated on ?editor=1 in an iframe.
230
267
  iterantPlugins(),
231
268
  // Dev-only: swap 5xx error pages for the branded in-progress shell.
@@ -323,6 +360,11 @@ export function iterantStarter({
323
360
  ...(site !== undefined && { site }),
324
361
  output: "server" as const,
325
362
  trailingSlash: "never" as const,
363
+ // Hashed assets under the brand's folder, so one forwarding rule on the
364
+ // customer's domain covers every stylesheet, script and image a page asks
365
+ // for. The Cloudflare adapter reads this key too and writes its immutable
366
+ // Cache-Control rule for the same prefix into _headers.
367
+ ...(siteFolder && { build: { assets: `${siteFolder}/_astro` } }),
326
368
  // No Astro sessions: the in-memory driver stops the Cloudflare adapter from
327
369
  // auto-provisioning a KV namespace per deploy.
328
370
  session: {
@@ -0,0 +1,106 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { AstroIntegration } from "astro";
4
+
5
+ import type { CanonicalScope } from "../lib/canonical-scope";
6
+
7
+ // The platform's build-time facts, under a fixed module id: the brand's folder
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.
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.
17
+ const SITE_ID = "virtual:iterant/site";
18
+ const RESOLVED_SITE_ID = `\0${SITE_ID}`;
19
+
20
+ export interface SiteModuleOptions {
21
+ /** The folder, already validated. Empty means the site serves from the root. */
22
+ folder: string;
23
+ /** Which paths the baked `site` origin serves, already validated. */
24
+ scope: CanonicalScope;
25
+ /** The repo's site config module, relative to the project root. */
26
+ siteConfigPath: string;
27
+ }
28
+
29
+ /**
30
+ * The virtual module above, and the one route this package addresses itself.
31
+ *
32
+ * llms.txt has to move under a folder, and no file name the template ships can
33
+ * carry a per-brand path segment, so with a folder set the preset injects the
34
+ * handler the repo used to mount at the root. A repo that still has the root
35
+ * src/pages/llms.txt.ts keeps serving /llms.txt too, at a different URL, so
36
+ * nothing collides; the file is dead weight on the customer's domain, where only
37
+ * the folder is forwarded, and the starter upgrade deletes it. The warning below
38
+ * says so on every build until it does.
39
+ */
40
+ export function siteModule({
41
+ folder,
42
+ scope,
43
+ siteConfigPath,
44
+ }: SiteModuleOptions): AstroIntegration {
45
+ return {
46
+ name: "iterant-site",
47
+ hooks: {
48
+ "astro:config:setup": ({ config, injectRoute, logger, updateConfig }) => {
49
+ const siteConfigFile = fileURLToPath(
50
+ new URL(siteConfigPath, config.root),
51
+ );
52
+ updateConfig({
53
+ vite: {
54
+ plugins: [
55
+ {
56
+ name: "iterant-site-module",
57
+ resolveId: (id: string) =>
58
+ id === SITE_ID ? RESOLVED_SITE_ID : null,
59
+ load: (id: string) =>
60
+ id === RESOLVED_SITE_ID
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`
65
+ : null,
66
+ },
67
+ ],
68
+ },
69
+ });
70
+ if (!folder) return;
71
+ injectRoute({
72
+ pattern: `/${folder}/llms.txt`,
73
+ entrypoint: new URL(
74
+ "../routes/llms-txt-injected.ts",
75
+ import.meta.url,
76
+ ),
77
+ prerender: true,
78
+ });
79
+ if (
80
+ existsSync(
81
+ fileURLToPath(new URL("src/pages/llms.txt.ts", config.root)),
82
+ )
83
+ ) {
84
+ logger.warn(
85
+ `src/pages/llms.txt.ts still serves /llms.txt; delete it so llms.txt lives under /${folder} alone`,
86
+ );
87
+ }
88
+ },
89
+ // The module's TYPES, into the repo's .astro/ where its tsconfig already
90
+ // reads them. Without this a consumer's `astro check` has no declaration
91
+ // for the id and reports TS2307 in three package files it type-checks
92
+ // through their imports. The text is the declaration this package ships
93
+ // and checks itself against, read rather than repeated, so the two
94
+ // readings cannot drift.
95
+ "astro:config:done": ({ injectTypes }) => {
96
+ injectTypes({
97
+ filename: "site.d.ts",
98
+ content: readFileSync(
99
+ new URL("./virtual-modules.d.ts", import.meta.url),
100
+ "utf8",
101
+ ),
102
+ });
103
+ },
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,22 @@
1
+ // The platform's build-time facts, served by the vite plugin in ./site-module.ts
2
+ // and read by the package's own routes and layout. A repo imports its site
3
+ // config directly; this id exists because that code is not called from the
4
+ // config and cannot be handed the folder any other way.
5
+ //
6
+ // This file is BOTH the package's own declaration and the one every consumer
7
+ // gets: the integration reads it and injects it into the repo's .astro/ at
8
+ // astro:config:done, so a brand's `astro check` resolves the id too.
9
+ declare module "virtual:iterant/site" {
10
+ export const SITE_CONFIG: {
11
+ name: string;
12
+ description: string;
13
+ };
14
+ /** The brand's folder, `""` when the site serves from the root. */
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;
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 {