blume 0.0.0 → 0.1.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 (330) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +12696 -0
  3. package/dist/cli/index.js.map +145 -0
  4. package/dist/types/core/bridge.d.ts +24 -0
  5. package/dist/types/core/config.d.ts +35 -0
  6. package/dist/types/core/data.d.ts +129 -0
  7. package/dist/types/core/define-components.d.ts +27 -0
  8. package/dist/types/core/define-meta.d.ts +16 -0
  9. package/dist/types/core/deployment-env.d.ts +9 -0
  10. package/dist/types/core/diagnostics.d.ts +17 -0
  11. package/dist/types/core/i18n-ui.d.ts +500 -0
  12. package/dist/types/core/load-module.d.ts +7 -0
  13. package/dist/types/core/package-root.d.ts +17 -0
  14. package/dist/types/core/project.d.ts +9 -0
  15. package/dist/types/core/schema.d.ts +3451 -0
  16. package/dist/types/core/sources/types.d.ts +107 -0
  17. package/dist/types/core/types.d.ts +245 -0
  18. package/dist/types/core/ui-packs/ar.d.ts +3 -0
  19. package/dist/types/core/ui-packs/bg.d.ts +3 -0
  20. package/dist/types/core/ui-packs/bn.d.ts +3 -0
  21. package/dist/types/core/ui-packs/ca.d.ts +3 -0
  22. package/dist/types/core/ui-packs/cs.d.ts +3 -0
  23. package/dist/types/core/ui-packs/da.d.ts +3 -0
  24. package/dist/types/core/ui-packs/de.d.ts +3 -0
  25. package/dist/types/core/ui-packs/el.d.ts +3 -0
  26. package/dist/types/core/ui-packs/es.d.ts +3 -0
  27. package/dist/types/core/ui-packs/fa.d.ts +3 -0
  28. package/dist/types/core/ui-packs/fi.d.ts +3 -0
  29. package/dist/types/core/ui-packs/fr.d.ts +3 -0
  30. package/dist/types/core/ui-packs/he.d.ts +3 -0
  31. package/dist/types/core/ui-packs/hi.d.ts +3 -0
  32. package/dist/types/core/ui-packs/hr.d.ts +3 -0
  33. package/dist/types/core/ui-packs/hu.d.ts +3 -0
  34. package/dist/types/core/ui-packs/id.d.ts +3 -0
  35. package/dist/types/core/ui-packs/index.d.ts +13 -0
  36. package/dist/types/core/ui-packs/it.d.ts +3 -0
  37. package/dist/types/core/ui-packs/ja.d.ts +3 -0
  38. package/dist/types/core/ui-packs/ko.d.ts +3 -0
  39. package/dist/types/core/ui-packs/nl.d.ts +3 -0
  40. package/dist/types/core/ui-packs/no.d.ts +3 -0
  41. package/dist/types/core/ui-packs/pl.d.ts +3 -0
  42. package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
  43. package/dist/types/core/ui-packs/pt.d.ts +3 -0
  44. package/dist/types/core/ui-packs/ro.d.ts +3 -0
  45. package/dist/types/core/ui-packs/ru.d.ts +3 -0
  46. package/dist/types/core/ui-packs/sk.d.ts +3 -0
  47. package/dist/types/core/ui-packs/sr.d.ts +3 -0
  48. package/dist/types/core/ui-packs/sv.d.ts +3 -0
  49. package/dist/types/core/ui-packs/th.d.ts +3 -0
  50. package/dist/types/core/ui-packs/tr.d.ts +3 -0
  51. package/dist/types/core/ui-packs/uk.d.ts +3 -0
  52. package/dist/types/core/ui-packs/vi.d.ts +3 -0
  53. package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
  54. package/dist/types/core/ui-packs/zh.d.ts +3 -0
  55. package/dist/types/core/version.d.ts +8 -0
  56. package/dist/types/index.d.ts +10 -0
  57. package/dist/types/migrate/mintlify/config.d.ts +2 -0
  58. package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
  59. package/dist/types/theme/fonts.d.ts +163 -0
  60. package/docs/01-quickstart.mdx +99 -0
  61. package/docs/02-deployment.mdx +129 -0
  62. package/docs/advanced/api-reference.mdx +114 -0
  63. package/docs/advanced/blog.mdx +121 -0
  64. package/docs/advanced/changelog.mdx +113 -0
  65. package/docs/advanced/custom-pages.mdx +268 -0
  66. package/docs/advanced/meta.ts +7 -0
  67. package/docs/changelog/v0-1-0.mdx +12 -0
  68. package/docs/changelog/v0-2-0.mdx +16 -0
  69. package/docs/configuration/ai.mdx +228 -0
  70. package/docs/configuration/analytics.mdx +98 -0
  71. package/docs/configuration/customization.mdx +91 -0
  72. package/docs/configuration/export.mdx +70 -0
  73. package/docs/configuration/index.mdx +290 -0
  74. package/docs/configuration/meta.ts +15 -0
  75. package/docs/configuration/search.mdx +172 -0
  76. package/docs/configuration/seo.mdx +196 -0
  77. package/docs/configuration/theming.mdx +178 -0
  78. package/docs/content/components.mdx +651 -0
  79. package/docs/content/i18n.mdx +205 -0
  80. package/docs/content/index.mdx +161 -0
  81. package/docs/content/islands.mdx +94 -0
  82. package/docs/content/meta.mdx +119 -0
  83. package/docs/content/meta.ts +15 -0
  84. package/docs/content/navigation.mdx +168 -0
  85. package/docs/content/sources.mdx +216 -0
  86. package/docs/content/syntax.mdx +445 -0
  87. package/docs/index.mdx +112 -0
  88. package/docs/reference/cli.mdx +43 -0
  89. package/docs/reference/frontmatter.mdx +74 -0
  90. package/docs/reference/meta.ts +7 -0
  91. package/package.json +150 -6
  92. package/src/ai/ask.ts +93 -0
  93. package/src/ai/llms.ts +64 -0
  94. package/src/ai/markdown.ts +31 -0
  95. package/src/ai/mcp/data.ts +74 -0
  96. package/src/ai/mcp/discovery.ts +49 -0
  97. package/src/ai/mcp/server.ts +225 -0
  98. package/src/ai/mcp/tools.ts +47 -0
  99. package/src/assets/icon.png +0 -0
  100. package/src/astro/examples.ts +104 -0
  101. package/src/astro/generate.ts +1006 -0
  102. package/src/astro/index.ts +4 -0
  103. package/src/astro/integration.ts +74 -0
  104. package/src/astro/islands.ts +131 -0
  105. package/src/astro/markdown-negotiation.ts +68 -0
  106. package/src/astro/pages.ts +87 -0
  107. package/src/astro/templates.ts +1326 -0
  108. package/src/cli/commands/add.ts +81 -0
  109. package/src/cli/commands/build.ts +103 -0
  110. package/src/cli/commands/dev.ts +108 -0
  111. package/src/cli/commands/doctor.ts +74 -0
  112. package/src/cli/commands/eject.ts +57 -0
  113. package/src/cli/commands/init.ts +98 -0
  114. package/src/cli/commands/migrate.ts +39 -0
  115. package/src/cli/commands/preview.ts +39 -0
  116. package/src/cli/commands/sync.ts +52 -0
  117. package/src/cli/commands/validate.ts +61 -0
  118. package/src/cli/index.ts +35 -0
  119. package/src/cli/log.ts +37 -0
  120. package/src/cli/prepare.ts +80 -0
  121. package/src/components/Icon.astro +99 -0
  122. package/src/components/content/Accordion.astro +8 -0
  123. package/src/components/content/AccordionItem.astro +121 -0
  124. package/src/components/content/AutoTypeTable.astro +51 -0
  125. package/src/components/content/Badge.astro +124 -0
  126. package/src/components/content/Callout.astro +73 -0
  127. package/src/components/content/Card.astro +104 -0
  128. package/src/components/content/CardGroup.astro +14 -0
  129. package/src/components/content/CodeBlock.astro +28 -0
  130. package/src/components/content/CodeGroup.astro +13 -0
  131. package/src/components/content/Color.astro +15 -0
  132. package/src/components/content/ColorItem.astro +87 -0
  133. package/src/components/content/ColorRow.astro +10 -0
  134. package/src/components/content/Column.astro +6 -0
  135. package/src/components/content/Columns.astro +9 -0
  136. package/src/components/content/Component.astro +65 -0
  137. package/src/components/content/Diff.astro +44 -0
  138. package/src/components/content/Expandable.astro +11 -0
  139. package/src/components/content/FileTree.astro +8 -0
  140. package/src/components/content/Frame.astro +70 -0
  141. package/src/components/content/GithubInfo.astro +110 -0
  142. package/src/components/content/Math.astro +24 -0
  143. package/src/components/content/Panel.astro +20 -0
  144. package/src/components/content/Prompt.astro +129 -0
  145. package/src/components/content/Step.astro +34 -0
  146. package/src/components/content/Steps.astro +20 -0
  147. package/src/components/content/Tab.astro +46 -0
  148. package/src/components/content/Tabs.astro +273 -0
  149. package/src/components/content/Tile.astro +42 -0
  150. package/src/components/content/Tooltip.astro +68 -0
  151. package/src/components/content/Tree.astro +300 -0
  152. package/src/components/content/TreeFile.astro +15 -0
  153. package/src/components/content/TreeFolder.astro +62 -0
  154. package/src/components/content/TypeTable.astro +106 -0
  155. package/src/components/content/Update.astro +66 -0
  156. package/src/components/content/Visibility.astro +12 -0
  157. package/src/components/content/Warning.astro +9 -0
  158. package/src/components/content/auto-type-table.ts +141 -0
  159. package/src/components/content/diff.ts +95 -0
  160. package/src/components/content/github-info.ts +79 -0
  161. package/src/components/content/mermaid-element.ts +68 -0
  162. package/src/components/github-mark.ts +9 -0
  163. package/src/components/index.ts +14 -0
  164. package/src/components/islands/AskAI.astro +12 -0
  165. package/src/components/islands/ask-ai.tsx +156 -0
  166. package/src/components/layout/Analytics.astro +63 -0
  167. package/src/components/layout/Banner.astro +50 -0
  168. package/src/components/layout/Breadcrumbs.astro +31 -0
  169. package/src/components/layout/Favicon.astro +22 -0
  170. package/src/components/layout/Fonts.astro +14 -0
  171. package/src/components/layout/Header.astro +188 -0
  172. package/src/components/layout/LanguageSwitcher.astro +56 -0
  173. package/src/components/layout/NavTree.astro +462 -0
  174. package/src/components/layout/PageActions.astro +438 -0
  175. package/src/components/layout/PageFeedback.astro +58 -0
  176. package/src/components/layout/PageLayout.astro +173 -0
  177. package/src/components/layout/Pagination.astro +56 -0
  178. package/src/components/layout/ReferenceLayout.astro +107 -0
  179. package/src/components/layout/RootLayout.astro +537 -0
  180. package/src/components/layout/Search.astro +608 -0
  181. package/src/components/layout/TableOfContents.astro +68 -0
  182. package/src/components/layout/analytics-client.ts +38 -0
  183. package/src/components/layout/head-scripts.ts +19 -0
  184. package/src/components/layout/nav-utils.ts +87 -0
  185. package/src/components/layout/overrides.ts +32 -0
  186. package/src/components/layout/search/algolia.ts +43 -0
  187. package/src/components/layout/search/endpoint.ts +22 -0
  188. package/src/components/layout/search/flexsearch.ts +52 -0
  189. package/src/components/layout/search/orama-cloud.ts +41 -0
  190. package/src/components/layout/search/orama.ts +26 -0
  191. package/src/components/layout/search/pagefind.ts +43 -0
  192. package/src/components/layout/search/types.ts +163 -0
  193. package/src/components/layout/search/typesense.ts +60 -0
  194. package/src/components/layout/toc-element.ts +108 -0
  195. package/src/core/bridge.ts +92 -0
  196. package/src/core/config.ts +112 -0
  197. package/src/core/content.ts +50 -0
  198. package/src/core/data.ts +128 -0
  199. package/src/core/define-components.ts +34 -0
  200. package/src/core/define-meta.ts +20 -0
  201. package/src/core/deployment-env.ts +73 -0
  202. package/src/core/diagnostics.ts +104 -0
  203. package/src/core/frontmatter.ts +43 -0
  204. package/src/core/graph.ts +128 -0
  205. package/src/core/i18n-ui.ts +171 -0
  206. package/src/core/i18n.ts +169 -0
  207. package/src/core/last-modified.ts +88 -0
  208. package/src/core/links.ts +352 -0
  209. package/src/core/load-module.ts +15 -0
  210. package/src/core/manifest.ts +126 -0
  211. package/src/core/meta.ts +97 -0
  212. package/src/core/navigation.ts +392 -0
  213. package/src/core/package-root.ts +37 -0
  214. package/src/core/project-graph.ts +153 -0
  215. package/src/core/project.ts +56 -0
  216. package/src/core/schema.ts +1057 -0
  217. package/src/core/server-features.ts +23 -0
  218. package/src/core/sources/assets.ts +77 -0
  219. package/src/core/sources/cache.ts +122 -0
  220. package/src/core/sources/filesystem.ts +99 -0
  221. package/src/core/sources/mdx-remote.ts +215 -0
  222. package/src/core/sources/mintlify.ts +161 -0
  223. package/src/core/sources/normalize.ts +243 -0
  224. package/src/core/sources/notion.ts +440 -0
  225. package/src/core/sources/portable-text.ts +143 -0
  226. package/src/core/sources/read.ts +36 -0
  227. package/src/core/sources/resolve.ts +158 -0
  228. package/src/core/sources/sanity.ts +218 -0
  229. package/src/core/sources/types.ts +105 -0
  230. package/src/core/tsconfig-aliases.ts +201 -0
  231. package/src/core/types.ts +261 -0
  232. package/src/core/ui-packs/ar.ts +47 -0
  233. package/src/core/ui-packs/bg.ts +47 -0
  234. package/src/core/ui-packs/bn.ts +47 -0
  235. package/src/core/ui-packs/ca.ts +47 -0
  236. package/src/core/ui-packs/cs.ts +47 -0
  237. package/src/core/ui-packs/da.ts +47 -0
  238. package/src/core/ui-packs/de.ts +47 -0
  239. package/src/core/ui-packs/el.ts +47 -0
  240. package/src/core/ui-packs/es.ts +47 -0
  241. package/src/core/ui-packs/fa.ts +47 -0
  242. package/src/core/ui-packs/fi.ts +47 -0
  243. package/src/core/ui-packs/fr.ts +47 -0
  244. package/src/core/ui-packs/he.ts +47 -0
  245. package/src/core/ui-packs/hi.ts +47 -0
  246. package/src/core/ui-packs/hr.ts +47 -0
  247. package/src/core/ui-packs/hu.ts +47 -0
  248. package/src/core/ui-packs/id.ts +47 -0
  249. package/src/core/ui-packs/index.ts +87 -0
  250. package/src/core/ui-packs/it.ts +47 -0
  251. package/src/core/ui-packs/ja.ts +47 -0
  252. package/src/core/ui-packs/ko.ts +47 -0
  253. package/src/core/ui-packs/nl.ts +47 -0
  254. package/src/core/ui-packs/no.ts +47 -0
  255. package/src/core/ui-packs/pl.ts +47 -0
  256. package/src/core/ui-packs/pt-br.ts +47 -0
  257. package/src/core/ui-packs/pt.ts +47 -0
  258. package/src/core/ui-packs/ro.ts +47 -0
  259. package/src/core/ui-packs/ru.ts +47 -0
  260. package/src/core/ui-packs/sk.ts +47 -0
  261. package/src/core/ui-packs/sr.ts +47 -0
  262. package/src/core/ui-packs/sv.ts +47 -0
  263. package/src/core/ui-packs/th.ts +47 -0
  264. package/src/core/ui-packs/tr.ts +47 -0
  265. package/src/core/ui-packs/uk.ts +47 -0
  266. package/src/core/ui-packs/vi.ts +47 -0
  267. package/src/core/ui-packs/zh-tw.ts +47 -0
  268. package/src/core/ui-packs/zh.ts +47 -0
  269. package/src/core/version.ts +23 -0
  270. package/src/deploy/robots.ts +20 -0
  271. package/src/deploy/rss.ts +128 -0
  272. package/src/deploy/sitemap.ts +28 -0
  273. package/src/index.ts +39 -0
  274. package/src/markdown/code-title.ts +71 -0
  275. package/src/markdown/directives.ts +83 -0
  276. package/src/markdown/heading-anchors.ts +137 -0
  277. package/src/markdown/index.ts +228 -0
  278. package/src/markdown/inline-code.ts +108 -0
  279. package/src/markdown/language-icon.ts +172 -0
  280. package/src/markdown/math.ts +32 -0
  281. package/src/markdown/mdast.ts +48 -0
  282. package/src/markdown/mermaid.ts +37 -0
  283. package/src/markdown/package-commands.ts +159 -0
  284. package/src/markdown/package-install.ts +40 -0
  285. package/src/migrate/fumadocs/config.ts +155 -0
  286. package/src/migrate/fumadocs/content.ts +365 -0
  287. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  288. package/src/migrate/fumadocs/groups.ts +230 -0
  289. package/src/migrate/fumadocs/index.ts +337 -0
  290. package/src/migrate/fumadocs/meta.ts +244 -0
  291. package/src/migrate/migrate.ts +53 -0
  292. package/src/migrate/mintlify/config.ts +1040 -0
  293. package/src/migrate/mintlify/content.ts +98 -0
  294. package/src/migrate/mintlify/frontmatter.ts +126 -0
  295. package/src/migrate/mintlify/i18n.ts +51 -0
  296. package/src/migrate/mintlify/icons.ts +128 -0
  297. package/src/migrate/mintlify/index.ts +266 -0
  298. package/src/migrate/mintlify/snippets.ts +306 -0
  299. package/src/migrate/mintlify/transform.ts +80 -0
  300. package/src/migrate/nextra/content.ts +46 -0
  301. package/src/migrate/nextra/frontmatter.ts +40 -0
  302. package/src/migrate/nextra/index.ts +374 -0
  303. package/src/migrate/nextra/meta.ts +266 -0
  304. package/src/migrate/shared.ts +720 -0
  305. package/src/migrate/starlight/config.ts +459 -0
  306. package/src/migrate/starlight/content.ts +78 -0
  307. package/src/migrate/starlight/frontmatter.ts +111 -0
  308. package/src/migrate/starlight/i18n.ts +54 -0
  309. package/src/migrate/starlight/index.ts +131 -0
  310. package/src/og/card.ts +92 -0
  311. package/src/og/index.ts +2 -0
  312. package/src/openapi/scalar.ts +246 -0
  313. package/src/registry/eject.ts +310 -0
  314. package/src/registry/registry.ts +100 -0
  315. package/src/registry/rewrite-imports.ts +39 -0
  316. package/src/runtime/index.ts +14 -0
  317. package/src/search/build.ts +23 -0
  318. package/src/search/documents.ts +164 -0
  319. package/src/search/orama-index.ts +66 -0
  320. package/src/search/providers.ts +91 -0
  321. package/src/search/sync/algolia.ts +30 -0
  322. package/src/search/sync/index.ts +50 -0
  323. package/src/search/sync/orama-cloud.ts +40 -0
  324. package/src/search/sync/typesense.ts +65 -0
  325. package/src/seo/jsonld.ts +113 -0
  326. package/src/theme/entry.ts +637 -0
  327. package/src/theme/fonts.ts +198 -0
  328. package/src/theme/icons.ts +184 -0
  329. package/src/theme/palette.ts +143 -0
  330. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,243 @@
1
+ import GithubSlugger from "github-slugger";
2
+ import { extname } from "pathe";
3
+
4
+ import { diagnosticsFromZod } from "../diagnostics.ts";
5
+ import { localePlacement, localizeRoute } from "../i18n.ts";
6
+ import { pageMetaSchema } from "../schema.ts";
7
+ import type { PageMeta } from "../schema.ts";
8
+ import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
9
+ import type { NormalizeContext, SourceEntry } from "./types.ts";
10
+
11
+ const NUMERIC_PREFIX = /^\d+[-_.]/u;
12
+ const GROUP_FOLDER = /^\((?<label>.+)\)$/u;
13
+ const WORD_SPLIT = /[-_]/u;
14
+
15
+ /** Strip a leading numeric ordering prefix (`01-intro` -> `intro`). */
16
+ const stripNumericPrefix = (segment: string): string =>
17
+ segment.replace(NUMERIC_PREFIX, "");
18
+
19
+ /** Detect a group folder `(name)` and return its label, else null. */
20
+ const groupLabel = (segment: string): string | null =>
21
+ segment.match(GROUP_FOLDER)?.groups?.label ?? null;
22
+
23
+ /**
24
+ * Slugify a content/route slug (Sanity, Notion, frontmatter `slug`). Heading
25
+ * anchor ids are *not* slugged here — they use a `github-slugger` in
26
+ * {@link extractHeadings}, matching the renderer (see `markdown/heading-anchors`)
27
+ * so `blume validate` checks anchors against the exact rendered heading ids.
28
+ */
29
+ export const slugify = (text: string): string =>
30
+ text
31
+ .toLowerCase()
32
+ .trim()
33
+ .replaceAll(/[^\w\s-]/gu, "")
34
+ .replaceAll(/[\s_]+/gu, "-")
35
+ .replaceAll(/-+/gu, "-")
36
+ .replaceAll(/^-|-$/gu, "");
37
+
38
+ /** Title-case a slug segment for display. */
39
+ const titleCase = (value: string): string =>
40
+ value
41
+ .split(WORD_SPLIT)
42
+ .filter(Boolean)
43
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
44
+ .join(" ");
45
+
46
+ /** Convert a content-root-relative path into URL + nav metadata. */
47
+ const mapRoute = (
48
+ relativePath: string
49
+ ): { segments: string[]; groups: string[]; route: string } => {
50
+ const withoutExt = relativePath.slice(
51
+ 0,
52
+ relativePath.length - extname(relativePath).length
53
+ );
54
+ const rawParts = withoutExt.split("/");
55
+
56
+ const segments: string[] = [];
57
+ const groups: string[] = [];
58
+
59
+ for (const part of rawParts) {
60
+ const group = groupLabel(part);
61
+ if (group !== null) {
62
+ groups.push(group);
63
+ continue;
64
+ }
65
+ const clean = stripNumericPrefix(part);
66
+ if (clean === "index") {
67
+ continue;
68
+ }
69
+ segments.push(clean);
70
+ }
71
+
72
+ const route = segments.length === 0 ? "/" : `/${segments.join("/")}`;
73
+ return { groups, route, segments };
74
+ };
75
+
76
+ const CODE_FENCE = /^```/u;
77
+ const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)\s*#*$/u;
78
+
79
+ /**
80
+ * Extract ATX headings from a markdown body, skipping fenced code blocks. Each
81
+ * heading's anchor slug comes from a per-document `github-slugger` — the exact
82
+ * slugger the renderer uses (`markdown/heading-anchors`) — advanced over every
83
+ * `#`–`######` in document order. Matching it (rather than a hand-rolled
84
+ * slugify) keeps the manifest's anchor ids identical to the rendered ones, so
85
+ * `blume validate` stops false-flagging links like `#the-read--write-fallback`
86
+ * (a hand slugify collapses `--`; github-slugger keeps it) and resolves repeated
87
+ * headings the same way (`setup`, `setup-1`).
88
+ */
89
+ export const extractHeadings = (body: string): Heading[] => {
90
+ const headings: Heading[] = [];
91
+ const slugger = new GithubSlugger();
92
+ let inFence = false;
93
+
94
+ for (const line of body.split("\n")) {
95
+ if (CODE_FENCE.test(line.trimStart())) {
96
+ inFence = !inFence;
97
+ continue;
98
+ }
99
+ if (inFence) {
100
+ continue;
101
+ }
102
+ const match = line.match(ATX_HEADING);
103
+ if (match?.groups) {
104
+ const depth = match.groups.hashes?.length ?? 1;
105
+ const text = (match.groups.text ?? "").trim();
106
+ headings.push({ depth, slug: slugger.slug(text), text });
107
+ }
108
+ }
109
+
110
+ return headings;
111
+ };
112
+
113
+ const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
114
+
115
+ /**
116
+ * Extract link targets from a markdown body for later validation, recording the
117
+ * 1-based line/column of each target. Skips fenced code blocks.
118
+ */
119
+ export const extractLinks = (body: string): PageLink[] => {
120
+ const links: PageLink[] = [];
121
+ let inFence = false;
122
+ let lineNumber = 0;
123
+
124
+ for (const line of body.split("\n")) {
125
+ lineNumber += 1;
126
+ if (CODE_FENCE.test(line.trimStart())) {
127
+ inFence = !inFence;
128
+ continue;
129
+ }
130
+ if (inFence) {
131
+ continue;
132
+ }
133
+ for (const match of line.matchAll(MD_LINK)) {
134
+ const target = match.groups?.target;
135
+ if (target === undefined || match.index === undefined) {
136
+ continue;
137
+ }
138
+ links.push({
139
+ column: line.indexOf(target, match.index) + 1,
140
+ line: lineNumber,
141
+ target,
142
+ });
143
+ }
144
+ }
145
+
146
+ return links;
147
+ };
148
+
149
+ const deriveTitle = (
150
+ meta: PageMeta,
151
+ headings: Heading[],
152
+ id: string
153
+ ): string => {
154
+ if (meta.title) {
155
+ return meta.title;
156
+ }
157
+ const firstHeading = headings.find((h) => h.depth === 1) ?? headings[0];
158
+ if (firstHeading) {
159
+ return firstHeading.text;
160
+ }
161
+ const base = id.split("/").pop() ?? id;
162
+ return titleCase(stripNumericPrefix(base.replace(extname(base), "")));
163
+ };
164
+
165
+ const withPrefix = (prefix: string | undefined, path: string): string =>
166
+ prefix ? `${prefix}/${path}` : path;
167
+
168
+ /**
169
+ * Normalize one source entry into per-locale `PageRecord`s. This is the single
170
+ * funnel every adapter's entries pass through, so route mapping, heading/link
171
+ * extraction, and meta validation are identical regardless of origin.
172
+ */
173
+ export const normalizeEntry = (
174
+ entry: SourceEntry,
175
+ ctx: NormalizeContext
176
+ ): { pages: PageRecord[]; diagnostics: Diagnostic[] } => {
177
+ const { format } = entry.body;
178
+ const ext = format === "mdx" ? ".mdx" : ".md";
179
+
180
+ const result = pageMetaSchema.safeParse(entry.data);
181
+ if (!result.success) {
182
+ return {
183
+ diagnostics: diagnosticsFromZod(result.error, {
184
+ code: "BLUME_FRONTMATTER_INVALID",
185
+ file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
186
+ }),
187
+ pages: [],
188
+ };
189
+ }
190
+
191
+ const meta = result.data;
192
+
193
+ // Locale and the locale-stripped nav path come from the entry's ref (a leading
194
+ // dir, or a filename suffix under the `dot` parser), not the slug — the slug is
195
+ // the logical, locale-agnostic path within a locale. A shared `$` file maps to
196
+ // every locale. Remote/CMS sources without i18n placement map to one locale.
197
+ const { i18n } = ctx;
198
+ const { navPath: rawNavPath, locales } = i18n
199
+ ? localePlacement(entry.ref, ext, i18n)
200
+ : { locales: [""], navPath: entry.ref };
201
+
202
+ const navPath = withPrefix(ctx.source.prefix, rawNavPath);
203
+ const routeInput = withPrefix(
204
+ ctx.source.prefix,
205
+ meta.slug ? `${meta.slug}${ext}` : rawNavPath
206
+ );
207
+
208
+ const { segments, groups, route: logicalRoute } = mapRoute(routeInput);
209
+ const headings = extractHeadings(entry.body.text);
210
+ const { staged } = ctx.source;
211
+
212
+ const base = {
213
+ body: staged ? { format, text: entry.raw ?? entry.body.text } : undefined,
214
+ collection: staged ? "staged" : undefined,
215
+ contentType: meta.type ?? ctx.defaultType,
216
+ description: meta.description,
217
+ editUrl: entry.editUrl,
218
+ entryId: staged ? `${ctx.source.name}/${entry.ref}` : undefined,
219
+ format,
220
+ groups,
221
+ headings,
222
+ id: `${ctx.source.name}:${entry.ref}`,
223
+ lastModified: meta.lastModified ?? entry.lastModified,
224
+ links: extractLinks(entry.body.text),
225
+ meta,
226
+ navPath,
227
+ segments,
228
+ source: { name: ctx.source.name, ref: entry.ref },
229
+ sourcePath: entry.sourcePath,
230
+ title: deriveTitle(meta, headings, navPath),
231
+ translationKey: logicalRoute,
232
+ } satisfies Omit<PageRecord, "locale" | "route">;
233
+
234
+ // One record per locale this entry maps to (one normally; every locale for a
235
+ // shared `$` file). All share the same id, source ref, and translation key.
236
+ const pages = locales.map((locale) => ({
237
+ ...base,
238
+ locale,
239
+ route: i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute,
240
+ }));
241
+
242
+ return { diagnostics: [], pages };
243
+ };
@@ -0,0 +1,440 @@
1
+ import { join } from "pathe";
2
+
3
+ import { BlumeError } from "../diagnostics.ts";
4
+ import matter from "../frontmatter.ts";
5
+ import type { Diagnostic } from "../types.ts";
6
+ import { materializeAssets } from "./assets.ts";
7
+ import {
8
+ hashText,
9
+ loadWithCache,
10
+ pollingWatch,
11
+ snapshotCache,
12
+ } from "./cache.ts";
13
+ import { slugify } from "./normalize.ts";
14
+ import type {
15
+ ContentSource,
16
+ SourceContext,
17
+ SourceEntry,
18
+ SourceLoadResult,
19
+ } from "./types.ts";
20
+
21
+ interface NotionRichText {
22
+ plain_text: string;
23
+ href?: string | null;
24
+ annotations?: {
25
+ bold?: boolean;
26
+ italic?: boolean;
27
+ code?: boolean;
28
+ strikethrough?: boolean;
29
+ };
30
+ }
31
+
32
+ interface NotionProperty {
33
+ type: string;
34
+ title?: NotionRichText[];
35
+ rich_text?: NotionRichText[];
36
+ select?: { name: string } | null;
37
+ status?: { name: string } | null;
38
+ number?: number | null;
39
+ }
40
+
41
+ interface NotionPage {
42
+ id: string;
43
+ properties: Record<string, NotionProperty>;
44
+ last_edited_time?: string;
45
+ }
46
+
47
+ interface NotionBlock {
48
+ id: string;
49
+ type: string;
50
+ has_children?: boolean;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ interface NotionList<T> {
55
+ results: T[];
56
+ has_more: boolean;
57
+ next_cursor: string | null;
58
+ }
59
+
60
+ /** The slice of `@notionhq/client` this adapter relies on (so it's mockable). */
61
+ export interface NotionClientLike {
62
+ databases: {
63
+ query: (args: {
64
+ database_id: string;
65
+ start_cursor?: string;
66
+ }) => Promise<NotionList<NotionPage>>;
67
+ };
68
+ blocks: {
69
+ children: {
70
+ list: (args: {
71
+ block_id: string;
72
+ start_cursor?: string;
73
+ }) => Promise<NotionList<NotionBlock>>;
74
+ };
75
+ };
76
+ }
77
+
78
+ /** Notion property names mapped onto Blume meta. */
79
+ export interface NotionPropertyMap {
80
+ /** Title property name; defaults to the database's `title`-typed property. */
81
+ title?: string;
82
+ /** Description property (rich_text); default `Description`. */
83
+ description?: string;
84
+ /** Slug property (rich_text); default `Slug`, else the slugified title. */
85
+ slug?: string;
86
+ /** Status property (select/status); default `Status`. */
87
+ status?: string;
88
+ /** Sidebar order property (number); default `Order`. */
89
+ order?: string;
90
+ }
91
+
92
+ export interface NotionSourceOptions {
93
+ name: string;
94
+ prefix?: string;
95
+ /** The Notion database id. */
96
+ database: string;
97
+ /** Integration token; defaults to `NOTION_TOKEN`. */
98
+ token?: string;
99
+ properties?: NotionPropertyMap;
100
+ /**
101
+ * When set, the `Status` property is treated as a publish signal: any value
102
+ * other than this maps to `draft: true`. Omit to import every page regardless
103
+ * of status (the safe default for databases that use Status for an editorial
104
+ * workflow rather than publishing).
105
+ */
106
+ publishedValue?: string;
107
+ /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
108
+ pollInterval?: number;
109
+ /** Injected for tests; otherwise built from `@notionhq/client`. */
110
+ client?: NotionClientLike;
111
+ /** Injected for tests; used to download images. */
112
+ fetchImpl?: typeof fetch;
113
+ }
114
+
115
+ const richToMarkdown = (rich: NotionRichText[] = []): string =>
116
+ rich
117
+ .map((node) => {
118
+ let text = node.plain_text;
119
+ if (node.annotations?.code) {
120
+ text = `\`${text}\``;
121
+ }
122
+ if (node.annotations?.bold) {
123
+ text = `**${text}**`;
124
+ }
125
+ if (node.annotations?.italic) {
126
+ text = `*${text}*`;
127
+ }
128
+ if (node.annotations?.strikethrough) {
129
+ text = `~~${text}~~`;
130
+ }
131
+ return node.href ? `[${text}](${node.href})` : text;
132
+ })
133
+ .join("");
134
+
135
+ const blockField = (block: NotionBlock): NotionRichText[] =>
136
+ ((block[block.type] as { rich_text?: NotionRichText[] })?.rich_text ??
137
+ []) as NotionRichText[];
138
+
139
+ /** Paginate a Notion list endpoint via recursion (no await-in-loop). */
140
+ const collectAll = async <T>(
141
+ page: (cursor?: string) => Promise<NotionList<T>>,
142
+ cursor?: string,
143
+ acc: T[] = []
144
+ ): Promise<T[]> => {
145
+ const res = await page(cursor);
146
+ const all = [...acc, ...res.results];
147
+ return res.has_more && res.next_cursor
148
+ ? collectAll(page, res.next_cursor, all)
149
+ : all;
150
+ };
151
+
152
+ const LIST_BLOCKS = new Set([
153
+ "bulleted_list_item",
154
+ "numbered_list_item",
155
+ "to_do",
156
+ ]);
157
+
158
+ /** Whether a block is a list item (so consecutive ones render as a tight list). */
159
+ const isListItem = (block: NotionBlock | undefined): boolean =>
160
+ block !== undefined && LIST_BLOCKS.has(block.type);
161
+
162
+ /** Render a leaf (non-container) block to Markdown, or null for containers. */
163
+ const renderLeaf = (block: NotionBlock): string | null => {
164
+ const data = (block[block.type] ?? {}) as Record<string, unknown>;
165
+ const text = richToMarkdown(blockField(block));
166
+ switch (block.type) {
167
+ case "paragraph": {
168
+ return text;
169
+ }
170
+ case "heading_1": {
171
+ return `# ${text}`;
172
+ }
173
+ case "heading_2": {
174
+ return `## ${text}`;
175
+ }
176
+ case "heading_3": {
177
+ return `### ${text}`;
178
+ }
179
+ case "bulleted_list_item": {
180
+ return `- ${text}`;
181
+ }
182
+ case "numbered_list_item": {
183
+ return `1. ${text}`;
184
+ }
185
+ case "to_do": {
186
+ return `- [${data.checked ? "x" : " "}] ${text}`;
187
+ }
188
+ case "quote": {
189
+ return `> ${text}`;
190
+ }
191
+ case "divider": {
192
+ return "---";
193
+ }
194
+ case "code": {
195
+ return `\`\`\`${(data.language as string) ?? ""}\n${text}\n\`\`\``;
196
+ }
197
+ case "image": {
198
+ const media = data as {
199
+ external?: { url: string };
200
+ file?: { url: string };
201
+ caption?: NotionRichText[];
202
+ };
203
+ const url = media.external?.url ?? media.file?.url;
204
+ return url ? `![${richToMarkdown(media.caption)}](${url})` : "";
205
+ }
206
+ default: {
207
+ return null;
208
+ }
209
+ }
210
+ };
211
+
212
+ /**
213
+ * Notion content source. Maps a database to a collection: each page becomes an
214
+ * entry, its properties become frontmatter, and its block tree is converted to
215
+ * MDX with Blume components. Images are materialized so signed URLs don't rot.
216
+ */
217
+ export const notionSource = (
218
+ options: NotionSourceOptions,
219
+ ctx?: SourceContext
220
+ ): ContentSource => {
221
+ const props = options.properties ?? {};
222
+ const cache = snapshotCache(
223
+ ctx?.cacheDir ?? join(".blume", "cache", options.name)
224
+ );
225
+ const assetsDir =
226
+ ctx?.assetsDir ?? join(".blume", "public", "blume-assets", options.name);
227
+ const assetsBaseUrl = ctx?.assetsBaseUrl ?? `/blume-assets/${options.name}`;
228
+ let snapshot = new Map<string, SourceEntry>();
229
+
230
+ const resolveClient = async (): Promise<NotionClientLike> => {
231
+ if (options.client) {
232
+ return options.client;
233
+ }
234
+ let Client: new (config: { auth?: string }) => NotionClientLike;
235
+ try {
236
+ ({ Client } = (await import("@notionhq/client")) as {
237
+ Client: new (config: { auth?: string }) => NotionClientLike;
238
+ });
239
+ } catch {
240
+ throw new BlumeError({
241
+ code: "BLUME_SOURCE_SDK_MISSING",
242
+ message: `Source "${options.name}" needs "@notionhq/client". Install it (e.g. \`npm install @notionhq/client\`).`,
243
+ severity: "error",
244
+ });
245
+ }
246
+ return new Client({ auth: options.token ?? process.env.NOTION_TOKEN });
247
+ };
248
+
249
+ const childrenOf = (
250
+ client: NotionClientLike,
251
+ blockId: string
252
+ ): Promise<NotionBlock[]> =>
253
+ collectAll((cursor) =>
254
+ client.blocks.children.list({ block_id: blockId, start_cursor: cursor })
255
+ );
256
+
257
+ // `render` is injected (rather than referenced) so this stays a forward-free
258
+ // definition; `renderBlocks` passes itself, the way `collectAll` recurses.
259
+ const renderContainer = async (
260
+ client: NotionClientLike,
261
+ block: NotionBlock,
262
+ render: (c: NotionClientLike, b: NotionBlock[]) => Promise<string>
263
+ ): Promise<string> => {
264
+ const children = async (target: NotionBlock): Promise<string> => {
265
+ if (!target.has_children) {
266
+ return "";
267
+ }
268
+ return render(client, await childrenOf(client, target.id));
269
+ };
270
+
271
+ if (block.type === "callout") {
272
+ const body = [richToMarkdown(blockField(block)), await children(block)]
273
+ .filter(Boolean)
274
+ .join("\n\n");
275
+ return `<Callout>\n${body}\n</Callout>`;
276
+ }
277
+ if (block.type === "toggle") {
278
+ const title = JSON.stringify(richToMarkdown(blockField(block)));
279
+ return `<Accordion>\n<AccordionItem title=${title}>\n${await children(block)}\n</AccordionItem>\n</Accordion>`;
280
+ }
281
+ if (block.type === "column_list") {
282
+ const cols = await childrenOf(client, block.id);
283
+ const rendered = await Promise.all(
284
+ cols.map(async (col) => `<Column>\n${await children(col)}\n</Column>`)
285
+ );
286
+ return `<Columns>\n${rendered.join("\n")}\n</Columns>`;
287
+ }
288
+ return `{/* unsupported Notion block: ${block.type} */}`;
289
+ };
290
+
291
+ const renderBlocks = async (
292
+ client: NotionClientLike,
293
+ blocks: NotionBlock[]
294
+ ): Promise<string> => {
295
+ const parts = await Promise.all(
296
+ blocks.map(
297
+ (block) =>
298
+ renderLeaf(block) ?? renderContainer(client, block, renderBlocks)
299
+ )
300
+ );
301
+ // Join with a blank line, except between consecutive list items, which stay
302
+ // tight so they render as a single list rather than separate loose ones.
303
+ const pairs = blocks
304
+ .map((block, i) => ({ block, text: parts[i] ?? "" }))
305
+ .filter((pair) => pair.text);
306
+ return pairs
307
+ .map((pair, i) => {
308
+ if (i === 0) {
309
+ return pair.text;
310
+ }
311
+ const tight = isListItem(pairs[i - 1]?.block) && isListItem(pair.block);
312
+ return `${tight ? "\n" : "\n\n"}${pair.text}`;
313
+ })
314
+ .join("");
315
+ };
316
+
317
+ const titleProperty = (page: NotionPage): NotionProperty | undefined => {
318
+ if (props.title) {
319
+ return page.properties[props.title];
320
+ }
321
+ return Object.values(page.properties).find((p) => p.type === "title");
322
+ };
323
+
324
+ const isDraft = (page: NotionPage): boolean => {
325
+ if (!options.publishedValue) {
326
+ return false;
327
+ }
328
+ const prop = page.properties[props.status ?? "Status"];
329
+ const status = prop?.status?.name ?? prop?.select?.name;
330
+ return Boolean(status && status !== options.publishedValue);
331
+ };
332
+
333
+ const orderOf = (page: NotionPage): number | undefined => {
334
+ const order = page.properties[props.order ?? "Order"]?.number;
335
+ return typeof order === "number" ? order : undefined;
336
+ };
337
+
338
+ const frontmatter = (
339
+ page: NotionPage
340
+ ): { data: Record<string, unknown>; slug: string } => {
341
+ const data: Record<string, unknown> = {};
342
+ const title = richToMarkdown(titleProperty(page)?.title);
343
+ if (title) {
344
+ data.title = title;
345
+ }
346
+ const description = richToMarkdown(
347
+ page.properties[props.description ?? "Description"]?.rich_text
348
+ );
349
+ if (description) {
350
+ data.description = description;
351
+ }
352
+ if (isDraft(page)) {
353
+ data.draft = true;
354
+ }
355
+ const order = orderOf(page);
356
+ if (order !== undefined) {
357
+ data.sidebar = { order };
358
+ }
359
+ const slugProp = richToMarkdown(
360
+ page.properties[props.slug ?? "Slug"]?.rich_text
361
+ );
362
+ const slug = slugify(slugProp || title) || page.id;
363
+ return { data, slug };
364
+ };
365
+
366
+ const toEntry = async (
367
+ client: NotionClientLike,
368
+ page: NotionPage
369
+ ): Promise<{ entry: SourceEntry; diagnostics: Diagnostic[] }> => {
370
+ const { data, slug } = frontmatter(page);
371
+ const mdx = await renderBlocks(client, await childrenOf(client, page.id));
372
+ const assets = await materializeAssets(mdx, {
373
+ assetsBaseUrl,
374
+ assetsDir,
375
+ fetchImpl: options.fetchImpl,
376
+ });
377
+ const raw = matter.stringify(assets.markdown, data);
378
+ return {
379
+ diagnostics: assets.diagnostics,
380
+ entry: {
381
+ body: { format: "mdx", text: assets.markdown },
382
+ data,
383
+ hash: hashText(raw),
384
+ lastModified: page.last_edited_time,
385
+ raw,
386
+ ref: `${slug}.mdx`,
387
+ },
388
+ };
389
+ };
390
+
391
+ const load = async (): Promise<SourceLoadResult> => {
392
+ const assetDiagnostics: Diagnostic[] = [];
393
+ const result = await loadWithCache(
394
+ options.name,
395
+ cache,
396
+ async () => {
397
+ const client = await resolveClient();
398
+ const pages = await collectAll((cursor) =>
399
+ client.databases.query({
400
+ database_id: options.database,
401
+ start_cursor: cursor,
402
+ })
403
+ );
404
+ const built = await Promise.all(
405
+ pages.map((page) => toEntry(client, page))
406
+ );
407
+ for (const item of built) {
408
+ assetDiagnostics.push(...item.diagnostics);
409
+ }
410
+ return built.map((item) => item.entry);
411
+ },
412
+ ctx?.refresh ?? true
413
+ );
414
+ snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
415
+ return {
416
+ diagnostics: [...result.diagnostics, ...assetDiagnostics],
417
+ entries: result.entries,
418
+ };
419
+ };
420
+
421
+ const read = async (ref: string): Promise<string> => {
422
+ const cached = snapshot.get(ref);
423
+ if (cached) {
424
+ return cached.raw ?? cached.body.text;
425
+ }
426
+ const all = await cache.read();
427
+ return all.find((e) => e.ref === ref)?.raw ?? "";
428
+ };
429
+
430
+ return {
431
+ load,
432
+ name: options.name,
433
+ prefix: options.prefix,
434
+ read,
435
+ staged: true,
436
+ watch: options.pollInterval
437
+ ? pollingWatch(load, options.pollInterval)
438
+ : undefined,
439
+ };
440
+ };