@iterant/site-runtime 3.9.0 → 3.11.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.9.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.11.0._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
@@ -157,7 +157,7 @@ CommonMark, stored as written.
157
157
 
158
158
  const body = readMarkdown(data.body); // raw CommonMark | null
159
159
  <div
160
- class="prose"
160
+ class="markdown-body"
161
161
  data-editable="body"
162
162
  data-path="props.body"
163
163
  data-edit-type="markdown"
@@ -165,6 +165,22 @@ CommonMark, stored as written.
165
165
  />;
166
166
  ```
167
167
 
168
+ - **`markdown-body` is the wrapper class, and the stylesheet is opt-in
169
+ (3.10.0).** The compiler emits bare `<h2>`, `<ul>` and `<blockquote>`, and
170
+ Preflight flattens all three to paragraph size, so an unstyled body renders as
171
+ a run of identical lines. Add the import to `src/styles/globals.css` once,
172
+ below `@import "tailwindcss"`:
173
+
174
+ ```css
175
+ @import "@iterant/site-runtime/styles/markdown.css";
176
+ ```
177
+
178
+ It styles measure, rhythm and hierarchy from the repo's own `--it-*` and
179
+ `--font-*` tokens, and reads two knobs of its own:
180
+ `--it-markdown-measure` (the reading width, `68ch`, `none` to fill) and
181
+ `--it-markdown-flow` (the space between blocks, `1.25em`). Every rule sits in
182
+ the `base` layer, so a utility class on the wrapper still wins.
183
+
168
184
  - **Headings start at `##`.** The body renders below the page's own h1; the
169
185
  compiler does not demote levels for you.
170
186
  - Hrefs inside the value follow the same rules as link wrappers: root-relative
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.9.0",
3
+ "version": "3.11.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": {
@@ -27,6 +27,7 @@
27
27
  "docs",
28
28
  "scripts",
29
29
  "src",
30
+ "styles",
30
31
  "!**/*.test.ts",
31
32
  "!scripts/check-fixture.mjs",
32
33
  "!scripts/check-packed.mjs",
@@ -59,6 +60,7 @@
59
60
  "./routes": "./src/routes/index.ts",
60
61
  "./config": "./src/config/preset.ts",
61
62
  "./integrations/*": "./src/integrations/*.mjs",
63
+ "./styles/markdown.css": "./styles/markdown.css",
62
64
  "./package.json": "./package.json"
63
65
  },
64
66
  "iterant": {
@@ -113,6 +113,7 @@ async function bespokeInventory() {
113
113
  * `/es` (and `/es/<base>`) to `src/pages/[locale]/index.astro` (respectively
114
114
  * `[locale]/<base>.astro`) before any rest-param catch-all, so when that file
115
115
  * exists the sibling never renders through the catch-all this gate asserts.
116
+ * @param {string} base
116
117
  */
117
118
  async function siblingRouteShadowed(base) {
118
119
  const file = base === "home" ? "index.astro" : `${base}.astro`;
@@ -135,6 +135,12 @@ export function createContentSchemas({
135
135
  // design ships its own nav/footer (a full-design import) so the layout
136
136
  // mounts no chrome. Additive — existing entries omit it and keep chrome.
137
137
  chrome: z.boolean().default(true),
138
+ // Kept out of search (site-runtime 3.11.0): the ONE owner of noindex.
139
+ // The layout renders noindex,nofollow from it, and the sitemap, llms.txt
140
+ // and a sibling's hreflang leave the page out. Each locale sibling
141
+ // carries its own value, as draft does. Additive: existing entries omit
142
+ // it and stay indexed.
143
+ noindex: z.boolean().default(false),
138
144
  // Which SHELL this page renders in (site-runtime 3.2.0). A replicated
139
145
  // site can carry more than one frame: a page whose chrome differed from
140
146
  // the site's kept its own under a scope suffix, and the shell registry
@@ -159,16 +165,23 @@ export function createContentSchemas({
159
165
  .object({
160
166
  title: z.string(),
161
167
  description: z.string().optional(),
162
- // Social/share image. src must be an absolute URL (crawlers 403 on
163
- // signed S3 URLs publish CDN URLs only). assetId links back to the
164
- // brand asset; alt is the accessible/og:image:alt text.
168
+ // Social/share image. src is a root-relative path, which the layout
169
+ // resolves against the site origin at render time, or an https URL
170
+ // (a published CDN URL; crawlers 403 on signed S3 URLs, and a
171
+ // scheme-relative or http src never reaches a share card). assetId
172
+ // links back to the brand asset; alt is the og:image:alt text.
165
173
  ogImage: z
166
174
  .object({
167
175
  // Every other image leaf in an entry is tagged type: "image",
168
176
  // and the model reaches for the same tag here; the tag is
169
177
  // accepted so a share image never refuses a whole page write.
170
178
  type: z.literal("image").optional(),
171
- src: z.string().url(),
179
+ src: z
180
+ .string()
181
+ .regex(
182
+ /^(?:\/(?!\/)\S*|https:\/\/\S+)$/,
183
+ "ogImage.src must be a root-relative path or an https URL",
184
+ ),
172
185
  assetId: z.string().optional(),
173
186
  alt: z.string().optional(),
174
187
  })
@@ -86,7 +86,7 @@ const {
86
86
  canonical,
87
87
  image,
88
88
  imageAlt,
89
- noindex = false,
89
+ noindex,
90
90
  type = "website",
91
91
  siteName = siteConfig.name,
92
92
  jsonLd,
@@ -144,6 +144,11 @@ const structuredData = resolveStructuredData(
144
144
  // prop, which is what makes the forward safe to ship ahead of any emit.
145
145
  const shellId = shell ?? pageEntry?.data.shell;
146
146
 
147
+ // Kept out of search, from the same entry, so the page's own noindex reaches
148
+ // <meta robots> through a catch-all that threads nothing. A shell that passes
149
+ // the prop wins, like shell does; no entry and no prop means indexed.
150
+ const noindexResolved = noindex ?? pageEntry?.data.noindex ?? false;
151
+
147
152
  // Machine-fed related links (ILV-6): the machine-owned src/content/links.json
148
153
  // manifest, resolved by this page's own route key. A locale sibling looks up
149
154
  // its full prefixed route; there is no fallback to the base route, because the
@@ -225,7 +230,7 @@ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false
225
230
  canonicalUrl={canonicalUrl}
226
231
  imageUrl={imageUrl}
227
232
  imageAlt={imageAlt}
228
- noindex={noindex}
233
+ noindex={noindexResolved}
229
234
  type={type}
230
235
  pageType={structuredData.pageType}
231
236
  datePublished={structuredData.datePublished}
@@ -0,0 +1,17 @@
1
+ // Whether a page entry is advertised to crawlers and agents: listed in the
2
+ // sitemap and llms.txt, and named by its siblings' hreflang. One flag with one
3
+ // meaning: `noindex` keeps a page out everywhere, and a draft is out except
4
+ // where drafts are shown (the dev server). Absent flags read as live and
5
+ // indexed, which is what every entry written before the flags carries.
6
+ export interface AdvertisedFlags {
7
+ draft?: boolean;
8
+ noindex?: boolean;
9
+ }
10
+
11
+ export function isAdvertised(
12
+ entry: AdvertisedFlags,
13
+ options: { includeDrafts: boolean },
14
+ ): boolean {
15
+ if (entry.noindex) return false;
16
+ return !entry.draft || options.includeDrafts;
17
+ }
@@ -1,3 +1,4 @@
1
+ import { isAdvertised } from "./advertised";
1
2
  import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
2
3
 
3
4
  // hreflang alternates for locale sibling pages (starter 2.9.0). A page with
@@ -21,12 +22,14 @@ export interface HreflangEntry {
21
22
  /** The entry's `route` field (`/example`, `/es/example`). */
22
23
  route: string;
23
24
  draft: boolean;
25
+ /** Kept out of search; never advertised as an alternate. */
26
+ noindex?: boolean;
24
27
  }
25
28
 
26
29
  /** A `pages` collection entry, as `pageLocaleHead` consumes it. */
27
30
  export interface PagesCollectionEntry {
28
31
  id: string;
29
- data: { route: string; draft: boolean };
32
+ data: { route: string; draft: boolean; noindex?: boolean };
30
33
  }
31
34
 
32
35
  /**
@@ -58,6 +61,7 @@ export function pageLocaleHead(params: {
58
61
  id: entry.id,
59
62
  route: entry.data.route,
60
63
  draft: entry.data.draft,
64
+ noindex: entry.data.noindex,
61
65
  })),
62
66
  baseName: parseEntryId(params.entryId).base,
63
67
  site: params.site,
@@ -74,8 +78,8 @@ function absolute(route: string, site: URL | string | undefined): string {
74
78
  * The reciprocal hreflang set for the group `baseName` belongs to, derived from
75
79
  * ALL page entries (the caller passes the whole collection). Every non-draft
76
80
  * entry in the group contributes one alternate; the base entry also seeds the
77
- * single `x-default`. Draft siblings are never advertised (unpublished
78
- * translations must not leak to crawlers). Deduped on the normalized locale key
81
+ * single `x-default`. Draft and noindex siblings are never advertised
82
+ * (unpublished translations and private pages must not leak to crawlers). Deduped on the normalized locale key
79
83
  * (first entry wins); hrefs absolute via `site` (`Astro.site`). Returns `[]`
80
84
  * when the group has no non-draft siblings — a lone page emits no hreflang.
81
85
  */
@@ -92,7 +96,8 @@ export function deriveHreflangAlternates(params: {
92
96
  >();
93
97
  let base: { route: string } | undefined;
94
98
  for (const entry of entries) {
95
- if (entry.draft) continue; // never advertise a draft sibling/base
99
+ // Never advertise a draft or a noindex sibling or base.
100
+ if (!isAdvertised(entry, { includeDrafts: false })) continue;
96
101
  const { base: entryBase, locale } = parseEntryId(entry.id);
97
102
  if (entryBase !== baseName) continue;
98
103
  const isBase = !locale;
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Locale siblings (`<page>.<locale>.json`) are ordinary entries here: each
8
8
  // contributes its own prefixed route (`/es/example`) so every published locale
9
- // is in the sitemap. Drafts (siblings inherit the base's `draft`) are skipped,
10
- // so an unpublished translation never appears.
9
+ // is in the sitemap. Each file's own `draft` and `noindex` are read, so an
10
+ // unpublished translation or a private page never appears.
11
11
  //
12
12
  // `root` is the CONSUMING repo's project root, which the caller reads off
13
13
  // Astro's resolved config rather than guessing from cwd: the package sits in
@@ -16,6 +16,7 @@
16
16
  import { readdirSync, readFileSync } from "node:fs";
17
17
  import { isAbsolute, join } from "node:path";
18
18
 
19
+ import { isAdvertised } from "../advertised";
19
20
  import { DEFAULT_PAGES_DIR } from "../content-paths";
20
21
 
21
22
  export interface SitemapPathsOptions {
@@ -31,8 +32,8 @@ function resolvePagesDir({ pagesDir, root }: SitemapPathsOptions): string {
31
32
  }
32
33
 
33
34
  /**
34
- * Every non-draft page entry's route, home excluded (@astrojs/sitemap already
35
- * emits the site root). Returns `{ paths, pagesDir }` so a caller can report
35
+ * Every advertised page entry's route (neither draft nor noindex), home
36
+ * excluded (@astrojs/sitemap already emits the site root). Returns `{ paths, pagesDir }` so a caller can report
36
37
  * WHERE it looked when the answer is empty.
37
38
  */
38
39
  export function getSitemapPaths(options: SitemapPathsOptions = {}): {
@@ -54,7 +55,8 @@ export function getSitemapPaths(options: SitemapPathsOptions = {}): {
54
55
  .map((rel) => {
55
56
  try {
56
57
  const data = JSON.parse(readFileSync(join(pagesDir, rel), "utf8"));
57
- if (data.draft || typeof data.route !== "string") return null;
58
+ if (typeof data.route !== "string") return null;
59
+ if (!isAdvertised(data, { includeDrafts: false })) return null;
58
60
  return data.route as string;
59
61
  } catch {
60
62
  return null; // malformed entry; the build will surface the schema error
@@ -8,6 +8,22 @@ import { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
8
8
  // placeholder and swap it for the resolved `config.site` at sitemap emit time.
9
9
  const PLACEHOLDER = "https://starter.invalid";
10
10
 
11
+ // Routes the platform serves that are never a page of the site: the holding
12
+ // page a brand shows before its first publish. @astrojs/sitemap lists every
13
+ // prerendered route, and these have no entry to carry noindex, so the filter
14
+ // names them here.
15
+ const PLATFORM_DENY = new Set(["/under-construction"]);
16
+
17
+ /** Whether a sitemap URL may be listed: everything but the platform's own routes. */
18
+ export function sitemapPageAllowed(url: string): boolean {
19
+ try {
20
+ const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/";
21
+ return !PLATFORM_DENY.has(pathname);
22
+ } catch {
23
+ return true;
24
+ }
25
+ }
26
+
11
27
  export type SitemapWithCustomPagesOptions = SitemapOptions &
12
28
  SitemapPathsOptions;
13
29
 
@@ -17,6 +33,7 @@ export function sitemapWithCustomPages(
17
33
  let resolvedSite = "";
18
34
  const { pagesDir, root, ...sitemapOptions } = options;
19
35
  const userSerialize = sitemapOptions.serialize;
36
+ const userFilter = sitemapOptions.filter;
20
37
 
21
38
  // Filled in at astro:config:setup and read by @astrojs/sitemap at
22
39
  // astro:build:done, which is what lets the entry routes be discovered against
@@ -67,6 +84,8 @@ export function sitemapWithCustomPages(
67
84
  sitemap({
68
85
  ...sitemapOptions,
69
86
  customPages,
87
+ filter: (page) =>
88
+ sitemapPageAllowed(page) && (userFilter ? userFilter(page) : true),
70
89
  serialize(item) {
71
90
  if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
72
91
  item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
@@ -3,7 +3,18 @@
3
3
  // package bump. No route is injected: a route that exists in no repo file breaks
4
4
  // the src/pages/ mental model and the debuggability contract.
5
5
  export { createLlmsTxtRoute, type LlmsTxtOptions } from "./llms-txt";
6
- export { createRobotsTxtRoute, type RobotsTxtOptions } from "./robots-txt";
6
+ export {
7
+ AI_ANSWER_CRAWLERS,
8
+ AI_POLICY_PRESETS,
9
+ AI_TRAINING_CRAWLERS,
10
+ createRobotsTxtRoute,
11
+ renderRobotsTxt,
12
+ resolveAiPolicy,
13
+ type AiPolicy,
14
+ type AiPolicyPreset,
15
+ type RobotsSiteConfig,
16
+ type RobotsTxtOptions,
17
+ } from "./robots-txt";
7
18
  export {
8
19
  createProxiedSitemapRoute,
9
20
  createSitemapRoute,
@@ -1,4 +1,5 @@
1
1
  import { getCollection } from "astro:content";
2
+ import { isAdvertised } from "../lib/advertised";
2
3
  import type { APIRoute } from "astro";
3
4
 
4
5
  // /llms.txt, generated at dev/build time with no crawler or AI step: a curated
@@ -24,8 +25,11 @@ const escapeLinkText = (value: string) =>
24
25
  export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
25
26
  return async ({ site }) => {
26
27
  const origin = site?.origin ?? "https://example.com";
27
- const pages = (await getCollection("pages")).filter(
28
- (entry) => !entry.data.draft || !import.meta.env.PROD,
28
+ const pages = (await getCollection("pages")).filter((entry) =>
29
+ isAdvertised(
30
+ { draft: entry.data.draft, noindex: entry.data.noindex },
31
+ { includeDrafts: !import.meta.env.PROD },
32
+ ),
29
33
  );
30
34
 
31
35
  const contentLinks = pages
@@ -2,55 +2,146 @@ import type { APIRoute } from "astro";
2
2
 
3
3
  import { normalizeSourceUrls } from "../lib/sitemap/shared";
4
4
 
5
- // /robots.txt: crawler permissions plus the sitemap pointers. The repo keeps the
6
- // route file and this supplies the body:
5
+ // /robots.txt: crawler permissions, the site's AI policy and the sitemap
6
+ // pointers. The repo keeps the route file and this supplies the body:
7
7
  //
8
8
  // src/pages/robots.txt.ts
9
9
  // ---
10
10
  // export const prerender = true;
11
- // export const GET = createRobotsTxtRoute({
12
- // sourceSitemapUrl: SITE_CONFIG.sourceSitemapUrl,
13
- // });
11
+ // export const GET = createRobotsTxtRoute({ siteConfig: SITE_CONFIG });
12
+ //
13
+ // A shim written before 3.11.0 passes `{ sourceSitemapUrl }` alone and keeps
14
+ // working: the policy then renders its recommended default.
14
15
 
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[];
16
+ /** What the site lets AI systems do with its content. The Content-Signal
17
+ * line states it and the per-crawler blocks enforce it. */
18
+ export interface AiPolicy {
19
+ /** Search engines may index and show the pages. */
20
+ search: boolean;
21
+ /** AI answers and assistants may read the pages to answer a person. */
22
+ aiInput: boolean;
23
+ /** AI models may train on the pages. */
24
+ aiTrain: boolean;
19
25
  }
20
26
 
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
- : "";
27
+ export type AiPolicyPreset = "recommended" | "open" | "closed";
28
+
29
+ export const AI_POLICY_PRESETS: Record<AiPolicyPreset, AiPolicy> = {
30
+ // Findable, quotable, not trained on: what the readiness playbook recommends.
31
+ recommended: { search: true, aiInput: true, aiTrain: false },
32
+ open: { search: true, aiInput: true, aiTrain: true },
33
+ closed: { search: true, aiInput: false, aiTrain: false },
34
+ };
35
+
36
+ // The AI crawlers the public readiness scanner names, by the purpose each one
37
+ // declares. One list per language: the platform's robots parser carries the
38
+ // same fifteen names, and the test here pins them.
39
+ export const AI_TRAINING_CRAWLERS = [
40
+ "GPTBot",
41
+ "ClaudeBot",
42
+ "Claude-Web",
43
+ "anthropic-ai",
44
+ "CCBot",
45
+ "Bytespider",
46
+ "cohere-ai",
47
+ "Google-Extended",
48
+ "GoogleOther",
49
+ "Applebot-Extended",
50
+ "FacebookBot",
51
+ ] as const;
52
+
53
+ export const AI_ANSWER_CRAWLERS = [
54
+ "OAI-SearchBot",
55
+ "ChatGPT-User",
56
+ "PerplexityBot",
57
+ "Amazonbot",
58
+ ] as const;
59
+
60
+ const SEARCH_CRAWLERS = [
61
+ "Googlebot",
62
+ "Bingbot",
63
+ "Twitterbot",
64
+ "facebookexternalhit",
65
+ ] as const;
66
+
67
+ /** The fields of `SITE_CONFIG` this route reads. */
68
+ export interface RobotsSiteConfig {
69
+ /** A mirrored legacy sitemap, advertised only when configured. */
70
+ sourceSitemapUrl?: string | string[];
71
+ /** The AI policy, explicit or by preset. Absent: `recommended`. */
72
+ aiPolicy?: AiPolicy | AiPolicyPreset;
73
+ /** Disallow paths carried over from the site's previous robots.txt, rendered
74
+ * under the wildcard agent so what the old site kept out stays out. */
75
+ legacyRules?: string[];
76
+ }
34
77
 
35
- const body = `User-agent: Googlebot
36
- Allow: /
78
+ export interface RobotsTxtOptions extends RobotsSiteConfig {
79
+ /** The brand's `SITE_CONFIG`, whole, so a field added later needs no shim
80
+ * change. Its fields win over the flat ones. */
81
+ siteConfig?: RobotsSiteConfig;
82
+ }
37
83
 
38
- User-agent: Bingbot
39
- Allow: /
84
+ export function resolveAiPolicy(
85
+ policy: AiPolicy | AiPolicyPreset | undefined,
86
+ ): AiPolicy {
87
+ if (policy === undefined) return AI_POLICY_PRESETS.recommended;
88
+ if (typeof policy === "string") {
89
+ return AI_POLICY_PRESETS[policy] ?? AI_POLICY_PRESETS.recommended;
90
+ }
91
+ return policy;
92
+ }
40
93
 
41
- User-agent: Twitterbot
42
- Allow: /
94
+ const grant = (allowed: boolean) => (allowed ? "Allow: /" : "Disallow: /");
95
+ const yesNo = (value: boolean) => (value ? "yes" : "no");
43
96
 
44
- User-agent: facebookexternalhit
45
- Allow: /
97
+ function agentBlocks(agents: readonly string[], allowed: boolean): string[] {
98
+ return agents.map((agent) => `User-agent: ${agent}\n${grant(allowed)}`);
99
+ }
46
100
 
47
- User-agent: *
48
- Allow: /
101
+ function legacyDisallows(rules: string[] | undefined): string[] {
102
+ return (rules ?? [])
103
+ .map((rule) => rule.replace(/^\s*disallow\s*:/i, "").trim())
104
+ .filter((path) => path.length > 0)
105
+ .map((path) => `Disallow: ${path}`);
106
+ }
49
107
 
50
- ${sitemap}`;
108
+ export function renderRobotsTxt(
109
+ config: RobotsSiteConfig,
110
+ site: URL | undefined,
111
+ ): string {
112
+ const policy = resolveAiPolicy(config.aiPolicy);
113
+ const sitemap = site
114
+ ? [
115
+ `Sitemap: ${new URL("sitemap-index.xml", site).href}`,
116
+ // Mirrored sitemap, only when sourceSitemapUrl is configured.
117
+ ...(normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0
118
+ ? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
119
+ : []),
120
+ ].join("\n") + "\n"
121
+ : "";
122
+ const wildcard = [
123
+ "User-agent: *",
124
+ "Allow: /",
125
+ ...legacyDisallows(config.legacyRules),
126
+ `Content-Signal: search=${yesNo(policy.search)}, ai-input=${yesNo(policy.aiInput)}, ai-train=${yesNo(policy.aiTrain)}`,
127
+ ].join("\n");
128
+ return (
129
+ [
130
+ ...agentBlocks(SEARCH_CRAWLERS, policy.search),
131
+ ...agentBlocks(AI_ANSWER_CRAWLERS, policy.aiInput),
132
+ ...agentBlocks(AI_TRAINING_CRAWLERS, policy.aiTrain),
133
+ wildcard,
134
+ ].join("\n\n") +
135
+ "\n\n" +
136
+ sitemap
137
+ );
138
+ }
51
139
 
52
- return new Response(body, {
140
+ export function createRobotsTxtRoute(options: RobotsTxtOptions): APIRoute {
141
+ const { siteConfig, ...flat } = options;
142
+ const config: RobotsSiteConfig = { ...flat, ...(siteConfig ?? {}) };
143
+ return ({ site }) =>
144
+ new Response(renderRobotsTxt(config, site), {
53
145
  headers: { "Content-Type": "text/plain" },
54
146
  });
55
- };
56
147
  }
@@ -0,0 +1,221 @@
1
+ /*
2
+ * The article-body stylesheet: `class="markdown-body"` around compiled
3
+ * `renderMarkdown` output.
4
+ *
5
+ * It exists because `renderMarkdown` is bare micromark: it emits `<h2>`, `<ul>`
6
+ * and `<blockquote>` with no classes, and Tailwind's Preflight flattens every
7
+ * one of them to the paragraph's size, weight and margin. A body compiled
8
+ * without this file renders as a run of identical lines, which is what a blog
9
+ * post looked like through 3.9.0.
10
+ *
11
+ * OPT-IN, and imported once. The package ships no stylesheet of its own and
12
+ * LayoutCore imports none, so nothing here reaches a brand until that brand's
13
+ * `globals.css` names it:
14
+ *
15
+ * @import "@iterant/site-runtime/styles/markdown.css";
16
+ *
17
+ * Put the line AFTER `@import "tailwindcss"`. Everything below lives in the
18
+ * `base` layer, so it wins over Preflight by order and loses to every utility
19
+ * class: `class="markdown-body text-lg"` still reads as the site author wrote
20
+ * it, and a brand rule outside a layer overrides anything here.
21
+ *
22
+ * Colors, fonts, radius and border come from the `--it-*` and `--font-*` tokens
23
+ * a brand's `globals.css` defines, each with a fallback so the file also works
24
+ * in a repo that defines none. Two knobs are its own, settable on any ancestor:
25
+ *
26
+ * --it-markdown-measure the reading width (default 68ch; `none` to fill)
27
+ * --it-markdown-flow the space between blocks (default 1.25em)
28
+ */
29
+
30
+ @layer base {
31
+ .markdown-body {
32
+ color: var(--it-text-primary, currentColor);
33
+ font-family: var(--font-body, inherit);
34
+ font-size: var(--font-size-base, 1rem);
35
+ font-weight: var(--font-body-weight, 400);
36
+ line-height: var(--font-body-line-height, 1.7);
37
+ max-width: var(--it-markdown-measure, 68ch);
38
+ }
39
+
40
+ /* The wrapper's own spacing belongs to the page around it. */
41
+ .markdown-body > :first-child {
42
+ margin-top: 0;
43
+ }
44
+
45
+ .markdown-body > :last-child {
46
+ margin-bottom: 0;
47
+ }
48
+
49
+ .markdown-body p {
50
+ margin: 0 0 var(--it-markdown-flow, 1.25em);
51
+ }
52
+
53
+ /* A body opens at `##` under the page's own h1, so h2 carries the section
54
+ weight here. h1 is styled anyway: a pasted body that starts at `#` should
55
+ read as a heading rather than as an outsized paragraph. */
56
+ .markdown-body h1,
57
+ .markdown-body h2,
58
+ .markdown-body h3,
59
+ .markdown-body h4 {
60
+ color: var(--it-text-primary, currentColor);
61
+ font-family: var(--font-heading, inherit);
62
+ font-weight: var(--font-heading-weight, 700);
63
+ letter-spacing: var(--font-heading-letter-spacing, normal);
64
+ line-height: var(--font-heading-line-height, 1.2);
65
+ margin: 2em 0 0.6em;
66
+ text-wrap: balance;
67
+ }
68
+
69
+ .markdown-body h1 {
70
+ font-size: var(--font-size-3xl, 1.875rem);
71
+ }
72
+
73
+ .markdown-body h2 {
74
+ font-size: var(--font-size-2xl, 1.5rem);
75
+ }
76
+
77
+ .markdown-body h3 {
78
+ font-size: var(--font-size-xl, 1.25rem);
79
+ }
80
+
81
+ .markdown-body h4 {
82
+ font-size: var(--font-size-lg, 1.125rem);
83
+ }
84
+
85
+ .markdown-body ul,
86
+ .markdown-body ol {
87
+ margin: 0 0 var(--it-markdown-flow, 1.25em);
88
+ padding-left: 1.5em;
89
+ }
90
+
91
+ .markdown-body ul {
92
+ list-style: disc;
93
+ }
94
+
95
+ .markdown-body ol {
96
+ list-style: decimal;
97
+ }
98
+
99
+ .markdown-body ul ul {
100
+ list-style: circle;
101
+ }
102
+
103
+ .markdown-body li {
104
+ margin-block: 0.35em;
105
+ }
106
+
107
+ .markdown-body li::marker {
108
+ color: var(--it-text-secondary, currentColor);
109
+ }
110
+
111
+ /* A nested list is part of its parent item, not a new block. */
112
+ .markdown-body li > ul,
113
+ .markdown-body li > ol {
114
+ margin-block: 0.35em;
115
+ }
116
+
117
+ .markdown-body li > p {
118
+ margin-bottom: 0.35em;
119
+ }
120
+
121
+ .markdown-body blockquote {
122
+ border-left: var(--border-width, 1px) var(--border-style, solid)
123
+ var(--it-border-primary, currentColor);
124
+ color: var(--it-text-secondary, currentColor);
125
+ margin: var(--it-markdown-flow, 1.25em) 0;
126
+ padding-left: 1.25em;
127
+ }
128
+
129
+ .markdown-body blockquote > :last-child {
130
+ margin-bottom: 0;
131
+ }
132
+
133
+ .markdown-body code {
134
+ font-family: var(--font-mono, ui-monospace, monospace);
135
+ font-size: 0.9em;
136
+ }
137
+
138
+ /* A code span is a chip; a fence is a surface. */
139
+ .markdown-body :not(pre) > code {
140
+ background-color: var(--it-background-secondary, transparent);
141
+ border-radius: calc(var(--radius, 0.5rem) / 2);
142
+ padding: 0.15em 0.4em;
143
+ }
144
+
145
+ .markdown-body pre {
146
+ background-color: var(--it-background-secondary, transparent);
147
+ border-radius: var(--radius, 0.5rem);
148
+ line-height: 1.55;
149
+ margin: var(--it-markdown-flow, 1.25em) 0;
150
+ overflow-x: auto;
151
+ padding: 1em 1.15em;
152
+ }
153
+
154
+ .markdown-body pre code {
155
+ background-color: transparent;
156
+ font-size: inherit;
157
+ padding: 0;
158
+ }
159
+
160
+ /* The link keeps the body's color: an accent token is a background color in
161
+ some brand palettes, and a link mid-sentence has to stay readable in all of
162
+ them. The underline is what marks it. */
163
+ .markdown-body a {
164
+ color: inherit;
165
+ text-decoration: underline;
166
+ text-decoration-thickness: from-font;
167
+ text-underline-offset: 0.2em;
168
+ }
169
+
170
+ .markdown-body a:hover {
171
+ text-decoration-thickness: 2px;
172
+ }
173
+
174
+ .markdown-body strong {
175
+ color: var(--it-text-primary, currentColor);
176
+ font-weight: var(--font-body-bold-weight, 600);
177
+ }
178
+
179
+ .markdown-body hr {
180
+ border: 0;
181
+ border-top: var(--border-width, 1px) var(--border-style, solid)
182
+ var(--it-border-primary, currentColor);
183
+ margin: 2.5em 0;
184
+ }
185
+
186
+ .markdown-body img {
187
+ display: block;
188
+ height: auto;
189
+ margin: var(--it-markdown-flow, 1.25em) 0;
190
+ max-width: 100%;
191
+ }
192
+
193
+ /* GFM tables. The table itself scrolls, so a wide one never widens the page. */
194
+ .markdown-body table {
195
+ border-collapse: collapse;
196
+ display: block;
197
+ font-size: 0.95em;
198
+ margin: var(--it-markdown-flow, 1.25em) 0;
199
+ max-width: 100%;
200
+ overflow-x: auto;
201
+ width: max-content;
202
+ }
203
+
204
+ .markdown-body th,
205
+ .markdown-body td {
206
+ border: var(--border-width, 1px) var(--border-style, solid)
207
+ var(--it-border-primary, currentColor);
208
+ padding: 0.5em 0.75em;
209
+ }
210
+
211
+ /* gfm writes `align` on a column the author aligned; only the rest defaults. */
212
+ .markdown-body th:not([align]),
213
+ .markdown-body td:not([align]) {
214
+ text-align: left;
215
+ }
216
+
217
+ .markdown-body th {
218
+ background-color: var(--it-background-secondary, transparent);
219
+ font-weight: var(--font-body-bold-weight, 600);
220
+ }
221
+ }