blume 0.0.0 → 0.1.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 (263) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +11989 -0
  3. package/dist/cli/index.js.map +141 -0
  4. package/docs/01-quickstart.mdx +99 -0
  5. package/docs/02-deployment.mdx +129 -0
  6. package/docs/advanced/api-reference.mdx +114 -0
  7. package/docs/advanced/blog.mdx +121 -0
  8. package/docs/advanced/changelog.mdx +113 -0
  9. package/docs/advanced/custom-pages.mdx +187 -0
  10. package/docs/advanced/meta.ts +7 -0
  11. package/docs/changelog/v0-1-0.mdx +12 -0
  12. package/docs/changelog/v0-2-0.mdx +16 -0
  13. package/docs/configuration/ai.mdx +228 -0
  14. package/docs/configuration/analytics.mdx +98 -0
  15. package/docs/configuration/customization.mdx +91 -0
  16. package/docs/configuration/export.mdx +70 -0
  17. package/docs/configuration/index.mdx +271 -0
  18. package/docs/configuration/meta.ts +15 -0
  19. package/docs/configuration/search.mdx +172 -0
  20. package/docs/configuration/seo.mdx +196 -0
  21. package/docs/configuration/theming.mdx +178 -0
  22. package/docs/content/components.mdx +565 -0
  23. package/docs/content/i18n.mdx +205 -0
  24. package/docs/content/index.mdx +161 -0
  25. package/docs/content/islands.mdx +94 -0
  26. package/docs/content/meta.mdx +119 -0
  27. package/docs/content/meta.ts +15 -0
  28. package/docs/content/navigation.mdx +168 -0
  29. package/docs/content/sources.mdx +216 -0
  30. package/docs/content/syntax.mdx +445 -0
  31. package/docs/index.mdx +112 -0
  32. package/docs/reference/cli.mdx +43 -0
  33. package/docs/reference/frontmatter.mdx +74 -0
  34. package/docs/reference/meta.ts +7 -0
  35. package/package.json +140 -6
  36. package/src/ai/ask.ts +93 -0
  37. package/src/ai/llms.ts +65 -0
  38. package/src/ai/markdown.ts +31 -0
  39. package/src/ai/mcp/data.ts +74 -0
  40. package/src/ai/mcp/discovery.ts +49 -0
  41. package/src/ai/mcp/server.ts +225 -0
  42. package/src/ai/mcp/tools.ts +47 -0
  43. package/src/assets/icon.png +0 -0
  44. package/src/astro/generate.ts +878 -0
  45. package/src/astro/index.ts +4 -0
  46. package/src/astro/integration.ts +74 -0
  47. package/src/astro/islands.ts +131 -0
  48. package/src/astro/markdown-negotiation.ts +68 -0
  49. package/src/astro/pages.ts +28 -0
  50. package/src/astro/templates.ts +1199 -0
  51. package/src/cli/commands/add.ts +81 -0
  52. package/src/cli/commands/build.ts +103 -0
  53. package/src/cli/commands/dev.ts +108 -0
  54. package/src/cli/commands/doctor.ts +74 -0
  55. package/src/cli/commands/eject.ts +57 -0
  56. package/src/cli/commands/init.ts +98 -0
  57. package/src/cli/commands/migrate.ts +39 -0
  58. package/src/cli/commands/preview.ts +39 -0
  59. package/src/cli/commands/sync.ts +52 -0
  60. package/src/cli/commands/validate.ts +60 -0
  61. package/src/cli/index.ts +35 -0
  62. package/src/cli/log.ts +37 -0
  63. package/src/cli/prepare.ts +80 -0
  64. package/src/components/Icon.astro +99 -0
  65. package/src/components/content/Accordion.astro +8 -0
  66. package/src/components/content/AccordionItem.astro +121 -0
  67. package/src/components/content/AutoTypeTable.astro +51 -0
  68. package/src/components/content/Badge.astro +124 -0
  69. package/src/components/content/Callout.astro +73 -0
  70. package/src/components/content/Card.astro +104 -0
  71. package/src/components/content/CardGroup.astro +14 -0
  72. package/src/components/content/CodeGroup.astro +13 -0
  73. package/src/components/content/Color.astro +15 -0
  74. package/src/components/content/ColorItem.astro +87 -0
  75. package/src/components/content/ColorRow.astro +10 -0
  76. package/src/components/content/Column.astro +6 -0
  77. package/src/components/content/Columns.astro +9 -0
  78. package/src/components/content/Expandable.astro +11 -0
  79. package/src/components/content/FileTree.astro +8 -0
  80. package/src/components/content/Frame.astro +70 -0
  81. package/src/components/content/GithubInfo.astro +110 -0
  82. package/src/components/content/Math.astro +24 -0
  83. package/src/components/content/Panel.astro +20 -0
  84. package/src/components/content/Prompt.astro +129 -0
  85. package/src/components/content/Step.astro +34 -0
  86. package/src/components/content/Steps.astro +20 -0
  87. package/src/components/content/Tab.astro +40 -0
  88. package/src/components/content/Tabs.astro +273 -0
  89. package/src/components/content/Tile.astro +42 -0
  90. package/src/components/content/Tooltip.astro +68 -0
  91. package/src/components/content/Tree.astro +300 -0
  92. package/src/components/content/TreeFile.astro +15 -0
  93. package/src/components/content/TreeFolder.astro +62 -0
  94. package/src/components/content/TypeTable.astro +106 -0
  95. package/src/components/content/Update.astro +66 -0
  96. package/src/components/content/Visibility.astro +12 -0
  97. package/src/components/content/Warning.astro +9 -0
  98. package/src/components/content/auto-type-table.ts +141 -0
  99. package/src/components/content/github-info.ts +79 -0
  100. package/src/components/content/mermaid-element.ts +68 -0
  101. package/src/components/github-mark.ts +9 -0
  102. package/src/components/index.ts +14 -0
  103. package/src/components/islands/AskAI.astro +12 -0
  104. package/src/components/islands/ask-ai.tsx +156 -0
  105. package/src/components/layout/Analytics.astro +63 -0
  106. package/src/components/layout/Banner.astro +50 -0
  107. package/src/components/layout/Breadcrumbs.astro +31 -0
  108. package/src/components/layout/Favicon.astro +15 -0
  109. package/src/components/layout/Fonts.astro +14 -0
  110. package/src/components/layout/Header.astro +188 -0
  111. package/src/components/layout/LanguageSwitcher.astro +56 -0
  112. package/src/components/layout/NavTree.astro +462 -0
  113. package/src/components/layout/PageActions.astro +438 -0
  114. package/src/components/layout/PageFeedback.astro +58 -0
  115. package/src/components/layout/Pagination.astro +56 -0
  116. package/src/components/layout/ReferenceLayout.astro +102 -0
  117. package/src/components/layout/RootLayout.astro +533 -0
  118. package/src/components/layout/Search.astro +608 -0
  119. package/src/components/layout/TableOfContents.astro +68 -0
  120. package/src/components/layout/analytics-client.ts +38 -0
  121. package/src/components/layout/nav-utils.ts +87 -0
  122. package/src/components/layout/overrides.ts +32 -0
  123. package/src/components/layout/search/algolia.ts +43 -0
  124. package/src/components/layout/search/endpoint.ts +22 -0
  125. package/src/components/layout/search/flexsearch.ts +52 -0
  126. package/src/components/layout/search/orama-cloud.ts +41 -0
  127. package/src/components/layout/search/orama.ts +26 -0
  128. package/src/components/layout/search/pagefind.ts +43 -0
  129. package/src/components/layout/search/types.ts +163 -0
  130. package/src/components/layout/search/typesense.ts +60 -0
  131. package/src/components/layout/toc-element.ts +108 -0
  132. package/src/core/bridge.ts +92 -0
  133. package/src/core/config.ts +112 -0
  134. package/src/core/content.ts +50 -0
  135. package/src/core/define-components.ts +34 -0
  136. package/src/core/define-meta.ts +20 -0
  137. package/src/core/deployment-env.ts +73 -0
  138. package/src/core/diagnostics.ts +104 -0
  139. package/src/core/graph.ts +128 -0
  140. package/src/core/i18n-ui.ts +171 -0
  141. package/src/core/i18n.ts +169 -0
  142. package/src/core/last-modified.ts +88 -0
  143. package/src/core/links.ts +336 -0
  144. package/src/core/load-module.ts +15 -0
  145. package/src/core/manifest.ts +126 -0
  146. package/src/core/meta.ts +97 -0
  147. package/src/core/navigation.ts +392 -0
  148. package/src/core/package-root.ts +37 -0
  149. package/src/core/project-graph.ts +153 -0
  150. package/src/core/project.ts +56 -0
  151. package/src/core/schema.ts +1057 -0
  152. package/src/core/server-features.ts +23 -0
  153. package/src/core/sources/assets.ts +77 -0
  154. package/src/core/sources/cache.ts +122 -0
  155. package/src/core/sources/filesystem.ts +99 -0
  156. package/src/core/sources/mdx-remote.ts +216 -0
  157. package/src/core/sources/mintlify.ts +161 -0
  158. package/src/core/sources/normalize.ts +227 -0
  159. package/src/core/sources/notion.ts +440 -0
  160. package/src/core/sources/portable-text.ts +143 -0
  161. package/src/core/sources/read.ts +36 -0
  162. package/src/core/sources/resolve.ts +158 -0
  163. package/src/core/sources/sanity.ts +218 -0
  164. package/src/core/sources/types.ts +105 -0
  165. package/src/core/types.ts +261 -0
  166. package/src/core/ui-packs/ar.ts +47 -0
  167. package/src/core/ui-packs/bg.ts +47 -0
  168. package/src/core/ui-packs/bn.ts +47 -0
  169. package/src/core/ui-packs/ca.ts +47 -0
  170. package/src/core/ui-packs/cs.ts +47 -0
  171. package/src/core/ui-packs/da.ts +47 -0
  172. package/src/core/ui-packs/de.ts +47 -0
  173. package/src/core/ui-packs/el.ts +47 -0
  174. package/src/core/ui-packs/es.ts +47 -0
  175. package/src/core/ui-packs/fa.ts +47 -0
  176. package/src/core/ui-packs/fi.ts +47 -0
  177. package/src/core/ui-packs/fr.ts +47 -0
  178. package/src/core/ui-packs/he.ts +47 -0
  179. package/src/core/ui-packs/hi.ts +47 -0
  180. package/src/core/ui-packs/hr.ts +47 -0
  181. package/src/core/ui-packs/hu.ts +47 -0
  182. package/src/core/ui-packs/id.ts +47 -0
  183. package/src/core/ui-packs/index.ts +87 -0
  184. package/src/core/ui-packs/it.ts +47 -0
  185. package/src/core/ui-packs/ja.ts +47 -0
  186. package/src/core/ui-packs/ko.ts +47 -0
  187. package/src/core/ui-packs/nl.ts +47 -0
  188. package/src/core/ui-packs/no.ts +47 -0
  189. package/src/core/ui-packs/pl.ts +47 -0
  190. package/src/core/ui-packs/pt-br.ts +47 -0
  191. package/src/core/ui-packs/pt.ts +47 -0
  192. package/src/core/ui-packs/ro.ts +47 -0
  193. package/src/core/ui-packs/ru.ts +47 -0
  194. package/src/core/ui-packs/sk.ts +47 -0
  195. package/src/core/ui-packs/sr.ts +47 -0
  196. package/src/core/ui-packs/sv.ts +47 -0
  197. package/src/core/ui-packs/th.ts +47 -0
  198. package/src/core/ui-packs/tr.ts +47 -0
  199. package/src/core/ui-packs/uk.ts +47 -0
  200. package/src/core/ui-packs/vi.ts +47 -0
  201. package/src/core/ui-packs/zh-tw.ts +47 -0
  202. package/src/core/ui-packs/zh.ts +47 -0
  203. package/src/core/version.ts +23 -0
  204. package/src/deploy/robots.ts +20 -0
  205. package/src/deploy/rss.ts +128 -0
  206. package/src/deploy/sitemap.ts +28 -0
  207. package/src/index.ts +27 -0
  208. package/src/markdown/code-title.ts +71 -0
  209. package/src/markdown/directives.ts +83 -0
  210. package/src/markdown/heading-anchors.ts +137 -0
  211. package/src/markdown/index.ts +159 -0
  212. package/src/markdown/inline-code.ts +108 -0
  213. package/src/markdown/language-icon.ts +172 -0
  214. package/src/markdown/math.ts +32 -0
  215. package/src/markdown/mdast.ts +48 -0
  216. package/src/markdown/mermaid.ts +37 -0
  217. package/src/markdown/package-commands.ts +159 -0
  218. package/src/markdown/package-install.ts +40 -0
  219. package/src/migrate/fumadocs/config.ts +106 -0
  220. package/src/migrate/fumadocs/content.ts +365 -0
  221. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  222. package/src/migrate/fumadocs/index.ts +252 -0
  223. package/src/migrate/fumadocs/meta.ts +114 -0
  224. package/src/migrate/migrate.ts +53 -0
  225. package/src/migrate/mintlify/config.ts +1040 -0
  226. package/src/migrate/mintlify/content.ts +98 -0
  227. package/src/migrate/mintlify/frontmatter.ts +126 -0
  228. package/src/migrate/mintlify/i18n.ts +51 -0
  229. package/src/migrate/mintlify/icons.ts +128 -0
  230. package/src/migrate/mintlify/index.ts +266 -0
  231. package/src/migrate/mintlify/snippets.ts +305 -0
  232. package/src/migrate/mintlify/transform.ts +81 -0
  233. package/src/migrate/nextra/content.ts +46 -0
  234. package/src/migrate/nextra/frontmatter.ts +40 -0
  235. package/src/migrate/nextra/index.ts +374 -0
  236. package/src/migrate/nextra/meta.ts +266 -0
  237. package/src/migrate/shared.ts +623 -0
  238. package/src/migrate/starlight/config.ts +459 -0
  239. package/src/migrate/starlight/content.ts +78 -0
  240. package/src/migrate/starlight/frontmatter.ts +111 -0
  241. package/src/migrate/starlight/i18n.ts +54 -0
  242. package/src/migrate/starlight/index.ts +131 -0
  243. package/src/og/card.ts +92 -0
  244. package/src/og/index.ts +2 -0
  245. package/src/openapi/scalar.ts +246 -0
  246. package/src/registry/eject.ts +263 -0
  247. package/src/registry/registry.ts +100 -0
  248. package/src/registry/rewrite-imports.ts +39 -0
  249. package/src/runtime/index.ts +14 -0
  250. package/src/search/build.ts +23 -0
  251. package/src/search/documents.ts +165 -0
  252. package/src/search/orama-index.ts +66 -0
  253. package/src/search/providers.ts +91 -0
  254. package/src/search/sync/algolia.ts +30 -0
  255. package/src/search/sync/index.ts +50 -0
  256. package/src/search/sync/orama-cloud.ts +40 -0
  257. package/src/search/sync/typesense.ts +65 -0
  258. package/src/seo/jsonld.ts +113 -0
  259. package/src/theme/entry.ts +608 -0
  260. package/src/theme/fonts.ts +198 -0
  261. package/src/theme/icons.ts +184 -0
  262. package/src/theme/palette.ts +143 -0
  263. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A minimal Portable Text → Markdown serializer. Covers the standard block,
3
+ * list, and mark types Sanity emits; custom block/mark types fall through to a
4
+ * user-supplied serializer or are skipped with a noted comment. Output is
5
+ * Markdown text that flows through Blume's normal pipeline.
6
+ */
7
+
8
+ /** A single Portable Text node (block, image, or a custom type). */
9
+ export interface PortableTextBlock {
10
+ _type: string;
11
+ _key?: string;
12
+ style?: string;
13
+ listItem?: string;
14
+ level?: number;
15
+ children?: PortableTextSpan[];
16
+ markDefs?: PortableTextMarkDef[];
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ interface PortableTextSpan {
21
+ _type: string;
22
+ text?: string;
23
+ marks?: string[];
24
+ }
25
+
26
+ interface PortableTextMarkDef {
27
+ _key: string;
28
+ _type: string;
29
+ href?: string;
30
+ }
31
+
32
+ export interface PortableTextOptions {
33
+ /** Resolve an image block to a URL (the adapter knows project/dataset). */
34
+ imageUrl?: (block: PortableTextBlock) => string | null;
35
+ /** Custom block-type serializers, keyed by `_type`; return Markdown/MDX. */
36
+ serializers?: Record<string, (block: PortableTextBlock) => string>;
37
+ }
38
+
39
+ const HEADING_STYLES: Record<string, string> = {
40
+ h1: "# ",
41
+ h2: "## ",
42
+ h3: "### ",
43
+ h4: "#### ",
44
+ h5: "##### ",
45
+ h6: "###### ",
46
+ };
47
+
48
+ /** Wrap a span's text in Markdown for its marks (decorators + link defs). */
49
+ const renderSpan = (
50
+ span: PortableTextSpan,
51
+ defs: Map<string, PortableTextMarkDef>
52
+ ): string => {
53
+ let text = span.text ?? "";
54
+ if (!span.marks || span.marks.length === 0) {
55
+ return text;
56
+ }
57
+ // Decorators wrap inline; a link def wraps last so its label keeps emphasis.
58
+ let link: PortableTextMarkDef | undefined;
59
+ for (const mark of span.marks) {
60
+ switch (mark) {
61
+ case "strong": {
62
+ text = `**${text}**`;
63
+ break;
64
+ }
65
+ case "em": {
66
+ text = `*${text}*`;
67
+ break;
68
+ }
69
+ case "code": {
70
+ text = `\`${text}\``;
71
+ break;
72
+ }
73
+ case "strike-through": {
74
+ text = `~~${text}~~`;
75
+ break;
76
+ }
77
+ default: {
78
+ const def = defs.get(mark);
79
+ if (def?._type === "link") {
80
+ link = def;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return link?.href ? `[${text}](${link.href})` : text;
86
+ };
87
+
88
+ /** Render the inline children of a block to a single Markdown string. */
89
+ const renderChildren = (block: PortableTextBlock): string => {
90
+ const defs = new Map(
91
+ (block.markDefs ?? []).map((def) => [def._key, def] as const)
92
+ );
93
+ return (block.children ?? []).map((span) => renderSpan(span, defs)).join("");
94
+ };
95
+
96
+ const renderBlock = (
97
+ block: PortableTextBlock,
98
+ options: PortableTextOptions
99
+ ): string => {
100
+ const custom = options.serializers?.[block._type];
101
+ if (custom) {
102
+ return custom(block);
103
+ }
104
+ if (block._type === "image") {
105
+ const url = options.imageUrl?.(block);
106
+ const alt = typeof block.alt === "string" ? block.alt : "";
107
+ return url ? `![${alt}](${url})` : "";
108
+ }
109
+ if (block._type !== "block") {
110
+ return `<!-- unsupported Portable Text block: ${block._type} -->`;
111
+ }
112
+
113
+ const inline = renderChildren(block);
114
+ if (block.listItem) {
115
+ const indent = " ".repeat(Math.max(0, (block.level ?? 1) - 1));
116
+ const marker = block.listItem === "number" ? "1." : "-";
117
+ return `${indent}${marker} ${inline}`;
118
+ }
119
+ if (block.style === "blockquote") {
120
+ return `> ${inline}`;
121
+ }
122
+ return `${HEADING_STYLES[block.style ?? "normal"] ?? ""}${inline}`;
123
+ };
124
+
125
+ /** Serialize a Portable Text array into a Markdown string. */
126
+ export const portableTextToMarkdown = (
127
+ blocks: PortableTextBlock[],
128
+ options: PortableTextOptions = {}
129
+ ): string => {
130
+ const lines = blocks.map((block) => renderBlock(block, options));
131
+ // List items are single-newline separated; everything else gets a blank line.
132
+ const out: string[] = [];
133
+ for (let i = 0; i < lines.length; i += 1) {
134
+ const line = lines[i] ?? "";
135
+ const prevList = Boolean(blocks[i - 1]?.listItem);
136
+ const thisList = Boolean(blocks[i]?.listItem);
137
+ if (i > 0) {
138
+ out.push(prevList && thisList ? "\n" : "\n\n");
139
+ }
140
+ out.push(line);
141
+ }
142
+ return `${out.join("")}\n`;
143
+ };
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import type { PageRecord } from "../types.ts";
4
+ import type { ContentSource } from "./types.ts";
5
+
6
+ /** The subset of a scanned project the entry reader needs. */
7
+ export interface EntryReadContext {
8
+ /** The instantiated sources, keyed for `read()` lookups. */
9
+ sources?: ContentSource[];
10
+ }
11
+
12
+ /**
13
+ * Read an entry's raw body text without assuming a filesystem path. Prefers the
14
+ * body captured at scan time (staged sources), then the owning source's lazy
15
+ * `read()`, then the back-compat `sourcePath` (filesystem). Returns `""` when an
16
+ * entry can't be resolved, so callers degrade gracefully.
17
+ */
18
+ export const readEntryText = async (
19
+ ctx: EntryReadContext,
20
+ page: PageRecord
21
+ ): Promise<string> => {
22
+ if (page.body) {
23
+ return page.body.text;
24
+ }
25
+ const name = page.source?.name;
26
+ const source = name
27
+ ? ctx.sources?.find((candidate) => candidate.name === name)
28
+ : undefined;
29
+ if (source?.read) {
30
+ return await source.read(page.source.ref);
31
+ }
32
+ if (page.sourcePath) {
33
+ return await readFile(page.sourcePath, "utf-8");
34
+ }
35
+ return "";
36
+ };
@@ -0,0 +1,158 @@
1
+ import { join } from "pathe";
2
+
3
+ import type { ContentSourceConfig, ResolvedConfig } from "../schema.ts";
4
+ import type { ProjectContext } from "../types.ts";
5
+ import { filesystemSource } from "./filesystem.ts";
6
+ import { mdxRemoteSource } from "./mdx-remote.ts";
7
+ import { mintlifySource } from "./mintlify.ts";
8
+ import { notionSource } from "./notion.ts";
9
+ import { sanitySource } from "./sanity.ts";
10
+ import type { ContentSource, SourceContext } from "./types.ts";
11
+
12
+ /** Allocate a unique, stable source name from a base (prefix or type). */
13
+ const uniqueNamer = (): ((base: string) => string) => {
14
+ const used = new Set<string>();
15
+ return (base) => {
16
+ let name = base;
17
+ let n = 2;
18
+ while (used.has(name)) {
19
+ name = `${base}-${n}`;
20
+ n += 1;
21
+ }
22
+ used.add(name);
23
+ return name;
24
+ };
25
+ };
26
+
27
+ /** Runtime knobs for a scan: dev/build mode, preview, and cache refresh. */
28
+ export interface SourceRuntime {
29
+ mode: "dev" | "build";
30
+ preview?: boolean;
31
+ refresh?: boolean;
32
+ }
33
+
34
+ const sourceContext = (
35
+ context: ProjectContext,
36
+ name: string,
37
+ runtime: SourceRuntime
38
+ ): SourceContext => ({
39
+ assetsBaseUrl: `/blume-assets/${name}`,
40
+ assetsDir: join(context.outDir, "public", "blume-assets", name),
41
+ cacheDir: join(context.outDir, "cache", name),
42
+ mode: runtime.mode,
43
+ preview: runtime.preview,
44
+ projectRoot: context.root,
45
+ refresh: runtime.refresh ?? runtime.mode === "build",
46
+ });
47
+
48
+ const buildSource = (
49
+ def: ContentSourceConfig,
50
+ name: string,
51
+ context: ProjectContext,
52
+ runtime: SourceRuntime
53
+ ): ContentSource => {
54
+ if (def.type === "filesystem") {
55
+ return filesystemSource({
56
+ exclude: def.exclude,
57
+ include: def.include,
58
+ name,
59
+ prefix: def.prefix,
60
+ projectRoot: context.root,
61
+ root: def.root,
62
+ });
63
+ }
64
+ if (def.type === "mintlify") {
65
+ return mintlifySource({
66
+ configFile: def.configFile,
67
+ exclude: def.exclude,
68
+ include: def.include,
69
+ name,
70
+ prefix: def.prefix,
71
+ projectRoot: context.root,
72
+ root: def.root,
73
+ variables: def.variables,
74
+ });
75
+ }
76
+ if (def.type === "custom") {
77
+ // A user-provided instance manages its own context/caching; we only ensure
78
+ // its name is unique across the project for id namespacing.
79
+ return def.source.name === name ? def.source : { ...def.source, name };
80
+ }
81
+ if (def.type === "sanity") {
82
+ return sanitySource(
83
+ {
84
+ apiVersion: def.apiVersion,
85
+ dataset: def.dataset,
86
+ fields: def.fields,
87
+ name,
88
+ pollInterval: def.pollInterval,
89
+ prefix: def.prefix,
90
+ projectId: def.projectId,
91
+ query: def.query,
92
+ },
93
+ sourceContext(context, name, runtime)
94
+ );
95
+ }
96
+ if (def.type === "notion") {
97
+ return notionSource(
98
+ {
99
+ database: def.database,
100
+ name,
101
+ pollInterval: def.pollInterval,
102
+ prefix: def.prefix,
103
+ properties: def.properties,
104
+ publishedValue: def.publishedValue,
105
+ },
106
+ sourceContext(context, name, runtime)
107
+ );
108
+ }
109
+ return mdxRemoteSource(
110
+ {
111
+ files: def.files,
112
+ github: def.github,
113
+ include: def.include,
114
+ name,
115
+ pollInterval: def.pollInterval,
116
+ prefix: def.prefix,
117
+ url: def.url,
118
+ },
119
+ sourceContext(context, name, runtime)
120
+ );
121
+ };
122
+
123
+ /** The base name to allocate for a source config (before deduplication). */
124
+ const baseName = (def: ContentSourceConfig): string => {
125
+ if (def.type === "custom") {
126
+ return def.source.name;
127
+ }
128
+ return def.prefix ?? def.type;
129
+ };
130
+
131
+ /**
132
+ * Build the ordered list of content sources for a project. With no
133
+ * `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
134
+ * to a single implicit filesystem source, so existing projects are untouched.
135
+ */
136
+ export const resolveSources = (
137
+ config: ResolvedConfig,
138
+ context: ProjectContext,
139
+ runtime: SourceRuntime
140
+ ): ContentSource[] => {
141
+ const defs = config.content.sources;
142
+ if (!defs || defs.length === 0) {
143
+ return [
144
+ filesystemSource({
145
+ exclude: config.content.exclude,
146
+ include: config.content.include,
147
+ name: "filesystem",
148
+ projectRoot: context.root,
149
+ root: config.content.root,
150
+ }),
151
+ ];
152
+ }
153
+
154
+ const nameFor = uniqueNamer();
155
+ return defs.map((def) =>
156
+ buildSource(def, nameFor(baseName(def)), context, runtime)
157
+ );
158
+ };
@@ -0,0 +1,218 @@
1
+ import matter from "gray-matter";
2
+ import { join } from "pathe";
3
+
4
+ import { BlumeError } from "../diagnostics.ts";
5
+ import {
6
+ hashText,
7
+ loadWithCache,
8
+ pollingWatch,
9
+ snapshotCache,
10
+ } from "./cache.ts";
11
+ import { slugify } from "./normalize.ts";
12
+ import { portableTextToMarkdown } from "./portable-text.ts";
13
+ import type { PortableTextBlock } from "./portable-text.ts";
14
+ import type {
15
+ ContentSource,
16
+ SourceContext,
17
+ SourceEntry,
18
+ SourceLoadResult,
19
+ } from "./types.ts";
20
+
21
+ /** The slice of `@sanity/client` this adapter relies on (so it's mockable). */
22
+ export interface SanityClientLike {
23
+ fetch: <T = unknown>(query: string) => Promise<T>;
24
+ }
25
+
26
+ /** Field paths mapping a Sanity document onto Blume meta + body. */
27
+ export interface SanityFieldMap {
28
+ /** Frontmatter title; default `title`. */
29
+ title?: string;
30
+ /** Frontmatter description; default `description`. */
31
+ description?: string;
32
+ /** Route slug (dot path); default `slug.current`. */
33
+ slug?: string;
34
+ /** Portable Text body field; default `body`. */
35
+ body?: string;
36
+ /** Last-modified ISO date; default `_updatedAt`. */
37
+ lastModified?: string;
38
+ }
39
+
40
+ export interface SanitySourceOptions {
41
+ name: string;
42
+ prefix?: string;
43
+ projectId: string;
44
+ dataset: string;
45
+ /** Sanity API version (a date); default `2024-01-01`. */
46
+ apiVersion?: string;
47
+ /** GROQ query selecting the documents to import. */
48
+ query: string;
49
+ fields?: SanityFieldMap;
50
+ /** Custom Portable Text block serializers, keyed by `_type`. */
51
+ serializers?: Record<string, (block: PortableTextBlock) => string>;
52
+ /** Read token for private datasets; defaults to `SANITY_TOKEN`. */
53
+ token?: string;
54
+ /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
55
+ pollInterval?: number;
56
+ /** Injected for tests; otherwise built from `@sanity/client`. */
57
+ client?: SanityClientLike;
58
+ }
59
+
60
+ const IMAGE_REF = /^image-(?<id>[a-f0-9]+)-(?<dims>\d+x\d+)-(?<ext>\w+)$/u;
61
+
62
+ /** Resolve a dot path (`slug.current`) against a document. */
63
+ const getPath = (doc: Record<string, unknown>, path: string): unknown => {
64
+ let current: unknown = doc;
65
+ for (const key of path.split(".")) {
66
+ if (current && typeof current === "object") {
67
+ current = (current as Record<string, unknown>)[key];
68
+ } else {
69
+ return;
70
+ }
71
+ }
72
+ return current;
73
+ };
74
+
75
+ const asString = (value: unknown): string | undefined =>
76
+ typeof value === "string" ? value : undefined;
77
+
78
+ /** Build a Sanity CDN URL from an image asset `_ref`. */
79
+ const imageUrlFromRef = (
80
+ ref: string,
81
+ projectId: string,
82
+ dataset: string
83
+ ): string | null => {
84
+ const match = ref.match(IMAGE_REF);
85
+ if (!match?.groups) {
86
+ return null;
87
+ }
88
+ const { id, dims, ext } = match.groups;
89
+ return `https://cdn.sanity.io/images/${projectId}/${dataset}/${id}-${dims}.${ext}`;
90
+ };
91
+
92
+ const resolveClient = async (
93
+ options: SanitySourceOptions,
94
+ preview: boolean
95
+ ): Promise<SanityClientLike> => {
96
+ if (options.client) {
97
+ return options.client;
98
+ }
99
+ let createClient: (config: Record<string, unknown>) => SanityClientLike;
100
+ try {
101
+ ({ createClient } = (await import("@sanity/client")) as {
102
+ createClient: (config: Record<string, unknown>) => SanityClientLike;
103
+ });
104
+ } catch {
105
+ throw new BlumeError({
106
+ code: "BLUME_SOURCE_SDK_MISSING",
107
+ message: `Source "${options.name}" needs "@sanity/client". Install it (e.g. \`npm install @sanity/client\`).`,
108
+ severity: "error",
109
+ });
110
+ }
111
+ return createClient({
112
+ apiVersion: options.apiVersion ?? "2024-01-01",
113
+ dataset: options.dataset,
114
+ // Preview reads draft documents through the API; published builds use the CDN.
115
+ perspective: preview ? "previewDrafts" : "published",
116
+ projectId: options.projectId,
117
+ token: options.token ?? process.env.SANITY_TOKEN,
118
+ useCdn: !preview,
119
+ });
120
+ };
121
+
122
+ /**
123
+ * Sanity content source. Runs a GROQ query, maps each document's fields to Blume
124
+ * frontmatter and its Portable Text body to Markdown, and stages the result.
125
+ */
126
+ export const sanitySource = (
127
+ options: SanitySourceOptions,
128
+ ctx?: SourceContext
129
+ ): ContentSource => {
130
+ const fields = options.fields ?? {};
131
+ // When constructed directly (custom-source SPI) without a context, cache under
132
+ // a name-derived dir relative to the project; the built-in type passes a ctx.
133
+ const cache = snapshotCache(
134
+ ctx?.cacheDir ?? join(".blume", "cache", options.name)
135
+ );
136
+ let snapshot = new Map<string, SourceEntry>();
137
+
138
+ const toEntry = (doc: Record<string, unknown>): SourceEntry => {
139
+ const slugValue =
140
+ asString(getPath(doc, fields.slug ?? "slug.current")) ??
141
+ asString(doc._id) ??
142
+ "untitled";
143
+ const slug = slugify(slugValue) || "untitled";
144
+
145
+ const data: Record<string, unknown> = {};
146
+ const title = asString(getPath(doc, fields.title ?? "title"));
147
+ const description = asString(
148
+ getPath(doc, fields.description ?? "description")
149
+ );
150
+ if (title) {
151
+ data.title = title;
152
+ }
153
+ if (description) {
154
+ data.description = description;
155
+ }
156
+
157
+ const blocks = (getPath(doc, fields.body ?? "body") ??
158
+ []) as PortableTextBlock[];
159
+ const markdown = Array.isArray(blocks)
160
+ ? portableTextToMarkdown(blocks, {
161
+ imageUrl: (block) => {
162
+ const ref = (block.asset as { _ref?: string } | undefined)?._ref;
163
+ return ref
164
+ ? imageUrlFromRef(ref, options.projectId, options.dataset)
165
+ : null;
166
+ },
167
+ serializers: options.serializers,
168
+ })
169
+ : "";
170
+
171
+ const raw = matter.stringify(markdown, data);
172
+ return {
173
+ body: { format: "md", text: markdown },
174
+ data,
175
+ hash: hashText(raw),
176
+ lastModified: asString(getPath(doc, fields.lastModified ?? "_updatedAt")),
177
+ raw,
178
+ ref: `${slug}.md`,
179
+ };
180
+ };
181
+
182
+ const load = async (): Promise<SourceLoadResult> => {
183
+ const result = await loadWithCache(
184
+ options.name,
185
+ cache,
186
+ async () => {
187
+ const client = await resolveClient(options, ctx?.preview ?? false);
188
+ const docs = await client.fetch<Record<string, unknown>[]>(
189
+ options.query
190
+ );
191
+ return docs.map(toEntry);
192
+ },
193
+ ctx?.refresh ?? true
194
+ );
195
+ snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
196
+ return result;
197
+ };
198
+
199
+ const read = async (ref: string): Promise<string> => {
200
+ const cached = snapshot.get(ref);
201
+ if (cached) {
202
+ return cached.raw ?? cached.body.text;
203
+ }
204
+ const all = await cache.read();
205
+ return all.find((e) => e.ref === ref)?.raw ?? "";
206
+ };
207
+
208
+ return {
209
+ load,
210
+ name: options.name,
211
+ prefix: options.prefix,
212
+ read,
213
+ staged: true,
214
+ watch: options.pollInterval
215
+ ? pollingWatch(load, options.pollInterval)
216
+ : undefined,
217
+ };
218
+ };
@@ -0,0 +1,105 @@
1
+ import type { ResolvedI18nConfig } from "../schema.ts";
2
+ import type { Diagnostic } from "../types.ts";
3
+
4
+ /**
5
+ * A single content item, normalized by a source adapter. Adapters lower their
6
+ * native shape (files, Portable Text, Notion blocks, remote HTML) to Markdown/MDX
7
+ * *text* so Blume's markdown processors and component set apply uniformly.
8
+ */
9
+ export interface SourceEntry {
10
+ /** Source-local stable id, e.g. `api/auth.mdx` or a CMS document id. */
11
+ ref: string;
12
+ /** Logical route input; defaults to `ref` if omitted. May include slashes. */
13
+ slug?: string;
14
+ /** Frontmatter-equivalent metadata, validated against the Blume meta schema. */
15
+ data: Record<string, unknown>;
16
+ /** The renderable body as Markdown/MDX source text (frontmatter stripped). */
17
+ body: { format: "md" | "mdx"; text: string };
18
+ /**
19
+ * Full source text including frontmatter, written verbatim to the staging
20
+ * dir so Astro re-parses the same frontmatter. Staged (non-filesystem) sources
21
+ * set this; filesystem entries (read from disk) omit it.
22
+ */
23
+ raw?: string;
24
+ /**
25
+ * Absolute filesystem path when the entry originates from disk. Populated by
26
+ * the filesystem adapter only; powers git last-modified and edit URLs and the
27
+ * `sourcePath` back-compat window. Omitted by remote/CMS adapters.
28
+ */
29
+ sourcePath?: string;
30
+ /** Optional provenance for "edit this page". */
31
+ editUrl?: string;
32
+ /** Optional last-modified ISO date supplied by the adapter (non-filesystem). */
33
+ lastModified?: string;
34
+ /** Content hash for cache invalidation / HMR; adapter-computed when cheap. */
35
+ hash?: string;
36
+ }
37
+
38
+ /** The result of a single `ContentSource.load()` call. */
39
+ export interface SourceLoadResult {
40
+ entries: SourceEntry[];
41
+ /** Source-level diagnostics (e.g. an offline cache fallback warning). */
42
+ diagnostics: Diagnostic[];
43
+ }
44
+
45
+ /**
46
+ * Per-source runtime context, handed to an adapter factory at construction so
47
+ * `load`/`read`/`watch` can close over it without re-threading on every call.
48
+ */
49
+ export interface SourceContext {
50
+ projectRoot: string;
51
+ /** Per-source cache dir under `.blume/cache/<source>/`. */
52
+ cacheDir: string;
53
+ mode: "dev" | "build";
54
+ /** Dir for downloaded assets (served from the site's public dir). */
55
+ assetsDir?: string;
56
+ /** Public URL prefix the downloaded assets are served under. */
57
+ assetsBaseUrl?: string;
58
+ /**
59
+ * Re-fetch remote content instead of serving the cached snapshot. True for
60
+ * builds and `blume sync`; false in dev (cache-first for fast, offline-tolerant
61
+ * restarts — refresh with `blume sync` or an opt-in `pollInterval`).
62
+ */
63
+ refresh?: boolean;
64
+ /**
65
+ * Preview unpublished content: drafts are kept and CMS adapters fetch draft
66
+ * documents (Sanity's `previewDrafts` perspective). Off for production builds.
67
+ */
68
+ preview?: boolean;
69
+ }
70
+
71
+ /**
72
+ * A pluggable content source. Adapters enumerate normalized entries and
73
+ * (optionally) re-read a single entry, validate themselves, and watch for
74
+ * changes. Identity is the opaque `SourceEntry.ref`, never a filesystem path.
75
+ */
76
+ export interface ContentSource {
77
+ /** Unique, stable name; used for id namespacing and diagnostics. */
78
+ readonly name: string;
79
+ /**
80
+ * Whether entries render through the staging collection. Filesystem sources
81
+ * render through Astro's existing `docs` glob collection (`false`); every
82
+ * other source materializes MDX into `.blume/content` (`true`).
83
+ */
84
+ readonly staged: boolean;
85
+ /** Optional route prefix; the source's routes namespace under `/<prefix>/`. */
86
+ readonly prefix?: string;
87
+ /** Pull every entry. Called once per scan. */
88
+ load: () => Promise<SourceLoadResult>;
89
+ /** Validate the source is usable; throws a BlumeError when not. */
90
+ validate?: () => void;
91
+ /** Read a single entry's body lazily (search / AI / raw export). */
92
+ read?: (ref: string) => Promise<string>;
93
+ /**
94
+ * Notify on change in dev. Returns a disposer. Filesystem uses `fs.watch`;
95
+ * remote/static sources omit it (content is frozen for the session).
96
+ */
97
+ watch?: (onChange: () => void) => () => void;
98
+ }
99
+
100
+ /** Context passed to `normalizeEntry`, describing the owning source. */
101
+ export interface NormalizeContext {
102
+ source: { name: string; prefix?: string; staged: boolean };
103
+ defaultType: string;
104
+ i18n?: ResolvedI18nConfig;
105
+ }