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,336 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { join } from "pathe";
4
+
5
+ import type {
6
+ ContentGraph,
7
+ Diagnostic,
8
+ PageLink,
9
+ PageRecord,
10
+ } from "./types.ts";
11
+
12
+ const HTTP = /^https?:\/\//iu;
13
+ const PROTOCOL_RELATIVE = /^\/\//u;
14
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
15
+ const DOC_EXT = /\.(?:md|mdx)$/iu;
16
+ const FILE_EXT = /\.[a-z0-9]+$/iu;
17
+
18
+ const EXTERNAL_CONCURRENCY = 8;
19
+ const EXTERNAL_TIMEOUT_MS = 10_000;
20
+ const STATUS_NOT_FOUND = 404;
21
+ const STATUS_GONE = 410;
22
+ const STATUS_METHOD_NOT_ALLOWED = 405;
23
+ const STATUS_NOT_IMPLEMENTED = 501;
24
+
25
+ /** Source position shared by every diagnostic raised for a link. */
26
+ interface LinkSite {
27
+ column: number;
28
+ file: string;
29
+ line: number;
30
+ }
31
+
32
+ /** An external link occurrence queued for a network probe. */
33
+ interface ExternalRef extends LinkSite {
34
+ url: string;
35
+ }
36
+
37
+ /** Lookups derived once from the content graph. */
38
+ interface LinkContext {
39
+ anchors: Map<string, Set<string>>;
40
+ publicDir: string | null;
41
+ routes: Set<string>;
42
+ }
43
+
44
+ /** Outcome of classifying one link target. */
45
+ type LinkResult = Diagnostic | "asset-unchecked" | null;
46
+
47
+ /** Resolve a relative link target against the directory of a page route. */
48
+ const resolveRelative = (pageRoute: string, target: string): string => {
49
+ const segments = pageRoute.split("/").filter(Boolean);
50
+ // Drop the page's own segment so links resolve against its parent directory.
51
+ segments.pop();
52
+ for (const part of target.split("/")) {
53
+ if (part === "" || part === ".") {
54
+ continue;
55
+ }
56
+ if (part === "..") {
57
+ segments.pop();
58
+ continue;
59
+ }
60
+ segments.push(part);
61
+ }
62
+ return `/${segments.join("/")}`;
63
+ };
64
+
65
+ /** Normalize an internal path to its canonical route form. */
66
+ const toRoute = (path: string): string => {
67
+ let route = path.replace(DOC_EXT, "");
68
+ if (route.endsWith("/index")) {
69
+ route = route.slice(0, -"/index".length);
70
+ }
71
+ if (route.length > 1 && route.endsWith("/")) {
72
+ route = route.slice(0, -1);
73
+ }
74
+ return route === "" ? "/" : route;
75
+ };
76
+
77
+ /** Build a map of route -> set of heading anchor slugs. */
78
+ const buildAnchorIndex = (pages: PageRecord[]): Map<string, Set<string>> => {
79
+ const anchors = new Map<string, Set<string>>();
80
+ for (const page of pages) {
81
+ anchors.set(
82
+ page.route,
83
+ new Set(page.headings.map((heading) => heading.slug))
84
+ );
85
+ }
86
+ return anchors;
87
+ };
88
+
89
+ /** Verify a fragment resolves to a heading on the target route. */
90
+ const checkAnchor = (
91
+ route: string,
92
+ fragment: string,
93
+ site: LinkSite,
94
+ ctx: LinkContext
95
+ ): Diagnostic | null => {
96
+ if (ctx.anchors.get(route)?.has(fragment.toLowerCase())) {
97
+ return null;
98
+ }
99
+ return {
100
+ ...site,
101
+ code: "BLUME_BROKEN_ANCHOR",
102
+ message: `No heading on ${route} matches anchor #${fragment}.`,
103
+ severity: "warning",
104
+ };
105
+ };
106
+
107
+ /** Validate a resolved internal path: asset, route, then optional anchor. */
108
+ const checkPathLink = (
109
+ resolved: string,
110
+ fragment: string,
111
+ target: string,
112
+ site: LinkSite,
113
+ ctx: LinkContext
114
+ ): LinkResult => {
115
+ if (FILE_EXT.test(resolved) && !DOC_EXT.test(resolved)) {
116
+ if (ctx.publicDir === null) {
117
+ return "asset-unchecked";
118
+ }
119
+ if (existsSync(join(ctx.publicDir, resolved))) {
120
+ return null;
121
+ }
122
+ return {
123
+ ...site,
124
+ code: "BLUME_BROKEN_ASSET",
125
+ message: `Asset ${resolved} was not found in the public directory.`,
126
+ severity: "warning",
127
+ suggestion: `Add the file at public${resolved} or fix the link.`,
128
+ };
129
+ }
130
+
131
+ const route = toRoute(resolved);
132
+ if (!ctx.routes.has(route)) {
133
+ return {
134
+ ...site,
135
+ code: "BLUME_BROKEN_LINK",
136
+ message: `Broken link to ${target}: no page resolves to ${route}.`,
137
+ severity: "error",
138
+ suggestion: "Check the path, or create the target page.",
139
+ };
140
+ }
141
+ return fragment ? checkAnchor(route, fragment, site, ctx) : null;
142
+ };
143
+
144
+ /** Probe a URL with the given method, normalizing failures to a result. */
145
+ const request = async (
146
+ url: string,
147
+ method: "GET" | "HEAD"
148
+ ): Promise<{
149
+ ok: boolean;
150
+ status?: number;
151
+ timedOut?: boolean;
152
+ error?: string;
153
+ }> => {
154
+ const controller = new AbortController();
155
+ const timer = setTimeout(() => controller.abort(), EXTERNAL_TIMEOUT_MS);
156
+ try {
157
+ const response = await fetch(url, {
158
+ method,
159
+ redirect: "follow",
160
+ signal: controller.signal,
161
+ });
162
+ return { ok: response.ok, status: response.status };
163
+ } catch (error) {
164
+ if (error instanceof Error && error.name === "AbortError") {
165
+ return { ok: false, timedOut: true };
166
+ }
167
+ return {
168
+ error: error instanceof Error ? error.message : String(error),
169
+ ok: false,
170
+ };
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ };
175
+
176
+ /** Probe a single URL: HEAD first, falling back to GET when needed. */
177
+ const probe = async (
178
+ url: string
179
+ ): Promise<Awaited<ReturnType<typeof request>>> => {
180
+ const head = await request(url, "HEAD");
181
+ const retry =
182
+ head.status === STATUS_METHOD_NOT_ALLOWED ||
183
+ head.status === STATUS_NOT_IMPLEMENTED ||
184
+ (!head.ok && head.status === undefined && !head.timedOut);
185
+ return retry ? await request(url, "GET") : head;
186
+ };
187
+
188
+ /** Grade a probe result into a diagnostic severity + detail, or null if OK. */
189
+ const gradeExternal = (
190
+ result: Awaited<ReturnType<typeof request>>
191
+ ): { severity: Diagnostic["severity"]; detail: string } | null => {
192
+ if (result.ok) {
193
+ return null;
194
+ }
195
+ if (result.timedOut) {
196
+ return { detail: "request timed out", severity: "warning" };
197
+ }
198
+ if (result.status === undefined) {
199
+ return { detail: result.error ?? "unreachable", severity: "error" };
200
+ }
201
+ if (result.status === STATUS_NOT_FOUND || result.status === STATUS_GONE) {
202
+ return { detail: `HTTP ${result.status}`, severity: "error" };
203
+ }
204
+ return { detail: `HTTP ${result.status}`, severity: "warning" };
205
+ };
206
+
207
+ /** Probe queued external links with bounded concurrency. */
208
+ const checkExternalLinks = async (
209
+ refs: ExternalRef[]
210
+ ): Promise<Diagnostic[]> => {
211
+ const unique = [...new Set(refs.map((ref) => ref.url))];
212
+ const results = new Map<string, Awaited<ReturnType<typeof probe>>>();
213
+
214
+ let cursor = 0;
215
+ const worker = async (): Promise<void> => {
216
+ while (cursor < unique.length) {
217
+ const url = unique[cursor];
218
+ cursor += 1;
219
+ if (url !== undefined) {
220
+ // oxlint-disable-next-line no-await-in-loop -- bounded-concurrency pool
221
+ results.set(url, await probe(url));
222
+ }
223
+ }
224
+ };
225
+ await Promise.all(
226
+ Array.from(
227
+ { length: Math.min(EXTERNAL_CONCURRENCY, unique.length) },
228
+ worker
229
+ )
230
+ );
231
+
232
+ const diagnostics: Diagnostic[] = [];
233
+ for (const ref of refs) {
234
+ const result = results.get(ref.url);
235
+ const grade = result ? gradeExternal(result) : null;
236
+ if (grade) {
237
+ diagnostics.push({
238
+ code: "BLUME_DEAD_LINK",
239
+ column: ref.column,
240
+ file: ref.file,
241
+ line: ref.line,
242
+ message: `External link ${ref.url} is unreachable (${grade.detail}).`,
243
+ severity: grade.severity,
244
+ });
245
+ }
246
+ }
247
+ return diagnostics;
248
+ };
249
+
250
+ /** Classify a single link, queueing external refs via `onExternal`. */
251
+ const classifyLink = (
252
+ page: PageRecord,
253
+ link: PageLink,
254
+ ctx: LinkContext,
255
+ onExternal: (ref: ExternalRef) => void
256
+ ): LinkResult => {
257
+ const { target } = link;
258
+ const site: LinkSite = {
259
+ column: link.column,
260
+ file: page.sourcePath ?? page.id,
261
+ line: link.line,
262
+ };
263
+
264
+ if (HTTP.test(target) || PROTOCOL_RELATIVE.test(target)) {
265
+ onExternal({
266
+ ...site,
267
+ url: PROTOCOL_RELATIVE.test(target) ? `https:${target}` : target,
268
+ });
269
+ return null;
270
+ }
271
+ if (SCHEME.test(target)) {
272
+ // mailto:, tel:, and other non-HTTP schemes are not validated.
273
+ return null;
274
+ }
275
+
276
+ const hashIndex = target.indexOf("#");
277
+ const fragment = hashIndex === -1 ? "" : target.slice(hashIndex + 1);
278
+ let rawPath = hashIndex === -1 ? target : target.slice(0, hashIndex);
279
+ const queryIndex = rawPath.indexOf("?");
280
+ if (queryIndex !== -1) {
281
+ rawPath = rawPath.slice(0, queryIndex);
282
+ }
283
+
284
+ if (rawPath === "") {
285
+ return fragment ? checkAnchor(page.route, fragment, site, ctx) : null;
286
+ }
287
+
288
+ const resolved = rawPath.startsWith("/")
289
+ ? rawPath
290
+ : resolveRelative(page.route, rawPath);
291
+ return checkPathLink(resolved, fragment, target, site, ctx);
292
+ };
293
+
294
+ /**
295
+ * Validate every link discovered in the content graph: internal page links and
296
+ * anchors against the route map, asset links against the public dir, and
297
+ * (opt-in) external links over the network.
298
+ */
299
+ export const validateLinks = async (
300
+ graph: ContentGraph,
301
+ options: { publicDir: string | null; checkExternal?: boolean }
302
+ ): Promise<Diagnostic[]> => {
303
+ const ctx: LinkContext = {
304
+ anchors: buildAnchorIndex(graph.pages),
305
+ publicDir: options.publicDir,
306
+ routes: new Set(graph.routes.keys()),
307
+ };
308
+ const diagnostics: Diagnostic[] = [];
309
+ const external: ExternalRef[] = [];
310
+ let uncheckedAssets = 0;
311
+
312
+ for (const page of graph.pages) {
313
+ for (const link of page.links) {
314
+ const result = classifyLink(page, link, ctx, (ref) => external.push(ref));
315
+ if (result === "asset-unchecked") {
316
+ uncheckedAssets += 1;
317
+ } else if (result) {
318
+ diagnostics.push(result);
319
+ }
320
+ }
321
+ }
322
+
323
+ if (uncheckedAssets > 0) {
324
+ diagnostics.push({
325
+ code: "BLUME_ASSETS_UNCHECKED",
326
+ message: `${uncheckedAssets} asset link(s) not checked: no public/ directory found.`,
327
+ severity: "info",
328
+ });
329
+ }
330
+
331
+ if (options.checkExternal && external.length > 0) {
332
+ diagnostics.push(...(await checkExternalLinks(external)));
333
+ }
334
+
335
+ return diagnostics;
336
+ };
@@ -0,0 +1,15 @@
1
+ import { createJiti } from "jiti";
2
+
3
+ /**
4
+ * Create a loader for user-authored ESM/TS modules (`blume.config.ts`,
5
+ * `meta.ts`). One jiti instance is reused across every file the returned loader
6
+ * is called with. `moduleCache: false` ensures edits are picked up on each load,
7
+ * which is what makes dev-server regeneration reflect config/meta changes.
8
+ */
9
+ export const createModuleLoader = (): ((file: string) => Promise<unknown>) => {
10
+ const jiti = createJiti(import.meta.url, { moduleCache: false });
11
+ return async (file: string) => {
12
+ const loaded = await jiti.import<{ default?: unknown }>(file);
13
+ return loaded?.default ?? loaded;
14
+ };
15
+ };
@@ -0,0 +1,126 @@
1
+ import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
2
+ import type { ResolvedConfig } from "./schema.ts";
3
+ import type {
4
+ BlumeManifest,
5
+ ContentGraph,
6
+ PageRecord,
7
+ ProjectContext,
8
+ RouteAlternate,
9
+ RouteManifestEntry,
10
+ } from "./types.ts";
11
+ import { getBlumeVersion } from "./version.ts";
12
+
13
+ /** The current manifest schema version. */
14
+ export const MANIFEST_VERSION = 1;
15
+
16
+ /**
17
+ * Whether a page may be indexed on its own merits — not author-excluded and not
18
+ * hidden (unless hidden pages are opted in). This is independent of whether the
19
+ * site search provider is enabled, so features like the MCP server can index
20
+ * docs even when on-page search is off.
21
+ */
22
+ export const contentIndexable = (
23
+ page: PageRecord,
24
+ config: ResolvedConfig
25
+ ): boolean =>
26
+ !page.meta.search.exclude &&
27
+ (!page.meta.sidebar.hidden || config.search.indexing.includeHiddenPages);
28
+
29
+ /** Build the runtime manifest that bridges core and the generated Astro app. */
30
+ export const buildManifest = (options: {
31
+ context: ProjectContext;
32
+ config: ResolvedConfig;
33
+ graph: ContentGraph;
34
+ }): BlumeManifest => {
35
+ const { context, config, graph } = options;
36
+ const searchEnabled = config.search.provider !== "none";
37
+ const { i18n } = config;
38
+
39
+ // Real translations per logical page, for `hreflang` and the switcher. Built
40
+ // only under i18n; a single-locale page has no alternates.
41
+ const alternatesByKey = new Map<string, RouteAlternate[]>();
42
+ if (i18n) {
43
+ for (const page of graph.pages) {
44
+ const list = alternatesByKey.get(page.translationKey) ?? [];
45
+ list.push({ locale: page.locale, path: page.route });
46
+ alternatesByKey.set(page.translationKey, list);
47
+ }
48
+ }
49
+
50
+ const routes: RouteManifestEntry[] = graph.pages.map((page) => ({
51
+ alternates: alternatesByKey.get(page.translationKey) ?? [],
52
+ collection: page.collection ?? "docs",
53
+ contentType: page.contentType,
54
+ draft: page.meta.draft,
55
+ editUrl: page.editUrl,
56
+ entryId: page.entryId ?? page.source.ref,
57
+ hidden: page.meta.sidebar.hidden,
58
+ id: page.id,
59
+ indexable: searchEnabled && contentIndexable(page, config),
60
+ lastModified: page.lastModified,
61
+ locale: page.locale,
62
+ path: page.route,
63
+ source: page.source,
64
+ sourcePath: page.sourcePath,
65
+ title: page.title,
66
+ }));
67
+
68
+ // Fallback materialization: render the fallback locale's content at the
69
+ // localized URL for any translation a non-default locale is missing, so static
70
+ // output is fully prerendered (render-fallback, no client redirect). Fallback
71
+ // routes are not indexed and carry no `hreflang` of their own.
72
+ if (i18n) {
73
+ const fallback = resolveFallbackLocale(i18n);
74
+ if (fallback) {
75
+ const fallbackPages = new Map(
76
+ graph.pages
77
+ .filter((page) => page.locale === fallback)
78
+ .map((page) => [page.translationKey, page] as const)
79
+ );
80
+ for (const { code } of i18n.locales) {
81
+ if (code === fallback) {
82
+ continue;
83
+ }
84
+ const present = new Set(
85
+ graph.pages
86
+ .filter((page) => page.locale === code)
87
+ .map((page) => page.translationKey)
88
+ );
89
+ for (const [key, source] of fallbackPages) {
90
+ if (present.has(key)) {
91
+ continue;
92
+ }
93
+ routes.push({
94
+ alternates: alternatesByKey.get(key) ?? [],
95
+ collection: source.collection ?? "docs",
96
+ contentType: source.contentType,
97
+ draft: source.meta.draft,
98
+ editUrl: source.editUrl,
99
+ entryId: source.entryId ?? source.source.ref,
100
+ fallback: true,
101
+ hidden: source.meta.sidebar.hidden,
102
+ id: source.id,
103
+ indexable: false,
104
+ lastModified: source.lastModified,
105
+ locale: code,
106
+ path: localizeRoute(key, code, i18n),
107
+ source: source.source,
108
+ sourcePath: source.sourcePath,
109
+ title: source.title,
110
+ });
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ routes.sort((a, b) => a.path.localeCompare(b.path));
117
+
118
+ return {
119
+ blumeVersion: getBlumeVersion(),
120
+ contentRoot: context.contentRoot,
121
+ output: config.deployment.output,
122
+ projectRoot: context.root,
123
+ routes,
124
+ version: MANIFEST_VERSION,
125
+ };
126
+ };
@@ -0,0 +1,97 @@
1
+ import { basename, dirname, relative } from "pathe";
2
+ import { glob } from "tinyglobby";
3
+
4
+ import { diagnosticsFromZod } from "./diagnostics.ts";
5
+ import { createModuleLoader } from "./load-module.ts";
6
+ import { folderMetaSchema } from "./schema.ts";
7
+ import type { FolderMeta } from "./schema.ts";
8
+ import type { Diagnostic } from "./types.ts";
9
+
10
+ const META_FILES = [
11
+ "**/meta.ts",
12
+ "**/meta.js",
13
+ "**/meta.mjs",
14
+ // Shared, locale-agnostic folder meta (applies to every locale).
15
+ "**/meta.$.ts",
16
+ "**/meta.$.js",
17
+ "**/meta.$.mjs",
18
+ ];
19
+
20
+ /** Resolve a meta module's default export, calling it if it is a factory. */
21
+ const resolveMeta = async (mod: unknown): Promise<unknown> =>
22
+ typeof mod === "function" ? await (mod as () => unknown)() : mod;
23
+
24
+ /**
25
+ * Discover `meta.{ts,js,mjs}` files under the content root. Keys are the
26
+ * directory path relative to the content root (`""` for the root directory).
27
+ * `meta.$.*` files are returned in `shared` — folder meta that applies to that
28
+ * directory in every locale (a locale-specific `meta.*` overrides it). Each file
29
+ * default-exports an object or a (sync/async) function returning one.
30
+ */
31
+ export const discoverFolderMeta = async (
32
+ contentRoot: string
33
+ ): Promise<{
34
+ meta: Map<string, FolderMeta>;
35
+ shared: Map<string, FolderMeta>;
36
+ diagnostics: Diagnostic[];
37
+ }> => {
38
+ const files = await glob(META_FILES, {
39
+ absolute: true,
40
+ cwd: contentRoot,
41
+ // Never descend into dependencies or build output — relevant when the
42
+ // content root is the project root (e.g. a migrated Mintlify project).
43
+ ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
44
+ onlyFiles: true,
45
+ });
46
+
47
+ const load = createModuleLoader();
48
+ const loaded = await Promise.all(
49
+ files.map(
50
+ async (
51
+ file
52
+ ): Promise<
53
+ | { ok: true; file: string; value: unknown }
54
+ | { ok: false; file: string; error: Error }
55
+ > => {
56
+ try {
57
+ return { file, ok: true, value: await resolveMeta(await load(file)) };
58
+ } catch (error) {
59
+ return { error: error as Error, file, ok: false };
60
+ }
61
+ }
62
+ )
63
+ );
64
+
65
+ const meta = new Map<string, FolderMeta>();
66
+ const shared = new Map<string, FolderMeta>();
67
+ const diagnostics: Diagnostic[] = [];
68
+
69
+ for (const entry of loaded) {
70
+ const dir = relative(contentRoot, dirname(entry.file));
71
+
72
+ if (!entry.ok) {
73
+ diagnostics.push({
74
+ code: "BLUME_META_LOAD_FAILED",
75
+ file: entry.file,
76
+ message: `Could not load meta file: ${entry.error.message}`,
77
+ severity: "error",
78
+ });
79
+ continue;
80
+ }
81
+
82
+ const result = folderMetaSchema.safeParse(entry.value);
83
+ if (result.success) {
84
+ const target = basename(entry.file).startsWith("meta.$.") ? shared : meta;
85
+ target.set(dir, result.data);
86
+ } else {
87
+ diagnostics.push(
88
+ ...diagnosticsFromZod(result.error, {
89
+ code: "BLUME_META_INVALID",
90
+ file: entry.file,
91
+ })
92
+ );
93
+ }
94
+ }
95
+
96
+ return { diagnostics, meta, shared };
97
+ };