blume 1.5.3 → 1.6.1

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 (209) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +3949 -1403
  3. package/dist/cli/index.js.map +111 -96
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +33 -2
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +10 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +122 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +29 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +26 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/07-faq.mdx +9 -9
  21. package/docs/_snippets/include-demo.mdx +7 -0
  22. package/docs/advanced/api-reference.mdx +13 -4
  23. package/docs/advanced/custom-pages.mdx +4 -2
  24. package/docs/advanced/graphql.mdx +84 -0
  25. package/docs/advanced/meta.ts +8 -1
  26. package/docs/configuration/ai.mdx +25 -3
  27. package/docs/configuration/index.mdx +24 -0
  28. package/docs/configuration/search.mdx +13 -1
  29. package/docs/configuration/seo.mdx +30 -3
  30. package/docs/configuration/theming.mdx +23 -0
  31. package/docs/content/components.mdx +15 -1
  32. package/docs/content/includes.mdx +68 -0
  33. package/docs/content/meta.ts +1 -0
  34. package/docs/content/navigation.mdx +25 -0
  35. package/docs/content/sources.mdx +42 -1
  36. package/docs/content/syntax.mdx +69 -1
  37. package/docs/content/versioning.mdx +15 -9
  38. package/docs/reference/cli.mdx +2 -1
  39. package/package.json +66 -57
  40. package/skills/blume-migrate/SKILL.md +16 -7
  41. package/skills/blume-migrate/references/docusaurus.md +5 -3
  42. package/skills/blume-migrate/references/fumadocs.md +10 -2
  43. package/skills/blume-migrate/references/mintlify.md +3 -2
  44. package/skills/blume-migrate/references/nextra.md +2 -2
  45. package/skills/blume-migrate/references/starlight.md +1 -1
  46. package/src/ai/agent-readability.ts +2 -1
  47. package/src/ai/ask-data.ts +2 -1
  48. package/src/ai/component-markdown.ts +199 -36
  49. package/src/ai/llms.ts +93 -6
  50. package/src/ai/markdown.ts +2 -2
  51. package/src/ai/mcp/discovery.ts +10 -2
  52. package/src/ai/mcp/server.ts +74 -2
  53. package/src/astro/examples.ts +29 -2
  54. package/src/astro/generate.ts +282 -177
  55. package/src/astro/include-hmr.ts +81 -0
  56. package/src/astro/include-refresh.ts +0 -0
  57. package/src/astro/index.ts +10 -5
  58. package/src/astro/markdown-negotiation.ts +1 -1
  59. package/src/astro/runtime-modules.ts +196 -0
  60. package/src/astro/templates.ts +365 -113
  61. package/src/cli/commands/build.ts +91 -16
  62. package/src/cli/commands/dev.ts +6 -3
  63. package/src/cli/host-args.ts +18 -0
  64. package/src/cli/index.ts +2 -1
  65. package/src/cli/init/questions.ts +1 -0
  66. package/src/cli/init/scaffold.ts +27 -4
  67. package/src/components/colors.ts +142 -0
  68. package/src/components/content/Badge.astro +5 -12
  69. package/src/components/content/Callout.astro +19 -36
  70. package/src/components/content/Card.astro +15 -21
  71. package/src/components/content/Component.astro +10 -1
  72. package/src/components/content/GithubInfo.astro +28 -9
  73. package/src/components/content/Tabs.astro +27 -5
  74. package/src/components/content/github-info.ts +20 -5
  75. package/src/components/copy-feedback.ts +93 -9
  76. package/src/components/dropdown-dismiss.ts +122 -0
  77. package/src/components/islands/ask-ai.tsx +4 -1
  78. package/src/components/islands/hooks.ts +3 -1
  79. package/src/components/layout/Fonts.astro +15 -8
  80. package/src/components/layout/Header.astro +44 -0
  81. package/src/components/layout/LanguageSwitcher.astro +9 -1
  82. package/src/components/layout/NavSelector.astro +12 -3
  83. package/src/components/layout/NavTree.astro +6 -18
  84. package/src/components/layout/PageActions.astro +54 -22
  85. package/src/components/layout/PageLayout.astro +2 -0
  86. package/src/components/layout/ReferenceLayout.astro +6 -1
  87. package/src/components/layout/RootLayout.astro +42 -15
  88. package/src/components/layout/Search.astro +36 -4
  89. package/src/components/layout/TableOfContents.astro +8 -2
  90. package/src/components/layout/head-scripts.ts +30 -1
  91. package/src/components/openapi/ApiOverview.astro +13 -3
  92. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  93. package/src/components/openapi/GraphqlChip.astro +33 -0
  94. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  95. package/src/components/openapi/GraphqlOperation.astro +186 -0
  96. package/src/components/openapi/GraphqlType.astro +154 -0
  97. package/src/components/openapi/MethodBadge.astro +3 -14
  98. package/src/components/openapi/Operation.astro +12 -5
  99. package/src/components/openapi/OperationPanel.astro +43 -0
  100. package/src/components/openapi/RequestPanel.astro +5 -10
  101. package/src/components/openapi/Responses.astro +1 -16
  102. package/src/components/openapi/graphql-helpers.ts +466 -0
  103. package/src/components/openapi/playground-client.ts +15 -0
  104. package/src/components/openapi/sample-panels.ts +45 -0
  105. package/src/components/openapi/snippets.ts +13 -35
  106. package/src/core/base-path.ts +11 -0
  107. package/src/core/config-input.ts +209 -2
  108. package/src/core/config.ts +6 -4
  109. package/src/core/content-assets.ts +15 -4
  110. package/src/core/data.ts +28 -3
  111. package/src/core/define-components.ts +2 -0
  112. package/src/core/diagnostics.ts +8 -0
  113. package/src/core/frontmatter.ts +20 -8
  114. package/src/core/github.ts +71 -0
  115. package/src/core/graph.ts +22 -8
  116. package/src/core/heading-markers.ts +96 -0
  117. package/src/core/i18n-ui.ts +12 -0
  118. package/src/core/includes.ts +633 -0
  119. package/src/core/last-modified.ts +36 -11
  120. package/src/core/links.ts +79 -13
  121. package/src/core/manifest.ts +10 -0
  122. package/src/core/meta.ts +2 -1
  123. package/src/core/nav-diagnostics.ts +11 -2
  124. package/src/core/navigation.ts +27 -6
  125. package/src/core/project-graph.ts +61 -9
  126. package/src/core/schema.ts +235 -36
  127. package/src/core/server-features.ts +5 -9
  128. package/src/core/sources/github-releases.ts +2 -2
  129. package/src/core/sources/normalize.ts +502 -115
  130. package/src/core/sources/notion.ts +43 -8
  131. package/src/core/sources/obsidian.ts +1038 -0
  132. package/src/core/sources/read.ts +36 -1
  133. package/src/core/sources/resolve.ts +34 -1
  134. package/src/core/sources/types.ts +28 -6
  135. package/src/core/sources/watch.ts +12 -8
  136. package/src/core/tsconfig-aliases.ts +48 -35
  137. package/src/core/types.ts +31 -2
  138. package/src/core/ui-packs/ar.ts +2 -0
  139. package/src/core/ui-packs/bg.ts +3 -0
  140. package/src/core/ui-packs/bn.ts +2 -0
  141. package/src/core/ui-packs/ca.ts +3 -0
  142. package/src/core/ui-packs/cs.ts +2 -0
  143. package/src/core/ui-packs/da.ts +2 -0
  144. package/src/core/ui-packs/de.ts +3 -0
  145. package/src/core/ui-packs/el.ts +3 -0
  146. package/src/core/ui-packs/es.ts +3 -0
  147. package/src/core/ui-packs/fa.ts +2 -0
  148. package/src/core/ui-packs/fi.ts +2 -0
  149. package/src/core/ui-packs/fr.ts +3 -0
  150. package/src/core/ui-packs/he.ts +2 -0
  151. package/src/core/ui-packs/hi.ts +2 -0
  152. package/src/core/ui-packs/hr.ts +3 -0
  153. package/src/core/ui-packs/hu.ts +3 -0
  154. package/src/core/ui-packs/id.ts +3 -0
  155. package/src/core/ui-packs/it.ts +2 -0
  156. package/src/core/ui-packs/ja.ts +3 -0
  157. package/src/core/ui-packs/ko.ts +3 -0
  158. package/src/core/ui-packs/nl.ts +3 -0
  159. package/src/core/ui-packs/no.ts +3 -0
  160. package/src/core/ui-packs/pl.ts +3 -0
  161. package/src/core/ui-packs/pt-br.ts +3 -0
  162. package/src/core/ui-packs/pt.ts +3 -0
  163. package/src/core/ui-packs/ro.ts +3 -0
  164. package/src/core/ui-packs/ru.ts +3 -0
  165. package/src/core/ui-packs/sk.ts +2 -0
  166. package/src/core/ui-packs/sr.ts +2 -0
  167. package/src/core/ui-packs/sv.ts +3 -0
  168. package/src/core/ui-packs/th.ts +2 -0
  169. package/src/core/ui-packs/tr.ts +3 -0
  170. package/src/core/ui-packs/uk.ts +3 -0
  171. package/src/core/ui-packs/vi.ts +2 -0
  172. package/src/core/ui-packs/zh-tw.ts +2 -0
  173. package/src/core/ui-packs/zh.ts +2 -0
  174. package/src/core/version-cut.ts +26 -6
  175. package/src/core/yaml.ts +26 -0
  176. package/src/deploy/function-bundle.ts +251 -0
  177. package/src/deploy/vercel-negotiation.ts +49 -6
  178. package/src/eval/schema.ts +3 -1
  179. package/src/markdown/code-title.ts +22 -16
  180. package/src/markdown/features.ts +21 -0
  181. package/src/markdown/fence-meta.ts +50 -0
  182. package/src/markdown/heading-anchors.ts +198 -37
  183. package/src/markdown/include.ts +247 -0
  184. package/src/markdown/index.ts +43 -34
  185. package/src/markdown/language-icon.ts +2 -2
  186. package/src/markdown/mdast.ts +7 -3
  187. package/src/markdown/ts2js.ts +264 -0
  188. package/src/og/card.ts +1 -1
  189. package/src/openapi/asyncapi.ts +4 -1
  190. package/src/openapi/graphql-build.ts +293 -0
  191. package/src/openapi/graphql.ts +212 -0
  192. package/src/openapi/model.ts +38 -5
  193. package/src/openapi/parse.ts +34 -0
  194. package/src/openapi/proxy.ts +30 -5
  195. package/src/openapi/references.ts +97 -13
  196. package/src/openapi/render-mdx.ts +66 -12
  197. package/src/openapi/scalar.ts +5 -16
  198. package/src/openapi/source.ts +91 -23
  199. package/src/registry/eject.ts +47 -17
  200. package/src/search/documents.ts +229 -37
  201. package/src/search/orama-index.ts +9 -5
  202. package/src/seo/jsonld.ts +293 -51
  203. package/src/theme/code-block-padding.ts +16 -0
  204. package/src/theme/entry.ts +67 -13
  205. package/src/theme/fonts.ts +189 -16
  206. package/src/theme/sources.ts +49 -0
  207. package/src/translate/prompts.ts +2 -0
  208. package/src/translate/run.ts +7 -0
  209. package/src/translate/work-list.ts +0 -0
@@ -0,0 +1,251 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { builtinModules } from "node:module";
4
+
5
+ import { dirname, join, relative } from "pathe";
6
+ import { z } from "zod";
7
+
8
+ import { packageRoot } from "../core/package-root.ts";
9
+
10
+ /**
11
+ * Post-build audit of a Vercel serverless function bundle.
12
+ *
13
+ * The Vercel adapter traces the server entry with `@vercel/nft` and copies
14
+ * every file it reaches into `.vercel/output/functions/_render.func`. A bare
15
+ * import the trace cannot resolve — a package that isn't reachable by walking
16
+ * `node_modules` up from the chunk that imports it — is dropped *silently*,
17
+ * and the deployed function dies on its first request with
18
+ * `ERR_MODULE_NOT_FOUND` (Vercel reports it as `FUNCTION_INVOCATION_FAILED`).
19
+ * Under an isolated linker (pnpm, Bun's `isolated` mode) that is exactly the
20
+ * shape of Blume's own runtime dependencies: the SSR build leaves them
21
+ * external, and they live under `blume`'s store directory, not the project
22
+ * root. Nothing before deploy surfaces it — the build is green, a warm-cache
23
+ * preview is green — so the bundle is re-checked here with Node's own
24
+ * resolution rule: a package is present when some `node_modules/<name>` on
25
+ * the walk from the importing chunk up to the function root exists.
26
+ */
27
+
28
+ /** A bare package import the function bundle cannot satisfy. */
29
+ export interface MissingPackage {
30
+ /** Function-relative paths of the chunks that import the package. */
31
+ importers: string[];
32
+ name: string;
33
+ }
34
+
35
+ /** One function directory's audit result. */
36
+ export interface FunctionBundleAudit {
37
+ /** Absolute path of the `*.func` directory. */
38
+ dir: string;
39
+ missing: MissingPackage[];
40
+ }
41
+
42
+ const BUILTINS = new Set(builtinModules);
43
+
44
+ /** Specifier prefixes that never name an installed package. */
45
+ const NON_PACKAGE_PREFIXES = ["node:", "astro:", "virtual:", "data:", "\0"];
46
+
47
+ /**
48
+ * The package a bare specifier resolves through (`@scope/name` or `name`), or
49
+ * null for relative/absolute paths, Node builtins, and virtual ids.
50
+ */
51
+ export const packageName = (specifier: string): string | null => {
52
+ if (
53
+ specifier.startsWith(".") ||
54
+ specifier.startsWith("/") ||
55
+ NON_PACKAGE_PREFIXES.some((prefix) => specifier.startsWith(prefix)) ||
56
+ BUILTINS.has(specifier)
57
+ ) {
58
+ return null;
59
+ }
60
+ const segments = specifier.split("/");
61
+ if (specifier.startsWith("@")) {
62
+ return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null;
63
+ }
64
+ return segments[0] || null;
65
+ };
66
+
67
+ /**
68
+ * Static specifiers: a side-effect `import "x"` or an `import`/`export … from
69
+ * "x"` clause. Only `import` takes the bare-string form — `export "x"` is not
70
+ * syntax, so a runtime message quoting `export 'ALL'` must not match. The
71
+ * specifier quote must not be escaped either: a backslash-quoted `from \"zod\"`
72
+ * is a code sample serialized into a string (the MCP snapshot carries page
73
+ * Markdown), not module syntax.
74
+ */
75
+ const STATIC_IMPORT =
76
+ /(?:^|[;\s}])(?:import\s*(?<!\\)["'](?<bare>[^"'\n]+)["']|(?:import|export)\s*[\w$*{},\s]*?\s*from\s*(?<!\\)["'](?<from>[^"'\n]+)["'])/gu;
77
+
78
+ /** Dynamic `import("…")` specifiers, same escaped-quote rule. */
79
+ const DYNAMIC_IMPORT =
80
+ /\bimport\(\s*(?<!\\)["'](?<dynamic>[^"'\n]+)["']\s*\)/gu;
81
+
82
+ /** Every bare package name a module's source imports. */
83
+ export const importedPackages = (source: string): string[] => {
84
+ const names = new Set<string>();
85
+ for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) {
86
+ for (const match of source.matchAll(pattern)) {
87
+ const groups = match.groups ?? {};
88
+ const name = packageName(
89
+ groups.bare ?? groups.from ?? groups.dynamic ?? ""
90
+ );
91
+ if (name) {
92
+ names.add(name);
93
+ }
94
+ }
95
+ }
96
+ return [...names];
97
+ };
98
+
99
+ /** Whether `node_modules/<name>` exists on the walk from `from` up to `root`. */
100
+ const resolvable = (name: string, from: string, root: string): boolean => {
101
+ let dir = from;
102
+ for (;;) {
103
+ if (existsSync(join(dir, "node_modules", name, "package.json"))) {
104
+ return true;
105
+ }
106
+ if (dir === root) {
107
+ return false;
108
+ }
109
+ const parent = dirname(dir);
110
+ if (parent === dir) {
111
+ return false;
112
+ }
113
+ dir = parent;
114
+ }
115
+ };
116
+
117
+ const MODULE_FILE = /\.(?:m?js|cjs)$/u;
118
+
119
+ /** The one `.vc-config.json` field the audit reads; the rest passes through. */
120
+ const vcConfigSchema = z.looseObject({ handler: z.string().optional() });
121
+
122
+ /** Every JavaScript module under `dir`, skipping any `node_modules`. */
123
+ const listModules = async (dir: string): Promise<string[]> => {
124
+ const files: string[] = [];
125
+ const entries = await readdir(dir, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ const path = join(dir, entry.name);
128
+ if (entry.isDirectory()) {
129
+ if (entry.name !== "node_modules") {
130
+ // oxlint-disable-next-line no-await-in-loop -- sequential walk keeps ordering deterministic
131
+ files.push(...(await listModules(path)));
132
+ }
133
+ } else if (entry.isFile() && MODULE_FILE.test(entry.name)) {
134
+ files.push(path);
135
+ }
136
+ }
137
+ return files;
138
+ };
139
+
140
+ /**
141
+ * Audit one function directory: read its `.vc-config.json` handler, scan
142
+ * every module beside the handler (the adapter's `dist/server` tree) for bare
143
+ * imports, and report the packages no `node_modules` on the walk up to the
144
+ * function root provides. A function without a handler has nothing to check.
145
+ */
146
+ export const auditFunctionBundle = async (
147
+ funcDir: string
148
+ ): Promise<MissingPackage[]> => {
149
+ const configPath = join(funcDir, ".vc-config.json");
150
+ if (!existsSync(configPath)) {
151
+ return [];
152
+ }
153
+ const config = vcConfigSchema.safeParse(
154
+ JSON.parse(await readFile(configPath, "utf-8"))
155
+ );
156
+ if (!config.success || !config.data.handler) {
157
+ return [];
158
+ }
159
+ const serverDir = dirname(join(funcDir, config.data.handler));
160
+ if (!existsSync(serverDir)) {
161
+ return [];
162
+ }
163
+ const missing = new Map<string, string[]>();
164
+ for (const file of await listModules(serverDir)) {
165
+ // oxlint-disable-next-line no-await-in-loop -- sequential read keeps the importer lists ordered
166
+ const source = await readFile(file, "utf-8");
167
+ for (const name of importedPackages(source)) {
168
+ if (resolvable(name, dirname(file), funcDir)) {
169
+ continue;
170
+ }
171
+ const importers = missing.get(name) ?? [];
172
+ importers.push(relative(funcDir, file));
173
+ missing.set(name, importers);
174
+ }
175
+ }
176
+ return [...missing]
177
+ .map(([name, importers]) => ({ importers, name }))
178
+ .toSorted((a, b) => a.name.localeCompare(b.name));
179
+ };
180
+
181
+ /**
182
+ * Audit every function in a Vercel Build Output tree (`.vercel/output`).
183
+ * Functions with nothing missing are omitted; an output tree without a
184
+ * `functions/` directory (a static build) yields nothing.
185
+ */
186
+ export const auditVercelFunctions = async (
187
+ outputDir: string
188
+ ): Promise<FunctionBundleAudit[]> => {
189
+ const functionsDir = join(outputDir, "functions");
190
+ if (!existsSync(functionsDir)) {
191
+ return [];
192
+ }
193
+ const audits: FunctionBundleAudit[] = [];
194
+ const entries = await readdir(functionsDir, { withFileTypes: true });
195
+ for (const entry of entries.toSorted((a, b) => (a.name < b.name ? -1 : 1))) {
196
+ if (!(entry.isDirectory() && entry.name.endsWith(".func"))) {
197
+ continue;
198
+ }
199
+ const dir = join(functionsDir, entry.name);
200
+ // oxlint-disable-next-line no-await-in-loop -- sequential audit keeps the report ordered
201
+ const missing = await auditFunctionBundle(dir);
202
+ if (missing.length > 0) {
203
+ audits.push({ dir, missing });
204
+ }
205
+ }
206
+ return audits;
207
+ };
208
+
209
+ /** Blume's own runtime dependency names, from its published `package.json`. */
210
+ export const blumeDependencyNames = (): Set<string> => {
211
+ const manifest: { dependencies?: Record<string, string> } = JSON.parse(
212
+ readFileSync(join(packageRoot(), "package.json"), "utf-8")
213
+ );
214
+ return new Set(Object.keys(manifest.dependencies ?? {}));
215
+ };
216
+
217
+ /** What a build should do about an audit: nothing, warn, or fail. */
218
+ export interface FunctionBundleVerdict {
219
+ /**
220
+ * A missing package is one of Blume's own dependencies — the runtime Blume
221
+ * generated imports it, so the function is certain to crash. Anything else
222
+ * (a project's own external import) is reported but left to the author.
223
+ */
224
+ fatal: boolean;
225
+ message: string;
226
+ }
227
+
228
+ /**
229
+ * Describe a function's missing packages and how to fix them. Vercel's trace
230
+ * only reaches packages resolvable from the project root, so the remedy is a
231
+ * root-level dependency entry for each — the same mirror rule Blume's native
232
+ * dependencies (`sharp`, `takumi-js`) already follow.
233
+ */
234
+ export const functionBundleVerdict = (
235
+ audit: FunctionBundleAudit,
236
+ root: string,
237
+ ownDependencies: ReadonlySet<string>
238
+ ): FunctionBundleVerdict => {
239
+ const names = audit.missing.map((entry) => entry.name);
240
+ const lines = audit.missing.map(
241
+ (entry) => ` - ${entry.name} (imported by ${entry.importers.join(", ")})`
242
+ );
243
+ const fatal = names.some((name) => ownDependencies.has(name));
244
+ const message = [
245
+ `The Vercel function bundle at ${relative(root, audit.dir) || audit.dir} is missing packages its server code imports, so the deployed function would fail on every request with ERR_MODULE_NOT_FOUND:`,
246
+ ...lines,
247
+ "Vercel's dependency trace only includes packages resolvable from the project root; under an isolated linker (pnpm, Bun's isolated mode) Blume's own dependencies are not. Add them to the project's package.json so the trace can find them:",
248
+ ` npm install -D ${names.join(" ")}`,
249
+ ].join("\n");
250
+ return { fatal, message };
251
+ };
@@ -9,6 +9,9 @@
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 shell.
12
15
  */
13
16
 
14
17
  /**
@@ -49,6 +52,32 @@ const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
49
52
 
50
53
  const VARY_ACCEPT = { vary: "Accept" };
51
54
 
55
+ /** Where the prerendered Markdown 404 (`pages/404.md.ts`) lands. */
56
+ const NOT_FOUND_MARKDOWN_DEST = "/404.md";
57
+
58
+ /** The adapter's own not-found fallback — the anchor the Markdown 404 precedes. */
59
+ const NOT_FOUND_HTML_DEST = "/404.html";
60
+
61
+ /**
62
+ * Miss-phase routes that answer a missing page with the Markdown 404 body: any
63
+ * path when the client prefers Markdown, and any `.md`/`.mdx` URL (a request
64
+ * for a raw-Markdown mirror that has no page wants Markdown back, not the HTML
65
+ * shell). Both keep the 404 status. Spliced immediately before the adapter's
66
+ * `/404.html` fallback, so they run after every server route (the MCP
67
+ * endpoint, server islands, images) has had its turn and never hijack a
68
+ * request one of those would have answered.
69
+ */
70
+ const NOT_FOUND_MARKDOWN_ROUTES: readonly VercelRoute[] = [
71
+ {
72
+ dest: NOT_FOUND_MARKDOWN_DEST,
73
+ has: ACCEPT_MARKDOWN_CONDITION,
74
+ headers: VARY_ACCEPT,
75
+ src: "^/.*$",
76
+ status: 404,
77
+ },
78
+ { dest: NOT_FOUND_MARKDOWN_DEST, src: "^/.*\\.mdx?$", status: 404 },
79
+ ];
80
+
52
81
  /**
53
82
  * Vercel rejects route `src` patterns longer than 4096 characters, so route
54
83
  * alternations are split across as many route entries as needed. The budget
@@ -186,12 +215,14 @@ export const TRAILING_SLASH_REDIRECT: VercelRoute = {
186
215
  * user-authored route of that identical shape would be semantically equal to
187
216
  * the one re-added); the homepage `Link` route by its three-field
188
217
  * continue-with-link shape (the Build Output config is adapter-generated, so
189
- * no user-authored route competes in this file).
218
+ * no user-authored route competes in this file); the Markdown 404 routes by
219
+ * their `/404.md` destination.
190
220
  */
191
221
  const isNegotiationRoute = (route: VercelRoute): boolean =>
192
222
  route.has?.some(
193
223
  (condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE
194
224
  ) === true ||
225
+ (route.dest === NOT_FOUND_MARKDOWN_DEST && route.status === 404) ||
195
226
  (route.continue === true &&
196
227
  route.headers?.vary === "Accept" &&
197
228
  isString(route.src) &&
@@ -213,17 +244,21 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
213
244
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
214
245
  * signature directory). The trailing-slash 308 redirect is always spliced in
215
246
  * 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.
247
+ * slashless URL. With `notFoundMarkdown` (the build emitted `404.md`), the
248
+ * Markdown 404 routes go into the miss phase right before the adapter's
249
+ * `/404.html` fallback — and nowhere when that fallback is absent, since a
250
+ * `dest` with no file behind it would serve nothing. Returns the updated JSON
251
+ * text (tab-indented, like the adapter's own output), or `null` when there is
252
+ * nowhere safe to splice: an unparsable config, no `routes` array, or no
253
+ * `handle: "filesystem"` marker to anchor the splice.
220
254
  */
221
255
  export const injectNegotiationRoutes = (
222
256
  configText: string,
223
257
  routePaths: readonly string[],
224
258
  homeLinkHeader?: string | null,
225
259
  contentTypeOverrides?: Record<string, string>,
226
- homeTokens?: number
260
+ homeTokens?: number,
261
+ notFoundMarkdown = false
227
262
  ): string | null => {
228
263
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
229
264
  let config: {
@@ -272,6 +307,14 @@ export const injectNegotiationRoutes = (
272
307
  ...rewriteRoutes,
273
308
  TRAILING_SLASH_REDIRECT
274
309
  );
310
+ if (notFoundMarkdown) {
311
+ const fallbackIndex = routes.findIndex(
312
+ (route) => route.status === 404 && route.dest === NOT_FOUND_HTML_DEST
313
+ );
314
+ if (fallbackIndex !== -1) {
315
+ routes.splice(fallbackIndex, 0, ...NOT_FOUND_MARKDOWN_ROUTES);
316
+ }
317
+ }
275
318
  config.routes = routes;
276
319
  return `${JSON.stringify(config, null, "\t")}\n`;
277
320
  };
@@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises";
3
3
  import { load } from "js-yaml";
4
4
  import { z } from "zod";
5
5
 
6
+ import { YAML_SCHEMA } from "../core/yaml.ts";
7
+
6
8
  /** Question ids are kebab-case slugs so they read well in reports and CI logs. */
7
9
  const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
8
10
 
@@ -90,7 +92,7 @@ export const loadEvalsFile = async (
90
92
 
91
93
  let parsed: unknown;
92
94
  try {
93
- parsed = load(raw);
95
+ parsed = load(raw, { schema: YAML_SCHEMA });
94
96
  } catch (error) {
95
97
  const detail = error instanceof Error ? error.message : String(error);
96
98
  throw new EvalsFileError(path, `Invalid YAML in ${path}: ${detail}`);
@@ -9,6 +9,13 @@
9
9
  * `data-line-numbers`; the theme renders a counter-driven line-number gutter.
10
10
  */
11
11
 
12
+ import {
13
+ isLineRange,
14
+ metaTokens,
15
+ QUOTED_ATTR,
16
+ RESERVED_META_KEYWORDS,
17
+ } from "./fence-meta.ts";
18
+
12
19
  /** The slice of Shiki's transformer `this` context Blume reads. */
13
20
  interface CodeMetaContext {
14
21
  options: { meta?: { __raw?: string } };
@@ -25,29 +32,25 @@ export interface CodeTitleTransformer {
25
32
  pre: (this: CodeMetaContext, node: PreNode) => void;
26
33
  }
27
34
 
28
- // The body excludes only the delimiting quote, so `title="foo's file.ts"`
29
- // (an apostrophe inside double quotes) still matches. The left boundary stops
30
- // `subtitle="..."` (or any `*title=` attr) from reading as a title.
31
35
  const TITLE_ATTR = /(?:^|\s)title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
32
36
  const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
33
- // Any quoted `key="..."` attr — blanked before keyword/bare-token scans so a
34
- // quoted value can't leak tokens (`title="enable lineNumbers later"`).
35
- const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
36
37
 
38
+ // Quoted attrs are blanked before keyword/bare-token scans so a quoted value
39
+ // can't leak tokens (`title="enable lineNumbers later"`).
37
40
  const withoutQuotedAttrs = (raw: string): string =>
38
41
  raw.replace(QUOTED_ATTR, " ");
39
42
 
40
43
  // The first bare token is the title (```ts blume.config.ts): a non-empty token
41
44
  // that isn't a Shiki line range (`{1,3-5}`), a `key=value` attr, or a reserved
42
- // `lineNumbers`/`twoslash` keyword.
43
- const isTitleToken = (token: string): boolean => {
44
- if (token.length === 0 || token.startsWith("{") || token.includes("=")) {
45
- return false;
46
- }
47
- return token !== "lineNumbers" && token !== "twoslash";
48
- };
45
+ // keyword.
46
+ const isTitleToken = (token: string): boolean =>
47
+ token.length > 0 &&
48
+ !isLineRange(token) &&
49
+ !token.includes("=") &&
50
+ !RESERVED_META_KEYWORDS.has(token);
49
51
 
50
- const parseTitle = (raw: string | undefined): string | undefined => {
52
+ /** The title a fence's meta string promotes to `data-title`, if any. */
53
+ export const parseCodeTitle = (raw: string | undefined): string | undefined => {
51
54
  if (!raw) {
52
55
  return undefined;
53
56
  }
@@ -62,7 +65,10 @@ const parseTitle = (raw: string | undefined): string | undefined => {
62
65
  if (attrTitle) {
63
66
  return attrTitle;
64
67
  }
65
- return withoutQuotedAttrs(raw).trim().split(/\s+/u).find(isTitleToken);
68
+ // The shared tokenizer keeps a quoted attr (rejected below by its `=`) and
69
+ // a spaced line range (`{1, 3-5}`) whole, so neither can shed a fragment
70
+ // that reads as a bare title.
71
+ return metaTokens(raw).find(isTitleToken);
66
72
  };
67
73
 
68
74
  const hasLineNumbers = (raw: string | undefined): boolean =>
@@ -73,7 +79,7 @@ export const codeTitleTransformer = (): CodeTitleTransformer => ({
73
79
  name: "blume:code-meta",
74
80
  pre(node) {
75
81
  const raw = this.options.meta?.__raw;
76
- const title = parseTitle(raw);
82
+ const title = parseCodeTitle(raw);
77
83
  if (title) {
78
84
  node.properties.dataTitle = title;
79
85
  }
@@ -0,0 +1,21 @@
1
+ import type { Features } from "satteri";
2
+
3
+ /**
4
+ * Sätteri's feature set for plain `.md` pages. Shared by the renderer and the
5
+ * search extractor so the index reads the same grammar the page renders.
6
+ */
7
+ export const MARKDOWN_FEATURES = {
8
+ subscript: true,
9
+ superscript: true,
10
+ } satisfies Features;
11
+
12
+ /**
13
+ * The `.mdx` feature set: Markdown's plus `:::` directives (→ `<Callout>`) and
14
+ * block-only math — `singleDollarTextMath: false` keeps a bare `$` (currency,
15
+ * shell, code) as literal text and only parses `$$…$$`.
16
+ */
17
+ export const MDX_FEATURES = {
18
+ ...MARKDOWN_FEATURES,
19
+ directive: true,
20
+ math: { singleDollarTextMath: false },
21
+ } satisfies Features;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The shared fence-meta grammar: how the tokens after a code fence's language
3
+ * (```ts title="..." {1,3-5} lineNumbers) split, and which of them carry
4
+ * reserved meaning. Both the Shiki-side meta reader (`code-title.ts`) and the
5
+ * MDAST plugins that rewrite marked fences (`ts2js.ts`) consume this, so the
6
+ * two layers can't drift on token boundaries or keyword lists.
7
+ */
8
+
9
+ /**
10
+ * Any quoted `key="..."` attr (spaces allowed inside the quotes). The body
11
+ * excludes only the delimiting quote, so `title="foo's file.ts"` (an
12
+ * apostrophe inside double quotes) still matches. The left boundary stops
13
+ * `subtitle="..."` (or any `*title=` attr) from reading as a title.
14
+ */
15
+ export const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
16
+
17
+ /**
18
+ * A Shiki `{1,3-5}` line-range token, braces included. Shiki tolerates
19
+ * whitespace inside the braces (`{1, 3-5}`), so the range is one token even
20
+ * when it contains spaces — split on whitespace alone, `3-5}` would surface
21
+ * as a bare word and be promoted to the block title.
22
+ */
23
+ const LINE_RANGE = /\{[^}]*\}/u;
24
+
25
+ /**
26
+ * One fence-meta token: a quoted attribute, a line range, or a bare word, so
27
+ * a keyword inside a quoted value (`title="enable ts2js later"`) never reads
28
+ * as a bare token and a spaced line range never splits.
29
+ */
30
+ const META_TOKEN = new RegExp(
31
+ `${QUOTED_ATTR.source}|${LINE_RANGE.source}|\\S+`,
32
+ "gu"
33
+ );
34
+
35
+ /** Split a raw fence meta string into its tokens. */
36
+ export const metaTokens = (meta: string | null | undefined): string[] =>
37
+ meta?.match(META_TOKEN) ?? [];
38
+
39
+ /**
40
+ * Bare keywords with reserved meaning after the language; never promoted to
41
+ * a block title.
42
+ */
43
+ export const RESERVED_META_KEYWORDS: ReadonlySet<string> = new Set([
44
+ "lineNumbers",
45
+ "ts2js",
46
+ "twoslash",
47
+ ]);
48
+
49
+ /** A Shiki `{1,3-5}` line-range token. */
50
+ export const isLineRange = (token: string): boolean => token.startsWith("{");