@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.
- package/bin/site-runtime.mjs +1 -0
- package/docs/runtime-contract.md +147 -5
- package/package.json +1 -1
- package/scripts/verify.mjs +25 -1
- package/src/components/seo.tsx +49 -18
- package/src/config/preset.ts +43 -1
- package/src/config/site-module.ts +106 -0
- package/src/config/virtual-modules.d.ts +22 -0
- package/src/index.ts +3 -2
- package/src/layouts/LayoutCore.astro +50 -20
- package/src/lib/canonical-scope.ts +108 -0
- package/src/lib/folder.ts +33 -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 +58 -36
- package/src/routes/index.ts +14 -4
- package/src/routes/llms-txt-injected.ts +11 -0
- package/src/routes/llms-txt.ts +103 -38
- package/src/routes/robots-txt.ts +35 -8
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
|
|
|
@@ -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
|
|
530
|
-
|
|
531
|
-
|
|
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.
|
|
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,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
|
-
|
|
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 {
|