@iterant/site-runtime 3.11.1 → 3.12.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.
@@ -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.1._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.12.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,57 @@ 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
+
572
625
  ### Web fonts (3.8.0)
573
626
 
574
627
  A site names its font families once, in `SITE_CONFIG.fonts`, and passes the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.11.1",
3
+ "version": "3.12.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": {
@@ -13,12 +13,14 @@ import {
13
13
  findFontFamily,
14
14
  } from "../fonts/catalog";
15
15
  import { DEFAULT_CHROME_DIR, DEFAULT_PAGES_DIR } from "../lib/content-paths";
16
+ import { assertFolder } from "../lib/folder";
16
17
  import devServerSignals from "../integrations/dev-server-signals.mjs";
17
18
  import iterantPlugins from "../integrations/iterant-plugins.mjs";
18
19
  import newFileReload from "../integrations/new-file-reload.mjs";
19
20
  import previewErrorShell from "../integrations/preview-error-shell.mjs";
20
21
  import siteConfigWatch from "../integrations/site-config-watch.mjs";
21
22
  import { sitemapWithCustomPages } from "../lib/sitemap";
23
+ import { siteModule } from "./site-module";
22
24
 
23
25
  /**
24
26
  * The browser floor every brand site's CSS is compiled for, stated rather than
@@ -167,6 +169,19 @@ export interface IterantStarterOptions {
167
169
  * `catalogFontFamilies` measures.
168
170
  */
169
171
  fonts?: readonly string[] | "catalog";
172
+ /**
173
+ * The one path segment this brand's platform files live under (`"feed"`), the
174
+ * backend's `brand.iterant_subfolder` as the repo's site config carries it.
175
+ * A repo passes `SITE_CONFIG.folder`, the same way it passes the fonts.
176
+ *
177
+ * Set, the hashed assets, the sitemap index and llms.txt all move under it,
178
+ * so the customer's edge forwards `example.com/feed/*` and every file a page
179
+ * needs arrives. PAGES DO NOT MOVE: they keep the public paths they are
180
+ * authored at, here and in every serving mode. Absent or empty is the root,
181
+ * byte-identical to a build before folders existed, so a repo that has not
182
+ * taken the upgrade keeps building.
183
+ */
184
+ folder?: string;
170
185
  /**
171
186
  * The repo's site config module, relative to the project root. In list mode
172
187
  * it is watched, so a change to `SITE_CONFIG.fonts` restarts the dev server
@@ -200,9 +215,11 @@ export function iterantStarter({
200
215
  pagesDir = DEFAULT_PAGES_DIR,
201
216
  chromeDir = DEFAULT_CHROME_DIR,
202
217
  fonts = [],
218
+ folder = "",
203
219
  siteConfigPath = "src/site-config.ts",
204
220
  overrides,
205
221
  }: IterantStarterOptions = {}) {
222
+ const siteFolder = folder === "" ? "" : assertFolder(folder);
206
223
  // A repo's own wrangler config, when it has one. The template checks in a
207
224
  // wrangler.jsonc and its compatibility_date decides how the adapter's workerd
208
225
  // prerender behaves, so passing configPath is not a detail; the lookup stays
@@ -225,7 +242,20 @@ export function iterantStarter({
225
242
  const integrations: NonNullable<AstroUserConfig["integrations"]> = [
226
243
  react(),
227
244
  // For SSR-only dynamic routes, the page entries' routes are read from disk.
228
- ...sitemapWithCustomPages({ pagesDir }),
245
+ // Under a folder the index and its children land in it; every <loc> stays a
246
+ // public path, because the pages did not move. @astrojs/sitemap creates
247
+ // dist/client and nothing below it, so the folder directory is there because
248
+ // `build.assets` below wrote the hashed assets into it first: a site that
249
+ // emitted no assets at all would ENOENT here rather than skip the sitemap.
250
+ ...sitemapWithCustomPages({
251
+ pagesDir,
252
+ ...(siteFolder && { filenameBase: `${siteFolder}/sitemap` }),
253
+ }),
254
+ // The folder and the site config, as a module the package's own layout and
255
+ // routes can read. Always registered: those readers do not care whether this
256
+ // site has a folder, and with none they read `""` and address the root.
257
+ // With one, it also injects /<folder>/llms.txt.
258
+ siteModule({ folder: siteFolder, siteConfigPath }),
229
259
  // Dev-only: platform visual editor, gated on ?editor=1 in an iframe.
230
260
  iterantPlugins(),
231
261
  // Dev-only: swap 5xx error pages for the branded in-progress shell.
@@ -323,6 +353,11 @@ export function iterantStarter({
323
353
  ...(site !== undefined && { site }),
324
354
  output: "server" as const,
325
355
  trailingSlash: "never" as const,
356
+ // Hashed assets under the brand's folder, so one forwarding rule on the
357
+ // customer's domain covers every stylesheet, script and image a page asks
358
+ // for. The Cloudflare adapter reads this key too and writes its immutable
359
+ // Cache-Control rule for the same prefix into _headers.
360
+ ...(siteFolder && { build: { assets: `${siteFolder}/_astro` } }),
326
361
  // No Astro sessions: the in-memory driver stops the Cloudflare adapter from
327
362
  // auto-provisioning a KV namespace per deploy.
328
363
  session: {
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { AstroIntegration } from "astro";
4
+
5
+ // 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.
10
+ //
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
+ const SITE_ID = "virtual:iterant/site";
15
+ const RESOLVED_SITE_ID = `\0${SITE_ID}`;
16
+
17
+ export interface SiteModuleOptions {
18
+ /** The folder, already validated. Empty means the site serves from the root. */
19
+ folder: string;
20
+ /** The repo's site config module, relative to the project root. */
21
+ siteConfigPath: string;
22
+ }
23
+
24
+ /**
25
+ * The virtual module above, and the one route this package addresses itself.
26
+ *
27
+ * llms.txt has to move under a folder, and no file name the template ships can
28
+ * carry a per-brand path segment, so with a folder set the preset injects the
29
+ * handler the repo used to mount at the root. A repo that still has the root
30
+ * src/pages/llms.txt.ts keeps serving /llms.txt too, at a different URL, so
31
+ * nothing collides; the file is dead weight on the customer's domain, where only
32
+ * the folder is forwarded, and the starter upgrade deletes it. The warning below
33
+ * says so on every build until it does.
34
+ */
35
+ export function siteModule({
36
+ folder,
37
+ siteConfigPath,
38
+ }: SiteModuleOptions): AstroIntegration {
39
+ return {
40
+ name: "iterant-site",
41
+ hooks: {
42
+ "astro:config:setup": ({ config, injectRoute, logger, updateConfig }) => {
43
+ const siteConfigFile = fileURLToPath(
44
+ new URL(siteConfigPath, config.root),
45
+ );
46
+ updateConfig({
47
+ vite: {
48
+ plugins: [
49
+ {
50
+ name: "iterant-site-module",
51
+ resolveId: (id: string) =>
52
+ id === SITE_ID ? RESOLVED_SITE_ID : null,
53
+ load: (id: string) =>
54
+ id === RESOLVED_SITE_ID
55
+ ? `export { SITE_CONFIG } from ${JSON.stringify(siteConfigFile)};\nexport const FOLDER = ${JSON.stringify(folder)};\n`
56
+ : null,
57
+ },
58
+ ],
59
+ },
60
+ });
61
+ if (!folder) return;
62
+ injectRoute({
63
+ pattern: `/${folder}/llms.txt`,
64
+ entrypoint: new URL(
65
+ "../routes/llms-txt-injected.ts",
66
+ import.meta.url,
67
+ ),
68
+ prerender: true,
69
+ });
70
+ if (
71
+ existsSync(
72
+ fileURLToPath(new URL("src/pages/llms.txt.ts", config.root)),
73
+ )
74
+ ) {
75
+ logger.warn(
76
+ `src/pages/llms.txt.ts still serves /llms.txt; delete it so llms.txt lives under /${folder} alone`,
77
+ );
78
+ }
79
+ },
80
+ // The module's TYPES, into the repo's .astro/ where its tsconfig already
81
+ // reads them. Without this a consumer's `astro check` has no declaration
82
+ // for the id and reports TS2307 in three package files it type-checks
83
+ // through their imports. The text is the declaration this package ships
84
+ // and checks itself against, read rather than repeated, so the two
85
+ // readings cannot drift.
86
+ "astro:config:done": ({ injectTypes }) => {
87
+ injectTypes({
88
+ filename: "site.d.ts",
89
+ content: readFileSync(
90
+ new URL("./virtual-modules.d.ts", import.meta.url),
91
+ "utf8",
92
+ ),
93
+ });
94
+ },
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,16 @@
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
+ }
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  import { Font, fontData } from "astro:assets";
3
+ import { FOLDER } from "virtual:iterant/site";
3
4
  import { getCollection, getEntry } from "astro:content";
4
5
  import type { AstroComponentFactory } from "astro/runtime/server/index.js";
5
6
  import RelatedLinks from "../components/RelatedLinks.astro";
6
7
  import { SEO, type PageType } from "../components/seo";
7
8
  import type { SeoJsonSchema } from "../components/seo-json";
8
9
  import { fontCssVariable } from "../fonts/catalog";
10
+ import { folderPath } from "../lib/folder";
9
11
  import type { HreflangAlternate } from "../lib/hreflang";
10
12
  import { DEFAULT_LOCALE, localeFromPath } from "../lib/locales";
11
13
  import { SITE_RUNTIME_VERSION } from "../version";
@@ -215,7 +217,11 @@ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false
215
217
  <head>
216
218
  <meta charset="UTF-8" />
217
219
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
218
- <link rel="icon" type="image/x-icon" href="/favicon.ico" />
220
+ <link
221
+ rel="icon"
222
+ type="image/x-icon"
223
+ href={folderPath(FOLDER, "favicon.ico")}
224
+ />
219
225
  <meta name="generator" content={Astro.generator} />
220
226
  <meta name="it-site-runtime" content={SITE_RUNTIME_VERSION} />
221
227
  <meta name="it-astro-starter-version" content={SITE_RUNTIME_VERSION} />
@@ -0,0 +1,33 @@
1
+ // The brand's folder: the one path segment every platform file a build emits
2
+ // lives under, so the customer's edge forwards `example.com/feed/*` and never
3
+ // needs a second rule. Pages are untouched: they keep their public paths, and a
4
+ // site with no folder emits exactly what it emitted before folders existed.
5
+ //
6
+ // The value is the backend's `brand.iterant_subfolder`, written into the repo's
7
+ // site config beside `fonts` and handed to `iterantStarter({ folder })`. One
8
+ // owner, one copy, never inferred from a path.
9
+
10
+ // The backend's own rule, character for character (backend/brands/folder.py):
11
+ // letters, digits and hyphens, so the folder is one level deep and can never
12
+ // shadow a platform file name. The reserved names and the locale-shape refusal
13
+ // stay at the door in Django, which owns the value; what a build can still be
14
+ // handed is a bad shape, and a bad shape emits asset URLs nobody can fetch and a
15
+ // sitemap nobody can find, so the build refuses it instead.
16
+ const FOLDER_SEGMENT = /^[a-z0-9-]+$/;
17
+
18
+ export function assertFolder(folder: string): string {
19
+ if (!FOLDER_SEGMENT.test(folder)) {
20
+ throw new Error(
21
+ `iterantStarter: folder must be one lower-case segment of letters, digits and hyphens, no slashes (got ${JSON.stringify(folder)})`,
22
+ );
23
+ }
24
+ return folder;
25
+ }
26
+
27
+ /** A platform file's public path: `/feed/favicon.ico` under a folder,
28
+ * `/favicon.ico` without one. The folder is validated here too, because this is
29
+ * where it becomes a URL: a value like `/evil.example` must never get that far,
30
+ * wherever it was read. */
31
+ export function folderPath(folder: string | undefined, file: string): string {
32
+ return folder ? `/${assertFolder(folder)}/${file}` : `/${file}`;
33
+ }
@@ -1,7 +1,12 @@
1
1
  // Platform route handlers. Every one of these stays behind a VISIBLE file in the
2
- // repo's src/pages/ existence and addressing are repo-owned, behavior rides a
3
- // package bump. No route is injected: a route that exists in no repo file breaks
4
- // the src/pages/ mental model and the debuggability contract.
2
+ // repo's src/pages/: existence and addressing are repo-owned, behavior rides a
3
+ // package bump. A route that exists in no repo file breaks the src/pages/ mental
4
+ // model and the debuggability contract.
5
+ //
6
+ // One route is addressed by the platform, llms.txt under a brand folder
7
+ // (site-runtime 3.12.0): the folder is per-brand data, so no file name the
8
+ // template ships can carry it, and the preset injects the same handler at
9
+ // /<folder>/llms.txt (../config/site-module.ts).
5
10
  export { createLlmsTxtRoute, type LlmsTxtOptions } from "./llms-txt";
6
11
  export {
7
12
  AI_ANSWER_CRAWLERS,
@@ -0,0 +1,11 @@
1
+ import { SITE_CONFIG } from "virtual:iterant/site";
2
+
3
+ import { createLlmsTxtRoute } from "./llms-txt";
4
+
5
+ // The body of /<folder>/llms.txt, as the preset injects it (see
6
+ // ../config/site-module.ts). The brand's name and description come from the
7
+ // repo's site config through the virtual module, since an injected route has no
8
+ // repo file to import them from; the folder the handler writes into its links
9
+ // comes from the same module.
10
+ export const prerender = true;
11
+ export const GET = createLlmsTxtRoute({ siteConfig: SITE_CONFIG });
@@ -1,5 +1,7 @@
1
1
  import { getCollection } from "astro:content";
2
+ import { FOLDER } from "virtual:iterant/site";
2
3
  import { isAdvertised } from "../lib/advertised";
4
+ import { folderPath } from "../lib/folder";
3
5
  import type { APIRoute } from "astro";
4
6
 
5
7
  // /llms.txt, generated at dev/build time with no crawler or AI step: a curated
@@ -11,6 +13,10 @@ import type { APIRoute } from "astro";
11
13
  // ---
12
14
  // export const prerender = true;
13
15
  // export const GET = createLlmsTxtRoute({ siteConfig: SITE_CONFIG });
16
+ //
17
+ // Under a brand folder the same handler is addressed by the preset instead, at
18
+ // /<folder>/llms.txt, because no file name the template ships can carry a
19
+ // per-brand segment (../config/site-module.ts).
14
20
 
15
21
  export interface LlmsTxtOptions {
16
22
  /** The brand's SITE_CONFIG; name and description head the file when set. */
@@ -58,7 +64,7 @@ export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
58
64
  : []),
59
65
  "## Discovery",
60
66
  "",
61
- `- [Sitemap](${new URL("/sitemap-index.xml", origin).href}): Complete search-engine sitemap, if available.`,
67
+ `- [Sitemap](${new URL(folderPath(FOLDER, "sitemap-index.xml"), origin).href}): Complete search-engine sitemap, if available.`,
62
68
  `- [Robots policy](${new URL("/robots.txt", origin).href}): Crawler permissions for automated agents.`,
63
69
  "",
64
70
  ];
@@ -1,5 +1,7 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { FOLDER } from "virtual:iterant/site";
2
3
 
4
+ import { folderPath } from "../lib/folder";
3
5
  import { normalizeSourceUrls } from "../lib/sitemap/shared";
4
6
 
5
7
  // /robots.txt: crawler permissions, the site's AI policy and the sitemap
@@ -108,11 +110,20 @@ function legacyDisallows(rules: string[] | undefined): string[] {
108
110
  export function renderRobotsTxt(
109
111
  config: RobotsSiteConfig,
110
112
  site: URL | undefined,
113
+ // The brand's folder (site-runtime 3.12.0). robots.txt itself stays at the
114
+ // root, where a crawler looks for it; its index line points into the folder,
115
+ // where the build writes the sitemap. Resolved against the ORIGIN, because the
116
+ // folder is what the customer forwards from their domain's root, while a site
117
+ // URL that carries a path keeps resolving relative to it without one.
118
+ folder: string = "",
111
119
  ): string {
112
120
  const policy = resolveAiPolicy(config.aiPolicy);
121
+ const indexPath = folder
122
+ ? folderPath(folder, "sitemap-index.xml")
123
+ : "sitemap-index.xml";
113
124
  const sitemap = site
114
125
  ? [
115
- `Sitemap: ${new URL("sitemap-index.xml", site).href}`,
126
+ `Sitemap: ${new URL(indexPath, site).href}`,
116
127
  // Mirrored sitemap, only when sourceSitemapUrl is configured.
117
128
  ...(normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0
118
129
  ? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
@@ -141,7 +152,7 @@ export function createRobotsTxtRoute(options: RobotsTxtOptions): APIRoute {
141
152
  const { siteConfig, ...flat } = options;
142
153
  const config: RobotsSiteConfig = { ...flat, ...(siteConfig ?? {}) };
143
154
  return ({ site }) =>
144
- new Response(renderRobotsTxt(config, site), {
155
+ new Response(renderRobotsTxt(config, site, FOLDER), {
145
156
  headers: { "Content-Type": "text/plain" },
146
157
  });
147
158
  }