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
@@ -11,6 +11,7 @@ export const id: UIStringsOverride = {
11
11
  copyClaudeCode: "Salin perintah Claude Code",
12
12
  copyCode: "Salin kode",
13
13
  copyCodex: "Salin perintah Codex",
14
+ copyFailed: "Gagal menyalin",
14
15
  copyMarkdown: "Salin sebagai Markdown",
15
16
  copyServerUrl: "Salin URL server",
16
17
  edit: "Edit di GitHub",
@@ -11,6 +11,7 @@ export const it: UIStringsOverride = {
11
11
  copyClaudeCode: "Copia comando di Claude Code",
12
12
  copyCode: "Copia codice",
13
13
  copyCodex: "Copia comando di Codex",
14
+ copyFailed: "Copia non riuscita",
14
15
  copyMarkdown: "Copia come Markdown",
15
16
  copyServerUrl: "Copia URL del server",
16
17
  edit: "Modifica su GitHub",
@@ -11,6 +11,7 @@ export const ja: 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 ko: 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 nl: UIStringsOverride = {
11
11
  copyClaudeCode: "Claude Code-opdracht kopiëren",
12
12
  copyCode: "Code kopiëren",
13
13
  copyCodex: "Codex-opdracht kopiëren",
14
+ copyFailed: "Kopiëren mislukt",
14
15
  copyMarkdown: "Kopiëren als Markdown",
15
16
  copyServerUrl: "Server-URL kopiëren",
16
17
  edit: "Bewerken op GitHub",
@@ -11,6 +11,7 @@ export const no: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopier Claude Code-kommando",
12
12
  copyCode: "Kopier kode",
13
13
  copyCodex: "Kopier Codex-kommando",
14
+ copyFailed: "Kopiering mislyktes",
14
15
  copyMarkdown: "Kopier som Markdown",
15
16
  copyServerUrl: "Kopier server-URL",
16
17
  edit: "Rediger på GitHub",
@@ -11,6 +11,7 @@ export const pl: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopiuj polecenie Claude Code",
12
12
  copyCode: "Kopiuj kod",
13
13
  copyCodex: "Kopiuj polecenie Codex",
14
+ copyFailed: "Kopiowanie nie powiodło się",
14
15
  copyMarkdown: "Kopiuj jako Markdown",
15
16
  copyServerUrl: "Kopiuj adres URL serwera",
16
17
  edit: "Edytuj na GitHubie",
@@ -11,6 +11,7 @@ export const ptBR: UIStringsOverride = {
11
11
  copyClaudeCode: "Copiar comando do Claude Code",
12
12
  copyCode: "Copiar código",
13
13
  copyCodex: "Copiar comando do Codex",
14
+ copyFailed: "Falha ao copiar",
14
15
  copyMarkdown: "Copiar como Markdown",
15
16
  copyServerUrl: "Copiar URL do servidor",
16
17
  edit: "Editar no GitHub",
@@ -11,6 +11,7 @@ export const pt: UIStringsOverride = {
11
11
  copyClaudeCode: "Copiar comando do Claude Code",
12
12
  copyCode: "Copiar código",
13
13
  copyCodex: "Copiar comando do Codex",
14
+ copyFailed: "Falha ao copiar",
14
15
  copyMarkdown: "Copiar como Markdown",
15
16
  copyServerUrl: "Copiar URL do servidor",
16
17
  edit: "Editar no GitHub",
@@ -11,6 +11,7 @@ export const ro: UIStringsOverride = {
11
11
  copyClaudeCode: "Copiază comanda Claude Code",
12
12
  copyCode: "Copiază codul",
13
13
  copyCodex: "Copiază comanda Codex",
14
+ copyFailed: "Copierea a eșuat",
14
15
  copyMarkdown: "Copiază ca Markdown",
15
16
  copyServerUrl: "Copiază URL-ul serverului",
16
17
  edit: "Editează pe GitHub",
@@ -11,6 +11,7 @@ export const ru: 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 sk: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopírovať príkaz Claude Code",
12
12
  copyCode: "Kopírovať kód",
13
13
  copyCodex: "Kopírovať príkaz Codex",
14
+ copyFailed: "Kopírovanie zlyhalo",
14
15
  copyMarkdown: "Kopírovať ako Markdown",
15
16
  copyServerUrl: "Kopírovať URL servera",
16
17
  edit: "Upraviť na GitHube",
@@ -11,6 +11,7 @@ export const sr: 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 sv: UIStringsOverride = {
11
11
  copyClaudeCode: "Kopiera Claude Code-kommando",
12
12
  copyCode: "Kopiera kod",
13
13
  copyCodex: "Kopiera Codex-kommando",
14
+ copyFailed: "Kopiering misslyckades",
14
15
  copyMarkdown: "Kopiera som Markdown",
15
16
  copyServerUrl: "Kopiera server-URL",
16
17
  edit: "Redigera på GitHub",
@@ -11,6 +11,7 @@ export const th: 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 tr: UIStringsOverride = {
11
11
  copyClaudeCode: "Claude Code komutunu kopyala",
12
12
  copyCode: "Kodu kopyala",
13
13
  copyCodex: "Codex komutunu kopyala",
14
+ copyFailed: "Kopyalama başarısız oldu",
14
15
  copyMarkdown: "Markdown olarak kopyala",
15
16
  copyServerUrl: "Sunucu URL'sini kopyala",
16
17
  edit: "GitHub'da düzenle",
@@ -11,6 +11,7 @@ export const uk: 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 vi: UIStringsOverride = {
11
11
  copyClaudeCode: "Sao chép lệnh Claude Code",
12
12
  copyCode: "Sao chép mã",
13
13
  copyCodex: "Sao chép lệnh Codex",
14
+ copyFailed: "Sao chép thất bại",
14
15
  copyMarkdown: "Sao chép dưới dạng Markdown",
15
16
  copyServerUrl: "Sao chép URL máy chủ",
16
17
  edit: "Chỉnh sửa trên GitHub",
@@ -11,6 +11,7 @@ export const zhTW: 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 zh: 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 上编辑",
@@ -177,12 +177,14 @@ export const insertArchivedVersion = async (
177
177
  const indent = text.slice(lineStart).match(/^\s*/u)?.[0] ?? "";
178
178
  const rest = text.slice(insertAt);
179
179
  // Match the array's authored shape: empty stays bare, an inline array gets
180
- // an inline entry, a multiline array gets its own indented line.
180
+ // an inline entry, a multiline array gets its own indented line — on the
181
+ // file's own line ending, so a CRLF config doesn't gain a lone LF.
182
+ const eol = /^\r?\n/u.exec(rest)?.[0];
181
183
  let entry: string;
182
184
  if (rest.trimStart().startsWith("]")) {
183
185
  entry = `{ id: "${id}" }`;
184
- } else if (rest.startsWith("\n")) {
185
- entry = `\n${indent} { id: "${id}" },`;
186
+ } else if (eol) {
187
+ entry = `${eol}${indent} { id: "${id}" },`;
186
188
  } else {
187
189
  entry = `{ id: "${id}" }, `;
188
190
  }
@@ -9,6 +9,11 @@
9
9
  * into it so a content-page request that prefers `text/markdown` is rewritten
10
10
  * (not redirected) to the page's prerendered `.md` mirror — the deployed
11
11
  * counterpart of the dev-server rewrite in `astro/markdown-negotiation.ts`.
12
+ * The same routing config also answers a *missing* page: a request that
13
+ * prefers Markdown (or asks for a `.md` URL no page backs) gets the
14
+ * prerendered Markdown 404 body with the 404 status, instead of the HTML
15
+ * shell, and one that prefers JSON (or asks for a `.json` URL) gets the
16
+ * prerendered problem-details 404.
12
17
  */
13
18
 
14
19
  /**
@@ -24,6 +29,15 @@
24
29
  export const ACCEPT_MARKDOWN_HEADER_VALUE =
25
30
  "(.*,)?\\s*text/(x-)?markdown(\\s*[;,].*)?$";
26
31
 
32
+ /**
33
+ * The JSON counterpart, for the problem-details 404: `application/json` or
34
+ * `application/problem+json`. Browsers never send either on a navigation
35
+ * (the catch-all wildcard does not match), so ordinary page requests are
36
+ * unaffected.
37
+ */
38
+ export const ACCEPT_JSON_HEADER_VALUE =
39
+ "(.*,)?\\s*application/(problem\\+)?json(\\s*[;,].*)?$";
40
+
27
41
  /**
28
42
  * A Build Output API route — the subset these helpers read and write. Parsed
29
43
  * routes keep whatever other fields they carry at runtime; only these are
@@ -47,8 +61,65 @@ const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
47
61
  { key: "accept", type: "header", value: ACCEPT_MARKDOWN_HEADER_VALUE },
48
62
  ];
49
63
 
64
+ const ACCEPT_JSON_CONDITION: VercelRoute["has"] = [
65
+ { key: "accept", type: "header", value: ACCEPT_JSON_HEADER_VALUE },
66
+ ];
67
+
50
68
  const VARY_ACCEPT = { vary: "Accept" };
51
69
 
70
+ /** Where the prerendered Markdown 404 (`pages/404.md.ts`) lands. */
71
+ const NOT_FOUND_MARKDOWN_DEST = "/404.md";
72
+
73
+ /** Where the prerendered JSON 404 (`pages/404.json.ts`) lands. */
74
+ const NOT_FOUND_JSON_DEST = "/404.json";
75
+
76
+ /** The adapter's own not-found fallback — the anchor the Markdown 404 precedes. */
77
+ const NOT_FOUND_HTML_DEST = "/404.html";
78
+
79
+ /**
80
+ * Miss-phase routes that answer a missing page with the Markdown 404 body: any
81
+ * path when the client prefers Markdown, and any `.md`/`.mdx` URL (a request
82
+ * for a raw-Markdown mirror that has no page wants Markdown back, not the HTML
83
+ * shell). Both keep the 404 status. Spliced immediately before the adapter's
84
+ * `/404.html` fallback, so they run after every server route (the MCP
85
+ * endpoint, server islands, images) has had its turn and never hijack a
86
+ * request one of those would have answered.
87
+ */
88
+ const NOT_FOUND_MARKDOWN_ROUTES: readonly VercelRoute[] = [
89
+ {
90
+ dest: NOT_FOUND_MARKDOWN_DEST,
91
+ has: ACCEPT_MARKDOWN_CONDITION,
92
+ headers: VARY_ACCEPT,
93
+ src: "^/.*$",
94
+ status: 404,
95
+ },
96
+ { dest: NOT_FOUND_MARKDOWN_DEST, src: "^/.*\\.mdx?$", status: 404 },
97
+ ];
98
+
99
+ /**
100
+ * The JSON 404's miss-phase routes, the problem-details twin of the Markdown
101
+ * ones: any path when the client prefers JSON, and any `.json` URL no file
102
+ * backs. Spliced at the same anchor, after every server route — so the
103
+ * `/api/` catch-all (which answers its own namespace with a problem document)
104
+ * has already had its turn.
105
+ */
106
+ const NOT_FOUND_JSON_ROUTES: readonly VercelRoute[] = [
107
+ {
108
+ dest: NOT_FOUND_JSON_DEST,
109
+ has: ACCEPT_JSON_CONDITION,
110
+ headers: VARY_ACCEPT,
111
+ src: "^/.*$",
112
+ status: 404,
113
+ },
114
+ { dest: NOT_FOUND_JSON_DEST, src: "^/.*\\.json$", status: 404 },
115
+ ];
116
+
117
+ /** Which prerendered 404 twins the build emitted, so their routes get wired. */
118
+ export interface NotFoundVariants {
119
+ json?: boolean;
120
+ markdown?: boolean;
121
+ }
122
+
52
123
  /**
53
124
  * Vercel rejects route `src` patterns longer than 4096 characters, so route
54
125
  * alternations are split across as many route entries as needed. The budget
@@ -186,12 +257,15 @@ export const TRAILING_SLASH_REDIRECT: VercelRoute = {
186
257
  * user-authored route of that identical shape would be semantically equal to
187
258
  * the one re-added); the homepage `Link` route by its three-field
188
259
  * continue-with-link shape (the Build Output config is adapter-generated, so
189
- * no user-authored route competes in this file).
260
+ * no user-authored route competes in this file); the Markdown 404 routes by
261
+ * their `/404.md` destination.
190
262
  */
191
263
  const isNegotiationRoute = (route: VercelRoute): boolean =>
192
264
  route.has?.some(
193
265
  (condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE
194
266
  ) === true ||
267
+ (route.dest === NOT_FOUND_MARKDOWN_DEST && route.status === 404) ||
268
+ (route.dest === NOT_FOUND_JSON_DEST && route.status === 404) ||
195
269
  (route.continue === true &&
196
270
  route.headers?.vary === "Accept" &&
197
271
  isString(route.src) &&
@@ -213,17 +287,22 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
213
287
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
214
288
  * signature directory). The trailing-slash 308 redirect is always spliced in
215
289
  * alongside, so slashed duplicates of every page collapse onto the canonical
216
- * slashless URL. Returns the updated JSON text (tab-indented, like the
217
- * adapter's own output), or `null` when there is nowhere safe to splice: an
218
- * unparsable config, no `routes` array, or no `handle: "filesystem"` marker
219
- * to anchor the splice.
290
+ * slashless URL. For each 404 twin the build emitted (`notFound.markdown` for
291
+ * `404.md`, `notFound.json` for `404.json`), its routes go into the miss
292
+ * phase right before the adapter's `/404.html` fallback and nowhere when
293
+ * that fallback is absent, since a `dest` with no file behind it would serve
294
+ * nothing. Returns the updated JSON
295
+ * text (tab-indented, like the adapter's own output), or `null` when there is
296
+ * nowhere safe to splice: an unparsable config, no `routes` array, or no
297
+ * `handle: "filesystem"` marker to anchor the splice.
220
298
  */
221
299
  export const injectNegotiationRoutes = (
222
300
  configText: string,
223
301
  routePaths: readonly string[],
224
302
  homeLinkHeader?: string | null,
225
303
  contentTypeOverrides?: Record<string, string>,
226
- homeTokens?: number
304
+ homeTokens?: number,
305
+ notFound: NotFoundVariants = {}
227
306
  ): string | null => {
228
307
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
229
308
  let config: {
@@ -272,6 +351,18 @@ export const injectNegotiationRoutes = (
272
351
  ...rewriteRoutes,
273
352
  TRAILING_SLASH_REDIRECT
274
353
  );
354
+ const notFoundRoutes = [
355
+ ...(notFound.markdown ? NOT_FOUND_MARKDOWN_ROUTES : []),
356
+ ...(notFound.json ? NOT_FOUND_JSON_ROUTES : []),
357
+ ];
358
+ if (notFoundRoutes.length > 0) {
359
+ const fallbackIndex = routes.findIndex(
360
+ (route) => route.status === 404 && route.dest === NOT_FOUND_HTML_DEST
361
+ );
362
+ if (fallbackIndex !== -1) {
363
+ routes.splice(fallbackIndex, 0, ...notFoundRoutes);
364
+ }
365
+ }
275
366
  config.routes = routes;
276
367
  return `${JSON.stringify(config, null, "\t")}\n`;
277
368
  };
package/src/og/card.ts CHANGED
@@ -81,7 +81,7 @@ export interface OgCardOptions {
81
81
  accent?: string;
82
82
  /** Brand/site name shown in the top-left lockup. */
83
83
  brand?: string;
84
- /** Muted subtitle under the headline (usually the site description). */
84
+ /** Muted subtitle under the headline (the page description, else the site's). */
85
85
  description?: string;
86
86
  /**
87
87
  * Inlined SVG markup of the configured logo, painted into the brand
@@ -54,6 +54,11 @@ export interface ReferenceSource {
54
54
  includeInSearch: boolean;
55
55
  /** Whether generated pages emit noindex metadata and stay out of the sitemap. */
56
56
  noindex: boolean;
57
+ /**
58
+ * Whether operation meta descriptions end with the generated English
59
+ * "Reference for …" sentence, or carry the spec's own prose alone.
60
+ */
61
+ seoDescriptionSuffix: boolean;
57
62
  /** Local path or `http(s)` URL, verbatim from config. */
58
63
  spec: string;
59
64
  /**
@@ -132,6 +137,7 @@ interface Block {
132
137
  label?: string;
133
138
  noindex: boolean;
134
139
  route?: string;
140
+ seoDescriptionSuffix: boolean;
135
141
  spec: string;
136
142
  }[];
137
143
  spec?: string;
@@ -146,6 +152,7 @@ const sourcesOf = (block: Block): Block["sources"] => {
146
152
  includeInLlms: true,
147
153
  includeInSearch: true,
148
154
  noindex: false,
155
+ seoDescriptionSuffix: true,
149
156
  spec: block.spec,
150
157
  });
151
158
  }
@@ -192,6 +199,7 @@ const referencesFor = (
192
199
  renderer,
193
200
  route,
194
201
  scalar: block.scalar,
202
+ seoDescriptionSuffix: source.seoDescriptionSuffix,
195
203
  slug: routeSlug(route),
196
204
  spec: source.spec,
197
205
  theme: block.theme,
@@ -174,12 +174,23 @@ const GRAPHQL_MEMBER_PHRASES = {
174
174
  /**
175
175
  * The spec's own prose for the operation, followed by the endpoint it documents
176
176
  * — so every operation page carries a distinct, self-describing meta
177
- * description even when the spec's summaries are terse.
177
+ * description even when the spec's summaries are terse. With the suffix
178
+ * switched off (`seoDescriptionSuffix: false`, for sites whose prose isn't
179
+ * English) the description is the prose alone, or the page `title` — a
180
+ * language-neutral `GET /pets`, channel, or field name — when the operation
181
+ * has no prose at all, so no page ships an empty description.
178
182
  */
179
183
  const operationDescription = (
180
184
  spec: ApiSpecData,
181
- operation: ApiOperationRef
185
+ operation: ApiOperationRef,
186
+ options: { suffix: boolean; title: string }
182
187
  ): string => {
188
+ if (!options.suffix) {
189
+ return clip(
190
+ plainProse(operation.description || operation.summary) || options.title,
191
+ META_DESCRIPTION_MAX
192
+ );
193
+ }
183
194
  // AsyncAPI operations act on a channel, not an HTTP endpoint; GraphQL pages
184
195
  // document a root field or a named type.
185
196
  let suffix: string;
@@ -210,7 +221,7 @@ export const operationMdx = (
210
221
  operation: ApiOperationRef,
211
222
  reference?: Pick<
212
223
  ReferenceSource,
213
- "includeInLlms" | "includeInSearch" | "noindex"
224
+ "includeInLlms" | "includeInSearch" | "noindex" | "seoDescriptionSuffix"
214
225
  >
215
226
  ): RenderedPage => {
216
227
  const method = operation.method.toUpperCase();
@@ -242,7 +253,10 @@ export const operationMdx = (
242
253
  searchFlags.exclude = true;
243
254
  }
244
255
  const seo: RenderedPageData["seo"] = {
245
- description: operationDescription(spec, operation),
256
+ description: operationDescription(spec, operation, {
257
+ suffix: reference?.seoDescriptionSuffix !== false,
258
+ title,
259
+ }),
246
260
  };
247
261
  if (reference?.noindex) {
248
262
  seo.noindex = true;
@@ -144,9 +144,6 @@ export const buildReferenceFiles = async (options: {
144
144
  warnings.push(spec.warning);
145
145
  }
146
146
  const pagePath = referencePagePath(ref.route);
147
- // Relative path from the page back to src/generated/data.json: a page one
148
- // directory deep (api/events.astro) needs an extra "../".
149
- const depth = pagePath.split("/").length - 1;
150
147
  files.push({
151
148
  content: scalarReferenceTemplate({
152
149
  configuration: {
@@ -157,7 +154,6 @@ export const buildReferenceFiles = async (options: {
157
154
  // hideTestRequestButton, orderSchemaPropertiesBy, and the rest).
158
155
  ...ref.scalar,
159
156
  },
160
- dataImport: `${"../".repeat(depth + 1)}generated/data.json`,
161
157
  noindex: ref.noindex,
162
158
  route: ref.route,
163
159
  title: ref.label,
@@ -9,7 +9,12 @@ import { buildRawMarkdown } from "../ai/markdown.ts";
9
9
  import { buildMcpData } from "../ai/mcp/data.ts";
10
10
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
11
11
  import { planComponentSlots } from "../astro/component-slots.ts";
12
- import { discoverExamples, exampleMarkdownLookup } from "../astro/examples.ts";
12
+ import {
13
+ EXAMPLE_SCAN_GLOB,
14
+ discoverExamples,
15
+ exampleMarkdownLookup,
16
+ exampleScanRoots,
17
+ } from "../astro/examples.ts";
13
18
  import {
14
19
  buildRuntimeData,
15
20
  collectStaged,
@@ -61,6 +66,7 @@ import {
61
66
  tailwindEntryTemplate,
62
67
  } from "../theme/entry.ts";
63
68
  import { buildThemeCss } from "../theme/palette.ts";
69
+ import { rebaseSourceDirectives } from "../theme/sources.ts";
64
70
  import { twoslashCss } from "../theme/twoslash.ts";
65
71
 
66
72
  const toPosix = (path: string): string => path.split("\\").join("/");
@@ -203,7 +209,7 @@ const mcpFiles = async (
203
209
  path: join(genDir, "mcp-data.json"),
204
210
  },
205
211
  {
206
- content: mcpEndpointTemplate(route),
212
+ content: mcpEndpointTemplate(),
207
213
  path: join(srcDir, "pages", mcpPageFile(route)),
208
214
  },
209
215
  {
@@ -251,13 +257,21 @@ const changelogFiles = (
251
257
  };
252
258
 
253
259
  /** Contents of the configured `examples.css`, or `""` when unset/absent. */
254
- const readExamplesCss = (
255
- root: string,
256
- css: string | undefined
257
- ): Promise<string> =>
258
- css && existsSync(join(root, css))
259
- ? readFile(join(root, css), "utf-8")
260
- : Promise.resolve("");
260
+ /**
261
+ * Read a user stylesheet that eject inlines into a generated entry under
262
+ * `genDir`, re-rooting its relative `@source` paths from the user's file.
263
+ * Resolves to an empty string when the file is unset or absent.
264
+ */
265
+ const readUserCss = async (
266
+ file: string | null,
267
+ genDir: string
268
+ ): Promise<string> => {
269
+ if (!(file && existsSync(file))) {
270
+ return "";
271
+ }
272
+ const css = await readFile(file, "utf-8");
273
+ return rebaseSourceDirectives(css, { from: file, to: genDir });
274
+ };
261
275
 
262
276
  /**
263
277
  * The per-example preview route `<Component />` iframes embed, nested under
@@ -331,10 +345,11 @@ export const eject = async (
331
345
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
332
346
  detectNeedsReact(root),
333
347
  detectUsesMath(root),
334
- context.themeFile
335
- ? readFile(context.themeFile, "utf-8")
336
- : Promise.resolve(""),
337
- readExamplesCss(root, config.examples.css),
348
+ readUserCss(context.themeFile, genDir),
349
+ readUserCss(
350
+ config.examples.css ? join(root, config.examples.css) : null,
351
+ genDir
352
+ ),
338
353
  buildRawMarkdown(project),
339
354
  discoverIslands(root),
340
355
  ]);
@@ -387,9 +402,11 @@ export const eject = async (
387
402
  contentRoot: relContext.contentRoot,
388
403
  contentRoutes: project.manifest.routes.map((route) => route.path),
389
404
  context: relContext,
390
- dataPath: "./src/generated/data.json",
391
405
  examplesPath: "./src/generated/examples.ts",
392
406
  examplesThemePath: "./src/generated/examples.css",
407
+ // No CLI publishes the runtime data modules in memory after eject, so
408
+ // the config aliases each to the JSON snapshot written below.
409
+ generatedModulesDir: "./src/generated",
393
410
  integrationBridge: ejectIntegrationBridge(
394
411
  config,
395
412
  root,
@@ -398,7 +415,6 @@ export const eject = async (
398
415
  needsReact,
399
416
  needsSvelte,
400
417
  needsVue,
401
- openapiPath: "./src/generated/openapi.json",
402
418
  pages: relPages,
403
419
  searchClientPath: "./src/generated/search-client.ts",
404
420
  themePath: "./src/generated/app.css",
@@ -452,7 +468,9 @@ export const eject = async (
452
468
  // Relative sources keep the ejected app portable.
453
469
  content: examplesEntryTemplate({
454
470
  configTokens: buildThemeCss(config.theme),
455
- sources: ["../../**/*.{astro,jsx,svelte,ts,tsx,vue}"],
471
+ sources: exampleScanRoots(root, examples.dir).map(
472
+ (dir) => `${relative(genDir, dir)}/${EXAMPLE_SCAN_GLOB}`
473
+ ),
456
474
  userCss: userExamplesCss,
457
475
  }),
458
476
  path: join(genDir, "examples.css"),
@@ -513,7 +531,8 @@ export const eject = async (
513
531
  if (config.seo.og.enabled) {
514
532
  files.push({
515
533
  content: ogEndpointTemplate(
516
- customOgRoutes(pages, config.title, config.seo.og.titles)
534
+ customOgRoutes(pages, config.title, config.seo.og.titles),
535
+ { pageDescriptions: config.seo.og.description !== false }
517
536
  ),
518
537
  path: join(srcDir, "pages", "og", "[...slug].png.ts"),
519
538
  });
@@ -835,7 +835,7 @@ ${options.userTheme}
835
835
  interface ExamplesEntryOptions {
836
836
  /** Config-derived token overrides (`:root { --blume-accent: ... }`). */
837
837
  configTokens: string;
838
- /** Globs to scan for utility classes (example files and their imports). */
838
+ /** Globs to scan for utility classes (the project and examples directory). */
839
839
  sources: string[];
840
840
  /** Raw contents of the configured `examples.css`, if any. */
841
841
  userCss: string;
@@ -855,7 +855,7 @@ export const examplesEntryTemplate = (options: ExamplesEntryOptions): string =>
855
855
  `/* Generated by Blume. Do not edit. */
856
856
  @import "tailwindcss";
857
857
 
858
- /* Scan the example files and the project sources they import. */
858
+ /* Scan the project (and an out-of-root examples directory) for utility classes. */
859
859
  ${options.sources.map((source) => `@source "${source}";`).join("\n")}
860
860
 
861
861
  ${DARK_VARIANT}
@@ -0,0 +1,49 @@
1
+ import { dirname, isAbsolute, relative, resolve } from "pathe";
2
+
3
+ /**
4
+ * A Tailwind `@source "…"` / `@source not "…"` directive with a quoted path.
5
+ * `@source inline("…")` never matches: the quote must directly follow the
6
+ * keyword (or `not`), and `inline(` sits in between.
7
+ */
8
+ const SOURCE_DIRECTIVE =
9
+ /@source(?<not>\s+not)?\s+(?<quote>["'])(?<path>[^"']+)\k<quote>/gu;
10
+
11
+ /**
12
+ * Rewrite the relative `@source` paths in a user stylesheet so they still
13
+ * point where the author meant once the sheet is inlined into a generated
14
+ * Tailwind entry. Tailwind resolves `@source` relative to the stylesheet that
15
+ * declares it, and Blume splices `theme.css` / `examples.css` verbatim into
16
+ * `.blume/src/generated/*.css` (or `src/generated/*.css` after eject), which
17
+ * would silently re-root them there. Resolving each path against the user's
18
+ * file and re-expressing it relative to the generated directory keeps the
19
+ * standard contract — write `@source "../../packages/ui"` next to the file
20
+ * that says it — and lets a monorepo scan sibling workspace packages for
21
+ * utility classes without any knowledge of Blume's internal layout. Absolute
22
+ * paths pass through untouched.
23
+ */
24
+ export const rebaseSourceDirectives = (
25
+ css: string,
26
+ options: {
27
+ /** The user's stylesheet the CSS was read from. */
28
+ from: string;
29
+ /** The directory of the generated entry the CSS is inlined into. */
30
+ to: string;
31
+ }
32
+ ): string => {
33
+ const base = dirname(options.from);
34
+ return css.replace(
35
+ SOURCE_DIRECTIVE,
36
+ (
37
+ directive: string,
38
+ not: string | undefined,
39
+ quote: string,
40
+ path: string
41
+ ): string => {
42
+ if (isAbsolute(path)) {
43
+ return directive;
44
+ }
45
+ const rebased = relative(options.to, resolve(base, path));
46
+ return `@source${not ?? ""} ${quote}${rebased}${quote}`;
47
+ }
48
+ );
49
+ };