blume 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  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 +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -20,6 +20,8 @@ export const pt: UIStringsOverride = {
20
20
  generating: "A gerar…",
21
21
  openIn: "Abrir em {name}",
22
22
  openInChat: "Abrir no chat",
23
+ openInChatPrompt:
24
+ "Leia {url} para que eu possa fazer perguntas sobre esta página.",
23
25
  scrollToTop: "Voltar ao topo",
24
26
  },
25
27
  ask: {
@@ -20,6 +20,8 @@ export const ro: UIStringsOverride = {
20
20
  generating: "Se generează…",
21
21
  openIn: "Deschide în {name}",
22
22
  openInChat: "Deschide în chat",
23
+ openInChatPrompt:
24
+ "Citește {url} ca să îți pot pune întrebări despre această pagină.",
23
25
  scrollToTop: "Sus de tot",
24
26
  },
25
27
  ask: {
@@ -20,6 +20,8 @@ export const ru: UIStringsOverride = {
20
20
  generating: "Генерация…",
21
21
  openIn: "Открыть в {name}",
22
22
  openInChat: "Открыть в чате",
23
+ openInChatPrompt:
24
+ "Прочитайте {url} — я буду задавать вопросы об этой странице.",
23
25
  scrollToTop: "Наверх",
24
26
  },
25
27
  ask: {
@@ -20,6 +20,7 @@ export const sk: UIStringsOverride = {
20
20
  generating: "Generovanie…",
21
21
  openIn: "Otvoriť v {name}",
22
22
  openInChat: "Otvoriť v chate",
23
+ openInChatPrompt: "Prečítajte si {url} — budem sa pýtať na túto stránku.",
23
24
  scrollToTop: "Späť nahor",
24
25
  },
25
26
  ask: {
@@ -20,6 +20,7 @@ export const sr: UIStringsOverride = {
20
20
  generating: "Генерисање…",
21
21
  openIn: "Отвори у {name}",
22
22
  openInChat: "Отвори у ћаскању",
23
+ openInChatPrompt: "Прочитајте {url} — постављаћу питања о овој страници.",
23
24
  scrollToTop: "Назад на врх",
24
25
  },
25
26
  ask: {
@@ -20,6 +20,8 @@ export const sv: UIStringsOverride = {
20
20
  generating: "Genererar…",
21
21
  openIn: "Öppna i {name}",
22
22
  openInChat: "Öppna i chatt",
23
+ openInChatPrompt:
24
+ "Läs {url} så att jag kan ställa frågor om den här sidan.",
23
25
  scrollToTop: "Till toppen",
24
26
  },
25
27
  ask: {
@@ -20,6 +20,7 @@ export const th: UIStringsOverride = {
20
20
  generating: "กำลังสร้าง…",
21
21
  openIn: "เปิดใน {name}",
22
22
  openInChat: "เปิดในแชท",
23
+ openInChatPrompt: "อ่าน {url} เพื่อให้ฉันถามคำถามเกี่ยวกับหน้านี้ได้",
23
24
  scrollToTop: "กลับไปด้านบน",
24
25
  },
25
26
  ask: {
@@ -20,6 +20,8 @@ export const tr: UIStringsOverride = {
20
20
  generating: "Oluşturuluyor…",
21
21
  openIn: "{name} içinde aç",
22
22
  openInChat: "Sohbette aç",
23
+ openInChatPrompt:
24
+ "Bu sayfa hakkında sorular sorabilmem için {url} adresini okuyun.",
23
25
  scrollToTop: "Başa dön",
24
26
  },
25
27
  ask: {
@@ -20,6 +20,8 @@ export const uk: UIStringsOverride = {
20
20
  generating: "Генерація…",
21
21
  openIn: "Відкрити в {name}",
22
22
  openInChat: "Відкрити в чаті",
23
+ openInChatPrompt:
24
+ "Прочитайте {url} — я ставитиму запитання щодо цієї сторінки.",
23
25
  scrollToTop: "Нагору",
24
26
  },
25
27
  ask: {
@@ -20,6 +20,7 @@ export const vi: UIStringsOverride = {
20
20
  generating: "Đang tạo…",
21
21
  openIn: "Mở trong {name}",
22
22
  openInChat: "Mở trong trò chuyện",
23
+ openInChatPrompt: "Hãy đọc {url} để tôi có thể đặt câu hỏi về trang này.",
23
24
  scrollToTop: "Lên đầu trang",
24
25
  },
25
26
  ask: {
@@ -20,6 +20,7 @@ export const zhTW: UIStringsOverride = {
20
20
  generating: "產生中…",
21
21
  openIn: "在 {name} 中開啟",
22
22
  openInChat: "在聊天中開啟",
23
+ openInChatPrompt: "請閱讀 {url} 以便我針對此頁面提問。",
23
24
  scrollToTop: "回到頂部",
24
25
  },
25
26
  ask: {
@@ -20,6 +20,7 @@ export const zh: UIStringsOverride = {
20
20
  generating: "生成中…",
21
21
  openIn: "在 {name} 中打开",
22
22
  openInChat: "在聊天中打开",
23
+ openInChatPrompt: "请阅读 {url} 以便我就此页面提问。",
23
24
  scrollToTop: "回到顶部",
24
25
  },
25
26
  ask: {
@@ -7,7 +7,7 @@ import { stripBasePath } from "./base-path.ts";
7
7
  import { writeTextAtomic } from "./fs-atomic.ts";
8
8
  import { localizeRoute } from "./i18n.ts";
9
9
  import type { BlumeProject } from "./project-graph.ts";
10
- import { scanProject } from "./project-graph.ts";
10
+ import { isWithin, scanProject } from "./project-graph.ts";
11
11
  import { VERSION_ID } from "./schema.ts";
12
12
  import { nextFenceState } from "./sources/normalize.ts";
13
13
  import type { FenceState } from "./sources/normalize.ts";
@@ -60,11 +60,16 @@ const buildRouteRewrites = (
60
60
  const { contentRoot } = project.context;
61
61
  const rewrites = new Map<string, string>();
62
62
  for (const page of project.graph.pages) {
63
- // `sourcePath` is set by the filesystem adapter only, so its absence
64
- // already excludes generated and remote pages.
63
+ // `sourcePath` marks a page backed by a local file, which excludes
64
+ // generated and remote pages; the content-root check below excludes local
65
+ // files that live outside the tree a version snapshot copies. A staged
66
+ // page (an Obsidian note) is served from its source, not from the copied
67
+ // tree, so it keeps publishing as current even when its file sits under
68
+ // the content root.
65
69
  const { sourcePath } = page;
66
70
  if (
67
71
  page.version !== "" ||
72
+ page.collection === "staged" ||
68
73
  !sourcePath ||
69
74
  relative(contentRoot, sourcePath).startsWith("..")
70
75
  ) {
@@ -240,6 +245,18 @@ export const cutVersion = async (
240
245
  "node_modules",
241
246
  ...(project.config.versions?.archived.map((version) => version.id) ?? []),
242
247
  ]);
248
+ // Another local source's tree under the content root — an Obsidian vault the
249
+ // filesystem source excludes — is that source's to publish. Copying it would
250
+ // republish its raw notes (wikilinks and `%%comments%%` intact) as
251
+ // filesystem pages of the snapshot, while the source itself keeps serving
252
+ // them as current.
253
+ const foreignRoots = project.sources
254
+ .flatMap((source) =>
255
+ source.staged && source.contentRoot ? [source.contentRoot] : []
256
+ )
257
+ .filter((tree) => isWithin(contentRoot, tree));
258
+ const isForeign = (path: string): boolean =>
259
+ foreignRoots.some((tree) => isWithin(tree, path));
243
260
  const entries = await readdir(contentRoot, { withFileTypes: true });
244
261
  let copied = 0;
245
262
  await Promise.all(
@@ -252,6 +269,7 @@ export const cutVersion = async (
252
269
  return;
253
270
  }
254
271
  await cp(join(contentRoot, entry.name), join(dir, entry.name), {
272
+ filter: (source) => !isForeign(source),
255
273
  recursive: true,
256
274
  });
257
275
  })
@@ -0,0 +1,26 @@
1
+ import {
2
+ binaryTag,
3
+ CORE_SCHEMA,
4
+ mergeTag,
5
+ omapTag,
6
+ pairsTag,
7
+ setTag,
8
+ timestampTag,
9
+ } from "js-yaml";
10
+
11
+ /**
12
+ * The schema js-yaml 4 loaded with by default: YAML 1.2 core scalars plus the
13
+ * `!!timestamp`, `!!merge`, `!!binary`, `!!omap`, `!!pairs`, and `!!set` tags.
14
+ * js-yaml 5 loads with the bare core schema instead, which turns
15
+ * `date: 2024-01-01` into a string and leaves `<<:` merge keys unresolved — a
16
+ * silent change to every page's front matter. Every Blume `load` call passes
17
+ * this schema so parsing stays identical across the upgrade.
18
+ */
19
+ export const YAML_SCHEMA = CORE_SCHEMA.withTags(
20
+ timestampTag,
21
+ mergeTag,
22
+ binaryTag,
23
+ omapTag,
24
+ pairsTag,
25
+ setTag
26
+ );
@@ -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
+ };
@@ -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("{");