blume 1.6.0 → 1.6.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +1318 -270
  3. package/dist/cli/index.js.map +68 -61
  4. package/dist/types/core/config-input.d.ts +9 -0
  5. package/dist/types/core/data.d.ts +12 -1
  6. package/dist/types/core/i18n-ui.d.ts +4 -0
  7. package/dist/types/core/schema.d.ts +7 -0
  8. package/dist/types/core/types.d.ts +6 -0
  9. package/dist/types/openapi/references.d.ts +5 -0
  10. package/docs/07-faq.mdx +9 -9
  11. package/docs/advanced/api-reference.mdx +10 -1
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/graphql.mdx +1 -1
  14. package/docs/configuration/ai.mdx +76 -7
  15. package/docs/configuration/seo.mdx +3 -3
  16. package/docs/configuration/theming.mdx +6 -0
  17. package/docs/content/components.mdx +8 -1
  18. package/docs/index.mdx +2 -2
  19. package/package.json +53 -53
  20. package/skills/blume/SKILL.md +2 -2
  21. package/src/ai/agent-readability.ts +60 -17
  22. package/src/ai/api/handlers.ts +273 -0
  23. package/src/ai/api/paths.ts +14 -0
  24. package/src/ai/api/problem.ts +63 -0
  25. package/src/ai/api/spec.ts +681 -0
  26. package/src/ai/api-catalog.ts +11 -1
  27. package/src/ai/link-headers.ts +12 -3
  28. package/src/ai/llms.ts +9 -2
  29. package/src/ai/mcp/query.ts +390 -0
  30. package/src/ai/mcp/server.ts +32 -352
  31. package/src/astro/examples.ts +29 -2
  32. package/src/astro/generate.ts +256 -64
  33. package/src/astro/index.ts +7 -0
  34. package/src/astro/markdown-negotiation.ts +1 -1
  35. package/src/astro/runtime-modules.ts +196 -0
  36. package/src/astro/templates.ts +398 -38
  37. package/src/cli/commands/build.ts +9 -1
  38. package/src/cli/commands/dev.ts +6 -3
  39. package/src/cli/host-args.ts +18 -0
  40. package/src/cli/index.ts +2 -1
  41. package/src/components/copy-feedback.ts +93 -9
  42. package/src/components/islands/ask-ai.tsx +4 -1
  43. package/src/components/islands/hooks.ts +3 -1
  44. package/src/components/layout/PageActions.astro +25 -14
  45. package/src/core/config-input.ts +9 -0
  46. package/src/core/data.ts +17 -2
  47. package/src/core/define-components.ts +2 -0
  48. package/src/core/i18n-ui.ts +3 -0
  49. package/src/core/includes.ts +2 -1
  50. package/src/core/manifest.ts +10 -0
  51. package/src/core/schema.ts +20 -5
  52. package/src/core/types.ts +6 -0
  53. package/src/core/ui-packs/ar.ts +1 -0
  54. package/src/core/ui-packs/bg.ts +1 -0
  55. package/src/core/ui-packs/bn.ts +1 -0
  56. package/src/core/ui-packs/ca.ts +1 -0
  57. package/src/core/ui-packs/cs.ts +1 -0
  58. package/src/core/ui-packs/da.ts +1 -0
  59. package/src/core/ui-packs/de.ts +1 -0
  60. package/src/core/ui-packs/el.ts +1 -0
  61. package/src/core/ui-packs/es.ts +1 -0
  62. package/src/core/ui-packs/fa.ts +1 -0
  63. package/src/core/ui-packs/fi.ts +1 -0
  64. package/src/core/ui-packs/fr.ts +1 -0
  65. package/src/core/ui-packs/he.ts +1 -0
  66. package/src/core/ui-packs/hi.ts +1 -0
  67. package/src/core/ui-packs/hr.ts +1 -0
  68. package/src/core/ui-packs/hu.ts +1 -0
  69. package/src/core/ui-packs/id.ts +1 -0
  70. package/src/core/ui-packs/it.ts +1 -0
  71. package/src/core/ui-packs/ja.ts +1 -0
  72. package/src/core/ui-packs/ko.ts +1 -0
  73. package/src/core/ui-packs/nl.ts +1 -0
  74. package/src/core/ui-packs/no.ts +1 -0
  75. package/src/core/ui-packs/pl.ts +1 -0
  76. package/src/core/ui-packs/pt-br.ts +1 -0
  77. package/src/core/ui-packs/pt.ts +1 -0
  78. package/src/core/ui-packs/ro.ts +1 -0
  79. package/src/core/ui-packs/ru.ts +1 -0
  80. package/src/core/ui-packs/sk.ts +1 -0
  81. package/src/core/ui-packs/sr.ts +1 -0
  82. package/src/core/ui-packs/sv.ts +1 -0
  83. package/src/core/ui-packs/th.ts +1 -0
  84. package/src/core/ui-packs/tr.ts +1 -0
  85. package/src/core/ui-packs/uk.ts +1 -0
  86. package/src/core/ui-packs/vi.ts +1 -0
  87. package/src/core/ui-packs/zh-tw.ts +1 -0
  88. package/src/core/ui-packs/zh.ts +1 -0
  89. package/src/core/version-cut.ts +5 -3
  90. package/src/deploy/vercel-negotiation.ts +97 -6
  91. package/src/og/card.ts +1 -1
  92. package/src/openapi/references.ts +8 -0
  93. package/src/openapi/render-mdx.ts +18 -4
  94. package/src/openapi/scalar.ts +0 -4
  95. package/src/registry/eject.ts +36 -17
  96. package/src/theme/entry.ts +2 -2
  97. package/src/theme/sources.ts +49 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Rewrite a bare `--host` in the raw argv to `--host=` before citty parses it.
3
+ *
4
+ * `host` is a string arg (citty has no mixed string/boolean type), and citty
5
+ * 0.2 parses with `node:util.parseArgs`, where a string option consumes the
6
+ * next token as its value even when that token is another flag: `blume dev
7
+ * --host --open` would bind the literal hostname "--open" and drop `--open`.
8
+ * The `--host=` spelling parses as `""` without touching its neighbor, which
9
+ * `normalizeHost` then maps to Astro's "bind all interfaces".
10
+ */
11
+ export const normalizeHostArgs = (rawArgs: readonly string[]): string[] =>
12
+ rawArgs.map((arg, index) => {
13
+ if (arg !== "--host") {
14
+ return arg;
15
+ }
16
+ const next = rawArgs[index + 1];
17
+ return next === undefined || next.startsWith("-") ? "--host=" : arg;
18
+ });
package/src/cli/index.ts CHANGED
@@ -17,6 +17,7 @@ import { translateCommand } from "./commands/translate.ts";
17
17
  import { validateCommand } from "./commands/validate.ts";
18
18
  import { versionCommand } from "./commands/version.ts";
19
19
  import { loadEnvFiles } from "./env.ts";
20
+ import { normalizeHostArgs } from "./host-args.ts";
20
21
  import { reportInternalError } from "./internal-error.ts";
21
22
 
22
23
  const main = defineCommand({
@@ -60,4 +61,4 @@ process.on("unhandledRejection", (error) => {
60
61
  process.exit(1);
61
62
  });
62
63
 
63
- runMain(main);
64
+ runMain(main, { rawArgs: normalizeHostArgs(process.argv.slice(2)) });
@@ -3,13 +3,14 @@
3
3
  * blocks, page actions, color swatches, prompts, API panels, Ask AI). One
4
4
  * implementation owns the invariants each site used to hand-roll:
5
5
  *
6
- * - the clipboard write is guarded, and nothing flashes on failure — a
7
- * confirmation must never lie;
6
+ * - the clipboard write is guarded and a confirmation must never lie: a
7
+ * site either shows nothing on failure or (the page actions) shows a
8
+ * failure label, but never a false "Copied";
8
9
  * - repeat copies restart the hold instead of stacking timers, so the copied
9
10
  * state never reverts early after a double-click;
10
- * - every successful copy is announced to a shared polite live region, so the
11
- * confirmation is audible, not just visual (previously only the code-block
12
- * button announced).
11
+ * - every flash is announced to a shared polite live region, so the
12
+ * confirmation — or the failure — is audible, not just visual (previously
13
+ * only the code-block button announced).
13
14
  */
14
15
 
15
16
  /** How long the copied confirmation holds before reverting. */
@@ -35,17 +36,100 @@ export const announceCopied = (message: string): void => {
35
36
  };
36
37
 
37
38
  /**
38
- * Copy `text` to the clipboard. Returns whether the write succeeded; failures
39
- * (insecure context, permissions) are swallowed so callers can simply skip
40
- * their confirmation.
39
+ * The legacy copy path: select `text` in an off-screen textarea and run the
40
+ * `copy` editing command. It needs no clipboard permission, only the user
41
+ * activation the click already provides, so it covers the places the async
42
+ * Clipboard API doesn't reach — in-app browsers and WebViews that ship no
43
+ * `navigator.clipboard`, insecure origins, and a denied permission prompt.
44
+ */
45
+ const copyViaCommand = (text: string): boolean => {
46
+ const previous = document.activeElement;
47
+ const textarea = document.createElement("textarea");
48
+ textarea.value = text;
49
+ textarea.setAttribute("readonly", "");
50
+ textarea.setAttribute("aria-hidden", "true");
51
+ // Off-screen rather than `display: none`: hidden controls can't be selected.
52
+ textarea.style.position = "fixed";
53
+ textarea.style.top = "0";
54
+ textarea.style.left = "-9999px";
55
+ textarea.style.opacity = "0";
56
+ // Beside the focused control, not on `<body>`: `select()` moves focus to the
57
+ // textarea, and a copy button inside a light-dismissed `<details>` menu (the
58
+ // MCP actions) would otherwise see focus leave the panel — closing the menu
59
+ // mid-click and hiding the label the outcome is about to flash on.
60
+ const host =
61
+ previous instanceof HTMLElement && previous.parentElement
62
+ ? previous.parentElement
63
+ : document.body;
64
+ host.append(textarea);
65
+ textarea.select();
66
+ // iOS WebKit has been known to ignore `select()` on a readonly textarea;
67
+ // an explicit range covers it (the same belt-and-braces clipboard.js uses).
68
+ textarea.setSelectionRange(0, text.length);
69
+ let copied = false;
70
+ try {
71
+ copied = document.execCommand("copy");
72
+ } catch {
73
+ // Some engines throw instead of returning false; either way it failed.
74
+ }
75
+ textarea.remove();
76
+ // `select()` moved focus to the textarea; put it back on the button so a
77
+ // keyboard user isn't dropped at the top of the document.
78
+ if (previous instanceof HTMLElement) {
79
+ previous.focus();
80
+ }
81
+ return copied;
82
+ };
83
+
84
+ /**
85
+ * Copy `text` to the clipboard. Returns whether the write succeeded. The async
86
+ * Clipboard API is tried first; when it is missing (in-app browsers, insecure
87
+ * contexts) or rejects (a denied permission), the legacy `copy` command is
88
+ * tried before giving up, so callers only see `false` when nothing worked and
89
+ * can show a failure instead of silently doing nothing.
41
90
  */
42
91
  export const copyText = async (text: string): Promise<boolean> => {
43
92
  try {
44
93
  await navigator.clipboard.writeText(text);
45
94
  return true;
46
95
  } catch {
47
- return false;
96
+ return copyViaCommand(text);
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Copy text that still has to be loaded — the page's Markdown mirror, fetched
102
+ * on click. Safari and Firefox only honor a clipboard write issued inside the
103
+ * click's own task: awaiting the load first lands the write outside the user
104
+ * activation, so `writeText` rejects and the legacy command returns `false`
105
+ * even though the clipboard is perfectly available. `ClipboardItem` accepts a
106
+ * promise for its payload, so the write is issued synchronously with the
107
+ * load still in flight and the activation intact. Engines without it (or a
108
+ * write that rejects for any reason, a failed load included) fall back to the
109
+ * awaited {@link copyText}, which is what Chrome's longer activation window
110
+ * already tolerated. A load failure still throws, so the caller can report
111
+ * it rather than a clipboard problem.
112
+ */
113
+ export const copyDeferredText = async (
114
+ load: () => Promise<string>
115
+ ): Promise<boolean> => {
116
+ const text = load();
117
+ if ("ClipboardItem" in globalThis) {
118
+ try {
119
+ await navigator.clipboard.write([
120
+ new ClipboardItem({
121
+ "text/plain": text.then(
122
+ (value) => new Blob([value], { type: "text/plain" })
123
+ ),
124
+ }),
125
+ ]);
126
+ return true;
127
+ } catch {
128
+ // Fall through to the awaited write; if the load itself failed, the
129
+ // `await` below rethrows that error for the caller.
130
+ }
48
131
  }
132
+ return copyText(await text);
49
133
  };
50
134
 
51
135
  /**
@@ -176,7 +176,7 @@ const AskAI = ({
176
176
  useEffect(() => {
177
177
  // The initial null→body flip is deliberate (there is no body during SSR);
178
178
  // it is the same one-time post-mount cascade the old `mounted` flag had.
179
- // oxlint-disable-next-line react/react-compiler -- deliberate post-mount portal-target initialization
179
+ // oxlint-disable-next-line react/react-compiler, react/set-state-in-effect -- deliberate post-mount portal-target initialization
180
180
  setPortalTarget(document.body);
181
181
  const onSwap = () => setPortalTarget(document.body);
182
182
  document.addEventListener("astro:after-swap", onSwap);
@@ -263,6 +263,7 @@ const AskAI = ({
263
263
  if (open && portalTarget) {
264
264
  document.body.dataset.blumeAsk = "open";
265
265
  }
266
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
266
267
  }, [open, portalTarget]);
267
268
 
268
269
  // Below the desktop dock breakpoint the open panel is a full-width overlay,
@@ -328,12 +329,14 @@ const AskAI = ({
328
329
  // portalTarget: each swap installs a new <body>, so the sweep and its
329
330
  // observer must re-run against the new children (the old ones are
330
331
  // detached).
332
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
331
333
  }, [open, portalTarget]);
332
334
 
333
335
  // Keep the newest message in view as it streams in — and after a swap, when
334
336
  // the re-portaled panel's scroll container is reborn at the top.
335
337
  useEffect(() => {
336
338
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
339
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- both deps are triggers, not values read here: re-run per streamed message and per re-portaled panel
337
340
  }, [messages, portalTarget]);
338
341
 
339
342
  const runQuestion = (raw: string) => {
@@ -57,7 +57,7 @@ const useClientData = (): BlumeClientData | null => {
57
57
  // Intentional post-mount hydration guard: `null` on the server and first
58
58
  // client render so hydration matches, then the snapshot once mounted. The
59
59
  // extra render is required; do not seed the initial value from the DOM.
60
- // oxlint-disable-next-line react/react-compiler, react-doctor/no-initialize-state -- deliberate SSR hydration guard
60
+ // oxlint-disable-next-line react/react-compiler, react/set-state-in-effect, react-doctor/no-initialize-state -- deliberate SSR hydration guard
61
61
  useEffect(() => setData(readClientData()), []);
62
62
  return data;
63
63
  };
@@ -109,6 +109,7 @@ export const useSearch = (): UseSearch => {
109
109
  // Before the lazy client import: the first search's heaviest phase is
110
110
  // creating the provider client (index download), and it must show loading.
111
111
  setLoading(true);
112
+ // oxlint-disable-next-line react/todo -- React Compiler cannot lower try/finally; the hook stays manually memoized above
112
113
  try {
113
114
  if (!searchFn.current) {
114
115
  const { createSearch } = await import("blume:search-client");
@@ -250,6 +251,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
250
251
  assistant.content = errorMessage;
251
252
  setMessages([...history, { ...assistant }]);
252
253
  }
254
+ // oxlint-disable-next-line react/todo -- React Compiler cannot lower try/finally; the hook stays manually memoized above
253
255
  } finally {
254
256
  if (live()) {
255
257
  setLoading(false);
@@ -104,6 +104,7 @@ const menuRowClass =
104
104
  data-blume-page-actions
105
105
  data-i18n-chat-prompt={a.openInChatPrompt}
106
106
  data-i18n-copied={a.copied}
107
+ data-i18n-copy-failed={a.copyFailed}
107
108
  data-i18n-generating={a.generating}
108
109
  data-mcp-name={mcpName ?? undefined}
109
110
  data-mcp-url={mcpUrl ?? undefined}
@@ -259,7 +260,7 @@ const menuRowClass =
259
260
  </div>
260
261
 
261
262
  <script>
262
- import { copyText, flashLabel } from "../copy-feedback.ts";
263
+ import { copyDeferredText, copyText, flashLabel } from "../copy-feedback.ts";
263
264
  import { installDropdownDismiss } from "../dropdown-dismiss.ts";
264
265
  import { prefixBase } from "../islands/base-path.ts";
265
266
  import { rafThrottle } from "../raf-throttle.ts";
@@ -393,6 +394,8 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
393
394
  const mdRoute = root.getAttribute("data-md") ?? "";
394
395
  const md = prefixBase(import.meta.env.BASE_URL, mdRoute);
395
396
  const copiedLabel = root.getAttribute("data-i18n-copied") || "Copied!";
397
+ const copyFailedLabel =
398
+ root.getAttribute("data-i18n-copy-failed") || "Copy failed";
396
399
  const generatingLabel =
397
400
  root.getAttribute("data-i18n-generating") || "Generating…";
398
401
  // Always stamped: the frontmatter merges `EN_UI.actions` under any
@@ -444,8 +447,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
444
447
  );
445
448
 
446
449
  const copy = async (value: string, el: Element | null) => {
447
- if ((await copyText(value)) && el instanceof HTMLElement) {
448
- flashLabel(el, copiedLabel);
450
+ const copied = await copyText(value);
451
+ if (el instanceof HTMLElement) {
452
+ flashLabel(el, copied ? copiedLabel : copyFailedLabel);
449
453
  }
450
454
  };
451
455
  root
@@ -475,21 +479,28 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
475
479
  root
476
480
  .querySelector("[data-blume-copy-page]")
477
481
  ?.addEventListener("click", async () => {
482
+ let copied = false;
478
483
  try {
479
- const response = await fetch(md);
480
- if (!response.ok) {
481
- throw new Error(`Fetching ${md} failed (${response.status})`);
482
- }
483
- if (
484
- (await copyText(await response.text())) &&
485
- label instanceof HTMLElement
486
- ) {
487
- flashLabel(label, copiedLabel);
488
- }
484
+ // The Markdown is fetched on click; the deferred write keeps the
485
+ // clipboard call inside the click's user activation (Safari and
486
+ // Firefox reject a write issued after an `await`).
487
+ copied = await copyDeferredText(async () => {
488
+ const response = await fetch(md);
489
+ if (!response.ok) {
490
+ throw new Error(`Fetching ${md} failed (${response.status})`);
491
+ }
492
+ return response.text();
493
+ });
489
494
  } catch (error) {
490
- // Clipboard or fetch unavailable; don't flash "Copied!" untruthfully.
491
495
  console.error("[blume] Copy as Markdown failed", error);
492
496
  }
497
+ // Never flash "Copied!" untruthfully — but never stay silent either:
498
+ // a reader in an in-app browser or on a denied clipboard prompt must
499
+ // see that nothing landed on the clipboard. The legacy copy command
500
+ // has already been tried by the time the helper reports `false`.
501
+ if (label instanceof HTMLElement) {
502
+ flashLabel(label, copied ? copiedLabel : copyFailedLabel);
503
+ }
493
504
  });
494
505
 
495
506
  root
@@ -788,6 +788,15 @@ export interface McpConfig {
788
788
  * hosted MCP server.
789
789
  */
790
790
  export interface AiConfig {
791
+ /**
792
+ * The JSON docs API — the REST twin of the MCP tools. Serves the page index
793
+ * (`/api/docs/pages.json`), each page as JSON (`/api/docs/pages/{route}.json`),
794
+ * and the navigation tree (`/api/docs/navigation.json`) as prerendered
795
+ * files, plus full-text search (`/api/docs/search?q=`) on server output,
796
+ * all described by an OpenAPI document at `/openapi.json`. Errors are RFC
797
+ * 9457 problem details. Defaults to `true`; set `false` to publish none of it.
798
+ */
799
+ api?: boolean;
791
800
  /** The Ask AI chat assistant. */
792
801
  ask?: AskConfig;
793
802
  /**
package/src/core/data.ts CHANGED
@@ -78,6 +78,11 @@ export interface BlumeRoute {
78
78
  alternates: RouteAlternate[];
79
79
  /** Astro collection the entry renders through (`"docs"` | `"staged"`). */
80
80
  collection: string;
81
+ /**
82
+ * Meta description as the page head renders it (`seo.description` over the
83
+ * front matter `description`), or `null` when the page declares neither.
84
+ */
85
+ description: string | null;
81
86
  draft: boolean;
82
87
  /** "Edit this page" URL, or `null` when no repo/source provides one. */
83
88
  editUrl: string | null;
@@ -133,7 +138,13 @@ export interface BlumeDataConfig {
133
138
  * `deployment.site`, the condition under which one is emitted) feeds the
134
139
  * 404 page's recovery links rather than the head.
135
140
  */
136
- discovery: { agentReadability: boolean; llmsTxt: boolean; sitemap: boolean };
141
+ discovery: {
142
+ agentReadability: boolean;
143
+ /** Whether the JSON docs API and its `/openapi.json` are published. */
144
+ api: boolean;
145
+ llmsTxt: boolean;
146
+ sitemap: boolean;
147
+ };
137
148
  favicon: BlumeFavicon;
138
149
  feedback: boolean;
139
150
  /**
@@ -161,7 +172,11 @@ export interface BlumeDataConfig {
161
172
  * kept out of this snapshot, which pages serialize into HTML.
162
173
  */
163
174
  og: {
164
- /** Card subtitle: `seo.og.description` (`false` omits it) over the site description. */
175
+ /**
176
+ * Site-wide card subtitle: `seo.og.description` (`false` omits it) over
177
+ * the site description. A page with its own description shows that
178
+ * instead; this is the fallback for pages without one.
179
+ */
165
180
  description?: string;
166
181
  enabled: boolean;
167
182
  /** Inlined SVG brand mark; `false` renders the card without any mark. */
@@ -4,6 +4,7 @@ import type { HydrationMode } from "./schema.ts";
4
4
  * A reference to a component. Either an imported component (preferred, for type
5
5
  * safety) or a string path resolved relative to the project root.
6
6
  */
7
+ // oxlint-disable-next-line anti-slop/no-unknown-type-aliases -- deliberately untyped: user configs pass imported components from any framework (React functions, Svelte classes, Vue SFC objects), which share no structural type
7
8
  export type ComponentReference = unknown | string;
8
9
 
9
10
  /** An interactive component plus its hydration strategy. */
@@ -15,6 +16,7 @@ export interface IslandDescriptor {
15
16
  }
16
17
 
17
18
  /** A component override: a static component or a hydrated island. */
19
+ // oxlint-disable-next-line anti-slop/no-unknown-type-aliases -- inherits the untyped `ComponentReference` above
18
20
  export type ComponentOverride = ComponentReference | IslandDescriptor;
19
21
 
20
22
  /** User-authored component overrides, grouped by surface. */
@@ -21,6 +21,7 @@ const uiStringsObject = z.object({
21
21
  copyClaudeCode: z.string().default("Copy Claude Code command"),
22
22
  copyCode: z.string().default("Copy code"),
23
23
  copyCodex: z.string().default("Copy Codex command"),
24
+ copyFailed: z.string().default("Copy failed"),
24
25
  copyMarkdown: z.string().default("Copy as Markdown"),
25
26
  copyServerUrl: z.string().default("Copy server URL"),
26
27
  edit: z.string().default("Edit on GitHub"),
@@ -108,6 +109,8 @@ const uiStringsObject = z.object({
108
109
  .prefault({}),
109
110
  notFound: z
110
111
  .object({
112
+ /** Label of the OpenAPI description link on the Markdown/JSON 404. */
113
+ api: z.string().default("JSON API description (openapi.json)"),
111
114
  description: z
112
115
  .string()
113
116
  .default("We couldn't find the page you're looking for."),
@@ -622,7 +622,8 @@ export const buildIncludeGraph = (
622
622
  continue;
623
623
  }
624
624
  for (const partial of page.includes ?? []) {
625
- const includers = (graph[partial] ??= []);
625
+ const includers = graph[partial] ?? [];
626
+ graph[partial] = includers;
626
627
  if (!includers.includes(sourcePath)) {
627
628
  includers.push(sourcePath);
628
629
  }
@@ -63,6 +63,14 @@ export const contentIndexable = (
63
63
  * output is fully prerendered (render-fallback, no client redirect). Fallback
64
64
  * routes are not indexed and carry no `hreflang` of their own.
65
65
  */
66
+ /**
67
+ * The description a page's `<head>` emits — `seo.description` over the front
68
+ * matter `description` — mirrored onto its route so the OG card's subtitle
69
+ * matches the page's `og:description`.
70
+ */
71
+ const routeDescription = (page: PageRecord): string | undefined =>
72
+ page.meta.seo.description ?? page.description;
73
+
66
74
  const buildFallbackRoutes = (
67
75
  graph: ContentGraph,
68
76
  i18n: NonNullable<ResolvedConfig["i18n"]>,
@@ -108,6 +116,7 @@ const buildFallbackRoutes = (
108
116
  alternates: alternatesByKey.get(key) ?? [],
109
117
  collection: source.collection ?? "docs",
110
118
  contentType: source.contentType,
119
+ description: routeDescription(source),
111
120
  draft: source.meta.draft,
112
121
  editUrl: source.editUrl,
113
122
  entryId: source.entryId ?? source.source.ref,
@@ -171,6 +180,7 @@ export const buildManifest = (options: {
171
180
  alternates: alternatesByKey.get(page.translationKey) ?? [],
172
181
  collection: page.collection ?? "docs",
173
182
  contentType: page.contentType,
183
+ description: routeDescription(page),
174
184
  draft: page.meta.draft,
175
185
  editUrl: page.editUrl,
176
186
  entryId: page.entryId ?? page.source.ref,
@@ -838,6 +838,13 @@ const llmsTxtObjectSchema = z.strictObject({
838
838
  type LlmsTxtResolved = z.output<typeof llmsTxtObjectSchema>;
839
839
 
840
840
  const aiConfigSchema = z.strictObject({
841
+ /**
842
+ * The JSON docs API: the page index, per-page JSON, and navigation under
843
+ * `/api/docs/` (prerendered, so a static site serves them from files), the
844
+ * live search endpoint on server output, and the OpenAPI description of
845
+ * the whole machine-readable surface at `/openapi.json`. On by default.
846
+ */
847
+ api: z.boolean().default(true),
841
848
  ask: z
842
849
  .strictObject({
843
850
  // Name of the env var holding the provider's API key; each provider has
@@ -907,9 +914,8 @@ const aiConfigSchema = z.strictObject({
907
914
  llmsTxt: z
908
915
  .union([z.boolean(), llmsTxtObjectSchema])
909
916
  .default(true)
910
- .transform(
911
- (value): LlmsTxtResolved =>
912
- isBoolean(value) ? { enabled: value, openapi: true } : value
917
+ .transform((value): LlmsTxtResolved =>
918
+ isBoolean(value) ? { enabled: value, openapi: true } : value
913
919
  ),
914
920
  // Serializers for the agent-facing Markdown downlevel (the `.md` mirror,
915
921
  // llms-full.txt, MCP get_page), keyed by JSX name. Functions live here —
@@ -1332,8 +1338,9 @@ const ogFontSchema = z.union([
1332
1338
 
1333
1339
  const ogConfigSchema = z.strictObject({
1334
1340
  /**
1335
- * Card subtitle. Defaults to the site description; a string overrides it,
1336
- * `false` renders the card without one.
1341
+ * Site-wide card subtitle, shown on pages without their own description.
1342
+ * Defaults to the site description; a string overrides it, `false` renders
1343
+ * every card without a subtitle (page descriptions included).
1337
1344
  */
1338
1345
  description: z.union([z.string(), z.literal(false)]).optional(),
1339
1346
  /**
@@ -1708,6 +1715,14 @@ const openapiSourceSchema = z.strictObject({
1708
1715
  noindex: z.boolean().default(false),
1709
1716
  /** Per-source route; defaults to the block's `route` (or a derived path). */
1710
1717
  route: z.string().optional(),
1718
+ /**
1719
+ * Append the English "Reference for the … endpoint in the … API." sentence
1720
+ * to every generated operation page's meta description. On by default, so
1721
+ * terse specs still ship distinct, snippet-length descriptions; set to
1722
+ * `false` on a non-English site to describe pages with the spec's own prose
1723
+ * alone (falling back to the page title when an operation has none).
1724
+ */
1725
+ seoDescriptionSuffix: z.boolean().default(true),
1711
1726
  /** Local path or `http(s)` URL to the spec. */
1712
1727
  spec: z.string(),
1713
1728
  });
package/src/core/types.ts CHANGED
@@ -368,6 +368,12 @@ export interface RouteManifestEntry {
368
368
  /** Adapter-supplied "edit this page" URL (non-filesystem sources). */
369
369
  editUrl?: string;
370
370
  title: string;
371
+ /**
372
+ * The page's meta description as the head renders it: `seo.description`
373
+ * over the front matter `description`. Feeds the generated OG card's
374
+ * subtitle, so a shared link's image and its `og:description` agree.
375
+ */
376
+ description?: string;
371
377
  contentType: string;
372
378
  hidden: boolean;
373
379
  draft: boolean;
@@ -11,6 +11,7 @@ export const ar: UIStringsOverride = {
11
11
  copyClaudeCode: "نسخ أمر Claude Code",
12
12
  copyCode: "نسخ الكود",
13
13
  copyCodex: "نسخ أمر Codex",
14
+ copyFailed: "فشل النسخ",
14
15
  copyMarkdown: "نسخ بصيغة Markdown",
15
16
  copyServerUrl: "نسخ عنوان URL للخادم",
16
17
  edit: "التعديل على GitHub",
@@ -11,6 +11,7 @@ export const bg: UIStringsOverride = {
11
11
  copyClaudeCode: "Копирай командата на Claude Code",
12
12
  copyCode: "Копирай кода",
13
13
  copyCodex: "Копирай командата на Codex",
14
+ copyFailed: "Копирането не бе успешно",
14
15
  copyMarkdown: "Копирай като Markdown",
15
16
  copyServerUrl: "Копирай URL на сървъра",
16
17
  edit: "Редактирай в GitHub",
@@ -11,6 +11,7 @@ export const bn: UIStringsOverride = {
11
11
  copyClaudeCode: "Claude Code কমান্ড অনুলিপি করুন",
12
12
  copyCode: "কোড অনুলিপি করুন",
13
13
  copyCodex: "Codex কমান্ড অনুলিপি করুন",
14
+ copyFailed: "অনুলিপি ব্যর্থ হয়েছে",
14
15
  copyMarkdown: "Markdown হিসেবে অনুলিপি করুন",
15
16
  copyServerUrl: "সার্ভার URL অনুলিপি করুন",
16
17
  edit: "GitHub-এ সম্পাদনা করুন",
@@ -11,6 +11,7 @@ export const ca: UIStringsOverride = {
11
11
  copyClaudeCode: "Copia l'ordre de Claude Code",
12
12
  copyCode: "Copia el codi",
13
13
  copyCodex: "Copia l'ordre de Codex",
14
+ copyFailed: "No s'ha pogut copiar",
14
15
  copyMarkdown: "Copia com a Markdown",
15
16
  copyServerUrl: "Copia l'URL del servidor",
16
17
  edit: "Edita a GitHub",
@@ -11,6 +11,7 @@ export const cs: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopírovat příkaz Claude Code",
12
12
  copyCode: "Kopírovat kód",
13
13
  copyCodex: "Kopírovat příkaz Codex",
14
+ copyFailed: "Kopírování se nezdařilo",
14
15
  copyMarkdown: "Kopírovat jako Markdown",
15
16
  copyServerUrl: "Kopírovat URL serveru",
16
17
  edit: "Upravit na GitHubu",
@@ -11,6 +11,7 @@ export const da: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopiér Claude Code-kommando",
12
12
  copyCode: "Kopiér kode",
13
13
  copyCodex: "Kopiér Codex-kommando",
14
+ copyFailed: "Kopiering mislykkedes",
14
15
  copyMarkdown: "Kopiér som Markdown",
15
16
  copyServerUrl: "Kopiér server-URL",
16
17
  edit: "Rediger på GitHub",
@@ -11,6 +11,7 @@ export const de: UIStringsOverride = {
11
11
  copyClaudeCode: "Claude-Code-Befehl kopieren",
12
12
  copyCode: "Code kopieren",
13
13
  copyCodex: "Codex-Befehl kopieren",
14
+ copyFailed: "Kopieren fehlgeschlagen",
14
15
  copyMarkdown: "Als Markdown kopieren",
15
16
  copyServerUrl: "Server-URL kopieren",
16
17
  edit: "Auf GitHub bearbeiten",
@@ -11,6 +11,7 @@ export const el: UIStringsOverride = {
11
11
  copyClaudeCode: "Αντιγραφή εντολής Claude Code",
12
12
  copyCode: "Αντιγραφή κώδικα",
13
13
  copyCodex: "Αντιγραφή εντολής Codex",
14
+ copyFailed: "Η αντιγραφή απέτυχε",
14
15
  copyMarkdown: "Αντιγραφή ως Markdown",
15
16
  copyServerUrl: "Αντιγραφή URL διακομιστή",
16
17
  edit: "Επεξεργασία στο GitHub",
@@ -11,6 +11,7 @@ export const es: UIStringsOverride = {
11
11
  copyClaudeCode: "Copiar comando de Claude Code",
12
12
  copyCode: "Copiar código",
13
13
  copyCodex: "Copiar comando de Codex",
14
+ copyFailed: "Error al copiar",
14
15
  copyMarkdown: "Copiar como Markdown",
15
16
  copyServerUrl: "Copiar URL del servidor",
16
17
  edit: "Editar en GitHub",
@@ -11,6 +11,7 @@ export const fa: UIStringsOverride = {
11
11
  copyClaudeCode: "کپی دستور Claude Code",
12
12
  copyCode: "کپی کد",
13
13
  copyCodex: "کپی دستور Codex",
14
+ copyFailed: "کپی ناموفق بود",
14
15
  copyMarkdown: "کپی به‌صورت Markdown",
15
16
  copyServerUrl: "کپی نشانی سرور",
16
17
  edit: "ویرایش در GitHub",
@@ -11,6 +11,7 @@ export const fi: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopioi Claude Code -komento",
12
12
  copyCode: "Kopioi koodi",
13
13
  copyCodex: "Kopioi Codex-komento",
14
+ copyFailed: "Kopiointi epäonnistui",
14
15
  copyMarkdown: "Kopioi Markdownina",
15
16
  copyServerUrl: "Kopioi palvelimen URL",
16
17
  edit: "Muokkaa GitHubissa",
@@ -11,6 +11,7 @@ export const fr: UIStringsOverride = {
11
11
  copyClaudeCode: "Copier la commande Claude Code",
12
12
  copyCode: "Copier le code",
13
13
  copyCodex: "Copier la commande Codex",
14
+ copyFailed: "Échec de la copie",
14
15
  copyMarkdown: "Copier en Markdown",
15
16
  copyServerUrl: "Copier l'URL du serveur",
16
17
  edit: "Modifier sur GitHub",
@@ -11,6 +11,7 @@ export const he: UIStringsOverride = {
11
11
  copyClaudeCode: "העתק פקודת Claude Code",
12
12
  copyCode: "העתק קוד",
13
13
  copyCodex: "העתק פקודת Codex",
14
+ copyFailed: "ההעתקה נכשלה",
14
15
  copyMarkdown: "העתק כ-Markdown",
15
16
  copyServerUrl: "העתק כתובת URL של השרת",
16
17
  edit: "ערוך ב-GitHub",
@@ -11,6 +11,7 @@ export const hi: UIStringsOverride = {
11
11
  copyClaudeCode: "Claude Code कमांड कॉपी करें",
12
12
  copyCode: "कोड कॉपी करें",
13
13
  copyCodex: "Codex कमांड कॉपी करें",
14
+ copyFailed: "कॉपी विफल रहा",
14
15
  copyMarkdown: "Markdown के रूप में कॉपी करें",
15
16
  copyServerUrl: "सर्वर URL कॉपी करें",
16
17
  edit: "GitHub पर संपादित करें",
@@ -11,6 +11,7 @@ export const hr: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopiraj naredbu Claude Code",
12
12
  copyCode: "Kopiraj kôd",
13
13
  copyCodex: "Kopiraj naredbu Codex",
14
+ copyFailed: "Kopiranje nije uspjelo",
14
15
  copyMarkdown: "Kopiraj kao Markdown",
15
16
  copyServerUrl: "Kopiraj URL poslužitelja",
16
17
  edit: "Uredi na GitHubu",
@@ -11,6 +11,7 @@ export const hu: UIStringsOverride = {
11
11
  copyClaudeCode: "Claude Code parancs másolása",
12
12
  copyCode: "Kód másolása",
13
13
  copyCodex: "Codex parancs másolása",
14
+ copyFailed: "A másolás nem sikerült",
14
15
  copyMarkdown: "Másolás Markdownként",
15
16
  copyServerUrl: "Szerver URL másolása",
16
17
  edit: "Szerkesztés a GitHubon",