@iterant/site-runtime 3.0.2
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/README.md +30 -0
- package/bin/site-runtime.mjs +46 -0
- package/docs/runtime-contract.md +324 -0
- package/package.json +84 -0
- package/scripts/scan-bespoke-siblings.mjs +191 -0
- package/scripts/scan-copy.mjs +204 -0
- package/scripts/scan-island-imports.mjs +207 -0
- package/scripts/verify.mjs +283 -0
- package/src/components/seo-json.tsx +157 -0
- package/src/components/seo.tsx +294 -0
- package/src/config/preset.ts +198 -0
- package/src/content/collections.ts +71 -0
- package/src/content/schema.ts +239 -0
- package/src/index.ts +22 -0
- package/src/integrations/iterant-plugins.mjs +83 -0
- package/src/integrations/new-file-reload.mjs +95 -0
- package/src/integrations/preview-error-shell.mjs +145 -0
- package/src/layouts/LayoutCore.astro +182 -0
- package/src/layouts/layout-core.ts +141 -0
- package/src/lib/bespoke-pages.ts +60 -0
- package/src/lib/chrome-schemas.ts +266 -0
- package/src/lib/chrome.ts +23 -0
- package/src/lib/content-paths.ts +16 -0
- package/src/lib/content-values.ts +201 -0
- package/src/lib/hreflang.ts +123 -0
- package/src/lib/locales.ts +92 -0
- package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
- package/src/lib/sitemap/index.ts +19 -0
- package/src/lib/sitemap/routes.ts +141 -0
- package/src/lib/sitemap/shared.ts +95 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
- package/src/routes/UnderConstruction.astro +43 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/llms-txt.ts +68 -0
- package/src/routes/robots-txt.ts +56 -0
- package/src/version.ts +9 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Shared helpers for the sitemap-proxy routes (./sitemap.ts and ./proxy-sitemap.ts):
|
|
2
|
+
// fetch the upstream, validate it's XML, parse <sitemapindex> entries, rewrite every
|
|
3
|
+
// URL's host to this site's domain. Plus the response headers and empty-urlset body
|
|
4
|
+
// both routes return.
|
|
5
|
+
|
|
6
|
+
// Successful responses cache for 1h; the empty fallback caches for 5m so a
|
|
7
|
+
// transient bad upstream self-heals on the next crawl.
|
|
8
|
+
const CACHE_SECONDS = 3600;
|
|
9
|
+
const EMPTY_CACHE_SECONDS = 300;
|
|
10
|
+
|
|
11
|
+
export const SITEMAP_HEADERS = {
|
|
12
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
13
|
+
"Cache-Control": `public, max-age=${CACHE_SECONDS}`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const EMPTY_SITEMAP_HEADERS = {
|
|
17
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
18
|
+
"Cache-Control": `public, max-age=${EMPTY_CACHE_SECONDS}`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const EMPTY_URLSET =
|
|
22
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
23
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n';
|
|
24
|
+
|
|
25
|
+
// Trim a string-or-array config to a clean, non-empty array.
|
|
26
|
+
export function normalizeSourceUrls(raw: string | string[]): string[] {
|
|
27
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
28
|
+
return arr.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Only rewrite hosts in <loc>, <image:loc>, and <xhtml:link href>.
|
|
32
|
+
const LOC_RE = /(<(?:loc|image:loc)>)([^<]+)(<\/(?:loc|image:loc)>)/g;
|
|
33
|
+
const XHTML_HREF_RE = /(<xhtml:link\b[^>]*?\bhref=")([^"]+)(")/g;
|
|
34
|
+
|
|
35
|
+
export function rewriteSitemapDomain(
|
|
36
|
+
xml: string,
|
|
37
|
+
targetOrigin: string,
|
|
38
|
+
): string {
|
|
39
|
+
const target = new URL(targetOrigin);
|
|
40
|
+
|
|
41
|
+
const rewriteUrl = (raw: string): string => {
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(raw.trim());
|
|
44
|
+
url.protocol = target.protocol;
|
|
45
|
+
url.host = target.host;
|
|
46
|
+
return url.toString();
|
|
47
|
+
} catch {
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let out = xml.replace(
|
|
53
|
+
LOC_RE,
|
|
54
|
+
(_m, open, url, close) => `${open}${rewriteUrl(url)}${close}`,
|
|
55
|
+
);
|
|
56
|
+
if (out.includes("xhtml:link")) {
|
|
57
|
+
out = out.replace(
|
|
58
|
+
XHTML_HREF_RE,
|
|
59
|
+
(_m, pre, url, post) => `${pre}${rewriteUrl(url)}${post}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns the entries inside `xml` if it's a <sitemapindex>, else null.
|
|
66
|
+
export function parseSitemapIndex(xml: string): string[] | null {
|
|
67
|
+
if (!/<sitemapindex[\s>]/.test(xml)) return null;
|
|
68
|
+
const urls: string[] = [];
|
|
69
|
+
const re = /<loc>([^<]+)<\/loc>/g;
|
|
70
|
+
let m: RegExpExecArray | null;
|
|
71
|
+
while ((m = re.exec(xml)) !== null) urls.push(m[1].trim());
|
|
72
|
+
return urls;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fetch a sitemap from `url`. Returns the body only if it looks like XML, so an
|
|
76
|
+
// HTML error / parked page can't get served back as a sitemap. null on any
|
|
77
|
+
// failure (logged).
|
|
78
|
+
export async function fetchUpstream(url: string): Promise<string | null> {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
headers: { Accept: "application/xml, text/xml" },
|
|
82
|
+
cf: { cacheTtl: CACHE_SECONDS, cacheEverything: true },
|
|
83
|
+
} as RequestInit);
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
console.warn(`[sitemap-proxy] upstream ${url} returned ${res.status}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
if (/^\s*<\??(xml|urlset|sitemapindex)/i.test(text)) return text;
|
|
90
|
+
console.warn(`[sitemap-proxy] upstream ${url} is not XML; ignoring`);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`[sitemap-proxy] failed to fetch ${url}`, err);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import sitemap, { type SitemapOptions } from "@astrojs/sitemap";
|
|
3
|
+
import type { AstroIntegration } from "astro";
|
|
4
|
+
|
|
5
|
+
import { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
|
|
6
|
+
|
|
7
|
+
// The platform patches the `site:` literal at deploy time, so we emit URLs against a
|
|
8
|
+
// placeholder and swap it for the resolved `config.site` at sitemap emit time.
|
|
9
|
+
const PLACEHOLDER = "https://starter.invalid";
|
|
10
|
+
|
|
11
|
+
export type SitemapWithCustomPagesOptions = SitemapOptions &
|
|
12
|
+
SitemapPathsOptions;
|
|
13
|
+
|
|
14
|
+
export function sitemapWithCustomPages(
|
|
15
|
+
options: SitemapWithCustomPagesOptions = {},
|
|
16
|
+
): AstroIntegration[] {
|
|
17
|
+
let resolvedSite = "";
|
|
18
|
+
const { pagesDir, root, ...sitemapOptions } = options;
|
|
19
|
+
const userSerialize = sitemapOptions.serialize;
|
|
20
|
+
|
|
21
|
+
// Filled in at astro:config:setup and read by @astrojs/sitemap at
|
|
22
|
+
// astro:build:done, which is what lets the entry routes be discovered against
|
|
23
|
+
// the RESOLVED project root instead of whatever cwd the build was driven from.
|
|
24
|
+
// This integration is ordered ahead of @astrojs/sitemap, so the array is
|
|
25
|
+
// populated before anything reads it.
|
|
26
|
+
const customPages: string[] = [...(sitemapOptions.customPages ?? [])];
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
name: "capture-site-for-sitemap",
|
|
31
|
+
hooks: {
|
|
32
|
+
"astro:config:setup": ({ config, logger }) => {
|
|
33
|
+
const { paths, pagesDir: searched, entryCount } = getSitemapPaths({
|
|
34
|
+
pagesDir,
|
|
35
|
+
root: root ?? fileURLToPath(config.root),
|
|
36
|
+
});
|
|
37
|
+
if (entryCount === 0) {
|
|
38
|
+
// SSR routes are invisible to @astrojs/sitemap, so an empty read
|
|
39
|
+
// means a sitemap with no content pages in it. Never silent.
|
|
40
|
+
logger.warn(
|
|
41
|
+
`no page entries found in ${searched}; SSR routes will be missing from the sitemap`,
|
|
42
|
+
);
|
|
43
|
+
} else if (paths.length === 0) {
|
|
44
|
+
// Entries exist but every one is a draft or the site root, which
|
|
45
|
+
// the sitemap already carries; expected on a fresh template.
|
|
46
|
+
logger.info(
|
|
47
|
+
`page entries in ${searched} are all drafts or the site root; the sitemap lists no SSR routes yet`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
for (const path of paths) {
|
|
51
|
+
customPages.push(
|
|
52
|
+
`${PLACEHOLDER}${path.startsWith("/") ? path : `/${path}`}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"astro:config:done": ({ config }) => {
|
|
57
|
+
if (config.site) {
|
|
58
|
+
resolvedSite = String(config.site).replace(/\/$/, "");
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
sitemap({
|
|
64
|
+
...sitemapOptions,
|
|
65
|
+
customPages,
|
|
66
|
+
serialize(item) {
|
|
67
|
+
if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
|
|
68
|
+
item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
|
|
69
|
+
}
|
|
70
|
+
return userSerialize ? userSerialize(item) : item;
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
3
|
+
|
|
4
|
+
// The fallback target for src/integrations/preview-error-shell.mjs: when any
|
|
5
|
+
// route 5xxes in dev (typically a bespoke page mid-build), the preview serves
|
|
6
|
+
// this body instead of the raw error overlay. Deliberately dependency-light —
|
|
7
|
+
// it must render even when page components are broken.
|
|
8
|
+
//
|
|
9
|
+
// The route stays a visible repo file (src/pages/under-construction.astro), a
|
|
10
|
+
// shim that hands its Layout in:
|
|
11
|
+
//
|
|
12
|
+
// ---
|
|
13
|
+
// import Layout from "../layouts/Layout.astro";
|
|
14
|
+
// import UnderConstruction from "@iterant/site-runtime/under-construction";
|
|
15
|
+
// ---
|
|
16
|
+
// <UnderConstruction Layout={Layout} />
|
|
17
|
+
//
|
|
18
|
+
// Do not delete, rename, or link to that route from site navigation.
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** The repo's src/layouts/Layout.astro. */
|
|
22
|
+
Layout: AstroComponentFactory;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { Layout } = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<Layout
|
|
29
|
+
title="Page in progress"
|
|
30
|
+
description="This page is being built."
|
|
31
|
+
noindex={true}
|
|
32
|
+
>
|
|
33
|
+
<section
|
|
34
|
+
class="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-6 text-center"
|
|
35
|
+
>
|
|
36
|
+
<p class="text-it-text-primary text-2xl font-semibold">
|
|
37
|
+
This page is in progress
|
|
38
|
+
</p>
|
|
39
|
+
<p class="text-it-text-secondary max-w-md text-sm">
|
|
40
|
+
We're still putting it together — check back in a moment.
|
|
41
|
+
</p>
|
|
42
|
+
</section>
|
|
43
|
+
</Layout>
|
|
@@ -0,0 +1,11 @@
|
|
|
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.
|
|
5
|
+
export { createLlmsTxtRoute, type LlmsTxtOptions } from "./llms-txt";
|
|
6
|
+
export { createRobotsTxtRoute, type RobotsTxtOptions } from "./robots-txt";
|
|
7
|
+
export {
|
|
8
|
+
createProxiedSitemapRoute,
|
|
9
|
+
createSitemapRoute,
|
|
10
|
+
type SitemapRouteOptions,
|
|
11
|
+
} from "../lib/sitemap/routes";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getCollection } from "astro:content";
|
|
2
|
+
import type { APIRoute } from "astro";
|
|
3
|
+
|
|
4
|
+
// /llms.txt, generated at dev/build time with no crawler or AI step: a curated
|
|
5
|
+
// index for AI assistants built from the site's own published content. The repo
|
|
6
|
+
// keeps the route file (so the URL is visible where every other route is) and
|
|
7
|
+
// this supplies the body:
|
|
8
|
+
//
|
|
9
|
+
// src/pages/llms.txt.ts
|
|
10
|
+
// ---
|
|
11
|
+
// export const prerender = true;
|
|
12
|
+
// export const GET = createLlmsTxtRoute({ siteConfig: SITE_CONFIG });
|
|
13
|
+
|
|
14
|
+
export interface LlmsTxtOptions {
|
|
15
|
+
/** The brand's SITE_CONFIG; name and description head the file when set. */
|
|
16
|
+
siteConfig: { name: string; description: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const normalizeText = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
20
|
+
|
|
21
|
+
const escapeLinkText = (value: string) =>
|
|
22
|
+
normalizeText(value).replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
23
|
+
|
|
24
|
+
export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
|
|
25
|
+
return async ({ site }) => {
|
|
26
|
+
const origin = site?.origin ?? "https://example.com";
|
|
27
|
+
const pages = (await getCollection("pages")).filter(
|
|
28
|
+
(entry) => !entry.data.draft || !import.meta.env.PROD,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const contentLinks = pages
|
|
32
|
+
.toSorted((a, b) => a.data.meta.title.localeCompare(b.data.meta.title))
|
|
33
|
+
.map((entry) => {
|
|
34
|
+
const title = escapeLinkText(entry.data.meta.title);
|
|
35
|
+
const url = new URL(entry.data.route, origin).href;
|
|
36
|
+
const description = entry.data.meta.description
|
|
37
|
+
? `: ${normalizeText(entry.data.meta.description)}`
|
|
38
|
+
: "";
|
|
39
|
+
|
|
40
|
+
return `- [${title}](${url})${description}`;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const lines = [
|
|
44
|
+
...(siteConfig.name ? [`# ${siteConfig.name}`, ""] : []),
|
|
45
|
+
...(siteConfig.description ? [`> ${siteConfig.description}`, ""] : []),
|
|
46
|
+
"This file is generated from the site's source content. It is a curated index for AI assistants, not a crawler permissions file. For crawler permissions, see `/robots.txt`.",
|
|
47
|
+
"",
|
|
48
|
+
"## Core Pages",
|
|
49
|
+
"",
|
|
50
|
+
`- [Home](${new URL("/", origin).href}): Primary overview of the site.`,
|
|
51
|
+
"",
|
|
52
|
+
...(contentLinks.length > 0
|
|
53
|
+
? ["## Public Content", "", ...contentLinks, ""]
|
|
54
|
+
: []),
|
|
55
|
+
"## Discovery",
|
|
56
|
+
"",
|
|
57
|
+
`- [Sitemap](${new URL("/sitemap-index.xml", origin).href}): Complete search-engine sitemap, if available.`,
|
|
58
|
+
`- [Robots policy](${new URL("/robots.txt", origin).href}): Crawler permissions for automated agents.`,
|
|
59
|
+
"",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
return new Response(lines.join("\n"), {
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "text/plain; charset=utf-8",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
import { normalizeSourceUrls } from "../lib/sitemap/shared";
|
|
4
|
+
|
|
5
|
+
// /robots.txt: crawler permissions plus the sitemap pointers. The repo keeps the
|
|
6
|
+
// route file and this supplies the body:
|
|
7
|
+
//
|
|
8
|
+
// src/pages/robots.txt.ts
|
|
9
|
+
// ---
|
|
10
|
+
// export const prerender = true;
|
|
11
|
+
// export const GET = createRobotsTxtRoute({
|
|
12
|
+
// sourceSitemapUrl: SITE_CONFIG.sourceSitemapUrl,
|
|
13
|
+
// });
|
|
14
|
+
|
|
15
|
+
export interface RobotsTxtOptions {
|
|
16
|
+
/** The brand's `SITE_CONFIG.sourceSitemapUrl`; a mirrored sitemap is only
|
|
17
|
+
* advertised when one is configured. */
|
|
18
|
+
sourceSitemapUrl: string | string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createRobotsTxtRoute({
|
|
22
|
+
sourceSitemapUrl,
|
|
23
|
+
}: RobotsTxtOptions): APIRoute {
|
|
24
|
+
return ({ site }) => {
|
|
25
|
+
const sitemap = site
|
|
26
|
+
? [
|
|
27
|
+
`Sitemap: ${new URL("sitemap-index.xml", site).href}`,
|
|
28
|
+
// Mirrored sitemap, only when sourceSitemapUrl is configured.
|
|
29
|
+
...(normalizeSourceUrls(sourceSitemapUrl).length > 0
|
|
30
|
+
? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
|
|
31
|
+
: []),
|
|
32
|
+
].join("\n") + "\n"
|
|
33
|
+
: "";
|
|
34
|
+
|
|
35
|
+
const body = `User-agent: Googlebot
|
|
36
|
+
Allow: /
|
|
37
|
+
|
|
38
|
+
User-agent: Bingbot
|
|
39
|
+
Allow: /
|
|
40
|
+
|
|
41
|
+
User-agent: Twitterbot
|
|
42
|
+
Allow: /
|
|
43
|
+
|
|
44
|
+
User-agent: facebookexternalhit
|
|
45
|
+
Allow: /
|
|
46
|
+
|
|
47
|
+
User-agent: *
|
|
48
|
+
Allow: /
|
|
49
|
+
|
|
50
|
+
${sitemap}`;
|
|
51
|
+
|
|
52
|
+
return new Response(body, {
|
|
53
|
+
headers: { "Content-Type": "text/plain" },
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import packageJson from "../package.json";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The runtime version every page announces (`<meta name="it-site-runtime">`).
|
|
5
|
+
* Read straight from the installed package, so the repo's pinned version IS
|
|
6
|
+
* the version the rendered site reports — no second place to edit, nothing to
|
|
7
|
+
* re-stamp on a bump.
|
|
8
|
+
*/
|
|
9
|
+
export const SITE_RUNTIME_VERSION: string = packageJson.version;
|