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,374 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+
4
+ import matter from "gray-matter";
5
+ import { basename, dirname, extname, join, relative } from "pathe";
6
+ import { glob } from "tinyglobby";
7
+
8
+ import type { BlumeConfig, FolderMeta } from "../../core/schema.ts";
9
+ import { writeBlumeConfig } from "../shared.ts";
10
+ import {
11
+ rewriteNextraCallouts,
12
+ stripNextraImports,
13
+ unsupportedNextraComponents,
14
+ } from "./content.ts";
15
+ import type { NextraPageOverride } from "./frontmatter.ts";
16
+ import { normalizeNextraPageMeta } from "./frontmatter.ts";
17
+ import { parseNextraMeta, toBlumeFolderMeta } from "./meta.ts";
18
+
19
+ export interface NextraMigrationResult {
20
+ moved: number;
21
+ warnings: string[];
22
+ }
23
+
24
+ interface Tab {
25
+ label: string;
26
+ path: string;
27
+ }
28
+
29
+ interface FolderIndex {
30
+ childDirs: Map<string, Set<string>>;
31
+ pagesByDir: Map<string, Map<string, string>>;
32
+ }
33
+
34
+ interface MetaPlan {
35
+ consumedMetas: string[];
36
+ folderTitleByDir: Map<string, string>;
37
+ metaByDir: Map<string, FolderMeta>;
38
+ pageOverrides: Map<string, NextraPageOverride>;
39
+ tabs: Tab[];
40
+ unparseableMetas: { abs: string; rel: string }[];
41
+ warnings: string[];
42
+ }
43
+
44
+ interface PageMove {
45
+ moved: number;
46
+ removed: string[];
47
+ skipped: string | null;
48
+ unsupported: string[];
49
+ }
50
+
51
+ /** Nextra keeps docs under `content/` (App Router) or `pages/` (Pages Router). */
52
+ const SOURCE_DIRS = ["content", "pages"];
53
+
54
+ const PAGE_GLOB = "**/*.{md,mdx}";
55
+ const META_GLOB = "**/_meta.{js,mjs,cjs,ts,jsx,tsx,json}";
56
+ const IGNORE = ["**/node_modules/**"];
57
+
58
+ /** Normalize a `dirname` result so the content root is "" rather than ".". */
59
+ const normalizeDir = (dir: string): string => (dir === "." ? "" : dir);
60
+
61
+ const mergeOverride = (
62
+ map: Map<string, NextraPageOverride>,
63
+ key: string,
64
+ patch: NextraPageOverride
65
+ ): void => {
66
+ map.set(key, { ...map.get(key), ...patch });
67
+ };
68
+
69
+ /** Index which child slugs in each folder are pages vs subdirectories. */
70
+ const indexFolders = (
71
+ base: string,
72
+ pageFiles: string[],
73
+ metaFiles: string[]
74
+ ): FolderIndex => {
75
+ const pagesByDir = new Map<string, Map<string, string>>();
76
+ const allDirs = new Set<string>([""]);
77
+ const registerDir = (dir: string): void => {
78
+ let current = normalizeDir(dir);
79
+ allDirs.add(current);
80
+ while (current !== "") {
81
+ current = normalizeDir(dirname(current));
82
+ allDirs.add(current);
83
+ }
84
+ };
85
+
86
+ for (const abs of pageFiles) {
87
+ const rel = relative(base, abs);
88
+ const dir = normalizeDir(dirname(rel));
89
+ const slug = basename(rel).replace(/\.mdx?$/u, "");
90
+ const folder = pagesByDir.get(dir) ?? new Map<string, string>();
91
+ folder.set(slug, abs);
92
+ pagesByDir.set(dir, folder);
93
+ registerDir(dir);
94
+ }
95
+ for (const abs of metaFiles) {
96
+ registerDir(normalizeDir(dirname(relative(base, abs))));
97
+ }
98
+
99
+ const childDirs = new Map<string, Set<string>>();
100
+ for (const dir of allDirs) {
101
+ if (dir === "") {
102
+ continue;
103
+ }
104
+ const parent = normalizeDir(dirname(dir));
105
+ const children = childDirs.get(parent) ?? new Set<string>();
106
+ children.add(basename(dir));
107
+ childDirs.set(parent, children);
108
+ }
109
+
110
+ return { childDirs, pagesByDir };
111
+ };
112
+
113
+ interface ParsedMeta {
114
+ abs: string;
115
+ ext: string;
116
+ raw: string;
117
+ rel: string;
118
+ }
119
+
120
+ /** Parse every `_meta`, accumulating ordering, titles, and page overrides. */
121
+ const planMetas = (metas: ParsedMeta[], index: FolderIndex): MetaPlan => {
122
+ const plan: MetaPlan = {
123
+ consumedMetas: [],
124
+ folderTitleByDir: new Map(),
125
+ metaByDir: new Map(),
126
+ pageOverrides: new Map(),
127
+ tabs: [],
128
+ unparseableMetas: [],
129
+ warnings: [],
130
+ };
131
+
132
+ for (const meta of metas) {
133
+ const dir = normalizeDir(dirname(meta.rel));
134
+ const entries = parseNextraMeta(meta.raw, meta.ext);
135
+ if (!entries) {
136
+ plan.unparseableMetas.push({ abs: meta.abs, rel: meta.rel });
137
+ continue;
138
+ }
139
+
140
+ const conversion = toBlumeFolderMeta(entries, {
141
+ hasDir: (slug) => index.childDirs.get(dir)?.has(slug) ?? false,
142
+ hasPage: (slug) => index.pagesByDir.get(dir)?.has(slug) ?? false,
143
+ });
144
+
145
+ plan.metaByDir.set(dir, conversion.folderMeta);
146
+ plan.consumedMetas.push(meta.abs);
147
+ for (const [slug, title] of Object.entries(conversion.folderTitles)) {
148
+ plan.folderTitleByDir.set(normalizeDir(join(dir, slug)), title);
149
+ }
150
+ for (const [slug, label] of Object.entries(conversion.pageLabels)) {
151
+ const pageAbs = index.pagesByDir.get(dir)?.get(slug);
152
+ if (pageAbs) {
153
+ mergeOverride(plan.pageOverrides, pageAbs, { label });
154
+ }
155
+ }
156
+ for (const slug of conversion.hiddenPages) {
157
+ const pageAbs = index.pagesByDir.get(dir)?.get(slug);
158
+ if (pageAbs) {
159
+ mergeOverride(plan.pageOverrides, pageAbs, { hidden: true });
160
+ }
161
+ }
162
+ if (dir === "") {
163
+ plan.tabs.push(...conversion.navPages);
164
+ } else if (conversion.navPages.length > 0) {
165
+ plan.warnings.push(
166
+ `Ignored ${conversion.navPages.length} top-nav page entr(y/ies) in ${meta.rel}; type:"page" only maps at the root.`
167
+ );
168
+ }
169
+ plan.warnings.push(...conversion.warnings);
170
+ }
171
+
172
+ return plan;
173
+ };
174
+
175
+ /** Move and rewrite a single page into `docs/`. */
176
+ const movePage = async (
177
+ abs: string,
178
+ options: {
179
+ base: string;
180
+ overrides: Map<string, NextraPageOverride>;
181
+ root: string;
182
+ }
183
+ ): Promise<PageMove> => {
184
+ const rel = relative(options.base, abs);
185
+ const dest = join(options.root, "docs", rel);
186
+ if (existsSync(dest)) {
187
+ return { moved: 0, removed: [], skipped: rel, unsupported: [] };
188
+ }
189
+ const raw = await readFile(abs, "utf-8");
190
+ const text = rewriteNextraCallouts(stripNextraImports(raw));
191
+ const unsupported = unsupportedNextraComponents(text);
192
+ const parsed = matter(text);
193
+ const { data, removed } = normalizeNextraPageMeta(
194
+ parsed.data,
195
+ options.overrides.get(abs) ?? {}
196
+ );
197
+ const content =
198
+ Object.keys(data).length > 0
199
+ ? matter.stringify(parsed.content, data)
200
+ : parsed.content;
201
+ await mkdir(dirname(dest), { recursive: true });
202
+ await writeFile(dest, content, "utf-8");
203
+ await rm(abs, { force: true });
204
+ return { moved: 1, removed, skipped: null, unsupported };
205
+ };
206
+
207
+ interface PageSummary {
208
+ moved: number;
209
+ removedKeys: string[];
210
+ skipped: string[];
211
+ unsupported: string[];
212
+ }
213
+
214
+ const summarizePageMoves = (results: PageMove[]): PageSummary => {
215
+ let moved = 0;
216
+ const removedKeys = new Set<string>();
217
+ const unsupported = new Set<string>();
218
+ const skipped: string[] = [];
219
+ for (const result of results) {
220
+ moved += result.moved;
221
+ if (result.skipped) {
222
+ skipped.push(result.skipped);
223
+ }
224
+ for (const key of result.removed) {
225
+ removedKeys.add(key);
226
+ }
227
+ for (const name of result.unsupported) {
228
+ unsupported.add(name);
229
+ }
230
+ }
231
+ return {
232
+ moved,
233
+ removedKeys: [...removedKeys],
234
+ skipped,
235
+ unsupported: [...unsupported],
236
+ };
237
+ };
238
+
239
+ /** Write a `meta.ts` per folder, merging own ordering with a parent title. */
240
+ const writeFolderMetas = async (
241
+ root: string,
242
+ plan: MetaPlan
243
+ ): Promise<void> => {
244
+ const dirs = new Set<string>([
245
+ ...plan.metaByDir.keys(),
246
+ ...plan.folderTitleByDir.keys(),
247
+ ]);
248
+ await Promise.all(
249
+ [...dirs].map(async (dir) => {
250
+ const finalMeta: FolderMeta = { ...plan.metaByDir.get(dir) };
251
+ const title = plan.folderTitleByDir.get(dir);
252
+ if (title !== undefined) {
253
+ finalMeta.title = title;
254
+ }
255
+ if (Object.keys(finalMeta).length === 0) {
256
+ return;
257
+ }
258
+ const dest = join(root, "docs", dir, "meta.ts");
259
+ await mkdir(dirname(dest), { recursive: true });
260
+ await writeFile(
261
+ dest,
262
+ `import { defineMeta } from "blume";\n\nexport default defineMeta(${JSON.stringify(finalMeta, null, 2)});\n`,
263
+ "utf-8"
264
+ );
265
+ })
266
+ );
267
+ };
268
+
269
+ /** Relocate `_meta` files we couldn't parse, untouched, for manual conversion. */
270
+ const relocateUnparseableMetas = async (
271
+ root: string,
272
+ metas: { abs: string; rel: string }[]
273
+ ): Promise<string[]> => {
274
+ const warnings: string[] = [];
275
+ await Promise.all(
276
+ metas.map(async ({ abs, rel }) => {
277
+ const dest = join(root, "docs", rel);
278
+ if (existsSync(dest)) {
279
+ warnings.push(`Skipped ${rel} (target already exists)`);
280
+ return;
281
+ }
282
+ await mkdir(dirname(dest), { recursive: true });
283
+ await rename(abs, dest);
284
+ warnings.push(
285
+ `Could not parse ${rel}; moved as-is — convert it to meta.ts by hand.`
286
+ );
287
+ })
288
+ );
289
+ return warnings;
290
+ };
291
+
292
+ const buildConfig = (tabs: Tab[]): BlumeConfig =>
293
+ tabs.length > 0
294
+ ? { navigation: { tabs }, title: "Documentation" }
295
+ : { title: "Documentation" };
296
+
297
+ /**
298
+ * Migrate a Nextra project to Blume: move every `.md`/`.mdx` page into `docs/`,
299
+ * rewrite Nextra `<Callout>`s to directives, and convert each `_meta` file into
300
+ * a typed `meta.ts`. Nextra declares a folder's title (and per-page sidebar
301
+ * labels) in the *parent* `_meta`, so those are propagated into child `meta.ts`
302
+ * titles and page frontmatter. Content lands in the default `docs/` root.
303
+ */
304
+ export const migrateNextraProject = async (
305
+ root: string
306
+ ): Promise<NextraMigrationResult> => {
307
+ const sourceDir = SOURCE_DIRS.find((dir) => existsSync(join(root, dir)));
308
+ if (!sourceDir) {
309
+ await writeBlumeConfig(root, { title: "Documentation" });
310
+ return {
311
+ moved: 0,
312
+ warnings: [
313
+ "No Nextra content directory (content/ or pages/) found; wrote a default config.",
314
+ ],
315
+ };
316
+ }
317
+ const base = join(root, sourceDir);
318
+
319
+ const pageFiles = await glob([PAGE_GLOB], {
320
+ absolute: true,
321
+ cwd: base,
322
+ ignore: IGNORE,
323
+ });
324
+ const metaFiles = await glob([META_GLOB], {
325
+ absolute: true,
326
+ cwd: base,
327
+ ignore: IGNORE,
328
+ });
329
+
330
+ const index = indexFolders(base, pageFiles, metaFiles);
331
+ const metas = await Promise.all(
332
+ metaFiles.map(async (abs) => ({
333
+ abs,
334
+ ext: extname(abs),
335
+ raw: await readFile(abs, "utf-8"),
336
+ rel: relative(base, abs),
337
+ }))
338
+ );
339
+ const plan = planMetas(metas, index);
340
+
341
+ const moves = await Promise.all(
342
+ pageFiles.map((abs) =>
343
+ movePage(abs, { base, overrides: plan.pageOverrides, root })
344
+ )
345
+ );
346
+ const pages = summarizePageMoves(moves);
347
+
348
+ await writeFolderMetas(root, plan);
349
+ await Promise.all(plan.consumedMetas.map((abs) => rm(abs, { force: true })));
350
+ const relocateWarnings = await relocateUnparseableMetas(
351
+ root,
352
+ plan.unparseableMetas
353
+ );
354
+ await writeBlumeConfig(root, buildConfig(plan.tabs));
355
+
356
+ const warnings = [
357
+ ...plan.warnings,
358
+ ...pages.skipped.map((rel) => `Skipped ${rel} (target already exists)`),
359
+ ...relocateWarnings,
360
+ ];
361
+ if (pages.removedKeys.length > 0) {
362
+ warnings.push(
363
+ `Dropped unsupported page frontmatter keys: ${pages.removedKeys.join(", ")}.`
364
+ );
365
+ }
366
+ if (pages.unsupported.length > 0) {
367
+ warnings.push(
368
+ `Components without a drop-in Blume equivalent need manual review: ${pages.unsupported.join(", ")}.`
369
+ );
370
+ }
371
+ warnings.push("Review blume.config.ts and the generated meta.ts files.");
372
+
373
+ return { moved: pages.moved, warnings };
374
+ };
@@ -0,0 +1,266 @@
1
+ import type { FolderMeta } from "../../core/schema.ts";
2
+ import {
3
+ parseKey,
4
+ readString,
5
+ scanObject,
6
+ splitKeyValue,
7
+ stripJsComments,
8
+ } from "../shared.ts";
9
+
10
+ /**
11
+ * Read Nextra `_meta` files (ordering + per-entry config) and map them onto
12
+ * Blume's folder `meta.ts` shape. `_meta.js`/`_meta.ts` are parsed by extracting
13
+ * their default-exported object literal as text — Nextra meta is overwhelmingly
14
+ * a map of string titles and simple config objects — rather than executing user
15
+ * code (matching the other migrators, which never eval). Entries whose value is
16
+ * an expression (JSX title, imported component, computed) keep their slug for
17
+ * ordering but are otherwise flagged `unparseable`. The low-level JS scanners
18
+ * live in `../shared.ts` so the Starlight migrator reuses them.
19
+ */
20
+
21
+ export interface NextraMetaEntry {
22
+ display?: string;
23
+ href?: string;
24
+ newWindow?: boolean;
25
+ slug: string;
26
+ title?: string;
27
+ type?: string;
28
+ /** The value couldn't be read beyond its key (e.g. a JSX/expression title). */
29
+ unparseable: boolean;
30
+ }
31
+
32
+ const DEFAULT_EXPORT = /(?:export\s+default|module\.exports\s*=)\s*/u;
33
+
34
+ /** Locate and split the default-exported object literal into raw entries. */
35
+ const extractObjectEntries = (source: string): string[] | null => {
36
+ const clean = stripJsComments(source);
37
+ const marker = DEFAULT_EXPORT.exec(clean);
38
+ if (!marker) {
39
+ return null;
40
+ }
41
+ let index = marker.index + marker[0].length;
42
+ while (index < clean.length && /\s/u.test(clean[index] ?? "")) {
43
+ index += 1;
44
+ }
45
+ if (clean[index] !== "{") {
46
+ return null;
47
+ }
48
+ return scanObject(clean, index)?.entries ?? null;
49
+ };
50
+
51
+ const applyField = (
52
+ target: NextraMetaEntry,
53
+ key: string,
54
+ value: string
55
+ ): void => {
56
+ if (key === "newWindow") {
57
+ const flag = value.trim();
58
+ if (flag === "true") {
59
+ target.newWindow = true;
60
+ } else if (flag === "false") {
61
+ target.newWindow = false;
62
+ }
63
+ return;
64
+ }
65
+ const str = readString(value);
66
+ if (str === null) {
67
+ return;
68
+ }
69
+ if (key === "title") {
70
+ target.title = str;
71
+ } else if (key === "type") {
72
+ target.type = str;
73
+ } else if (key === "display") {
74
+ target.display = str;
75
+ } else if (key === "href") {
76
+ target.href = str;
77
+ }
78
+ };
79
+
80
+ const entryFromObjectEntries = (
81
+ slug: string,
82
+ entries: string[]
83
+ ): NextraMetaEntry => {
84
+ const entry: NextraMetaEntry = { slug, unparseable: false };
85
+ for (const raw of entries) {
86
+ const kv = splitKeyValue(raw);
87
+ if (kv?.key) {
88
+ applyField(entry, parseKey(kv.key), kv.value);
89
+ }
90
+ }
91
+ return entry;
92
+ };
93
+
94
+ const entryFromValue = (slug: string, value: string): NextraMetaEntry => {
95
+ const trimmed = value.trim();
96
+ const [first] = trimmed;
97
+ if (first === '"' || first === "'" || first === "`") {
98
+ const str = readString(trimmed);
99
+ return str === null
100
+ ? { slug, unparseable: true }
101
+ : { slug, title: str, unparseable: false };
102
+ }
103
+ if (first === "{") {
104
+ const scan = scanObject(trimmed, 0);
105
+ if (scan) {
106
+ return entryFromObjectEntries(slug, scan.entries);
107
+ }
108
+ }
109
+ return { slug, unparseable: true };
110
+ };
111
+
112
+ const entryFromJson = (slug: string, value: unknown): NextraMetaEntry => {
113
+ if (typeof value === "string") {
114
+ return { slug, title: value, unparseable: false };
115
+ }
116
+ if (value && typeof value === "object" && !Array.isArray(value)) {
117
+ const obj = value as Record<string, unknown>;
118
+ const entry: NextraMetaEntry = { slug, unparseable: false };
119
+ if (typeof obj.title === "string") {
120
+ entry.title = obj.title;
121
+ }
122
+ if (typeof obj.type === "string") {
123
+ entry.type = obj.type;
124
+ }
125
+ if (typeof obj.display === "string") {
126
+ entry.display = obj.display;
127
+ }
128
+ if (typeof obj.href === "string") {
129
+ entry.href = obj.href;
130
+ }
131
+ if (typeof obj.newWindow === "boolean") {
132
+ entry.newWindow = obj.newWindow;
133
+ }
134
+ return entry;
135
+ }
136
+ return { slug, unparseable: true };
137
+ };
138
+
139
+ /**
140
+ * Parse a Nextra `_meta` file into ordered entries, or null if no object
141
+ * literal could be located (the caller then moves the file as-is and warns).
142
+ */
143
+ export const parseNextraMeta = (
144
+ source: string,
145
+ ext: string
146
+ ): NextraMetaEntry[] | null => {
147
+ if (ext === ".json") {
148
+ let parsed: unknown;
149
+ try {
150
+ parsed = JSON.parse(source);
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
155
+ return null;
156
+ }
157
+ return Object.entries(parsed as Record<string, unknown>).map(
158
+ ([slug, value]) => entryFromJson(slug, value)
159
+ );
160
+ }
161
+
162
+ const rawEntries = extractObjectEntries(source);
163
+ if (!rawEntries) {
164
+ return null;
165
+ }
166
+ const result: NextraMetaEntry[] = [];
167
+ for (const raw of rawEntries) {
168
+ const kv = splitKeyValue(raw);
169
+ if (kv?.key) {
170
+ result.push(entryFromValue(parseKey(kv.key), kv.value));
171
+ }
172
+ }
173
+ return result;
174
+ };
175
+
176
+ export interface NextraMetaConversion {
177
+ folderMeta: FolderMeta;
178
+ /** Child subfolder slug -> title (Nextra declares a folder's title in its parent). */
179
+ folderTitles: Record<string, string>;
180
+ /** Child page slugs to hide from the sidebar (`display: "hidden"`). */
181
+ hiddenPages: string[];
182
+ /** `type: "page"` entries -> top-nav tabs (only used for the root meta). */
183
+ navPages: { label: string; path: string }[];
184
+ /** Child page slug -> sidebar label (a Nextra `_meta` string title). */
185
+ pageLabels: Record<string, string>;
186
+ warnings: string[];
187
+ }
188
+
189
+ /**
190
+ * Map parsed Nextra entries onto a Blume `FolderMeta` (`pages` ordering) plus
191
+ * the cross-file overrides the orchestrator threads into child `meta.ts` titles
192
+ * and page frontmatter. `hasPage`/`hasDir` report whether a slug resolves to a
193
+ * content file or a subdirectory in this folder.
194
+ */
195
+ export const toBlumeFolderMeta = (
196
+ entries: NextraMetaEntry[],
197
+ options: {
198
+ hasDir: (slug: string) => boolean;
199
+ hasPage: (slug: string) => boolean;
200
+ }
201
+ ): NextraMetaConversion => {
202
+ const pages: string[] = [];
203
+ const folderTitles: Record<string, string> = {};
204
+ const pageLabels: Record<string, string> = {};
205
+ const hiddenPages: string[] = [];
206
+ const navPages: { label: string; path: string }[] = [];
207
+ const warnings: string[] = [];
208
+
209
+ for (const entry of entries) {
210
+ const { slug } = entry;
211
+ const label = entry.title ?? slug;
212
+
213
+ if (entry.type === "separator") {
214
+ warnings.push(
215
+ `Dropped separator "${label}" — recreate it with a sidebar group if needed.`
216
+ );
217
+ continue;
218
+ }
219
+ if (entry.href) {
220
+ warnings.push(
221
+ `External link "${label}" (${entry.href}) — add it to navbar.links manually.`
222
+ );
223
+ continue;
224
+ }
225
+ if (entry.type === "menu") {
226
+ warnings.push(
227
+ `Dropped navbar menu "${label}" — recreate it via navbar/navigation config.`
228
+ );
229
+ continue;
230
+ }
231
+ if (entry.type === "page") {
232
+ navPages.push({ label, path: slug === "index" ? "/" : `/${slug}` });
233
+ continue;
234
+ }
235
+
236
+ const isPage = options.hasPage(slug);
237
+ const isDir = options.hasDir(slug);
238
+ if (!(isPage || isDir)) {
239
+ continue;
240
+ }
241
+ if (entry.display === "hidden") {
242
+ if (isPage) {
243
+ hiddenPages.push(slug);
244
+ }
245
+ continue;
246
+ }
247
+
248
+ pages.push(slug);
249
+ if (entry.title) {
250
+ if (isDir) {
251
+ folderTitles[slug] = entry.title;
252
+ } else {
253
+ pageLabels[slug] = entry.title;
254
+ }
255
+ }
256
+ }
257
+
258
+ return {
259
+ folderMeta: pages.length > 0 ? { pages } : {},
260
+ folderTitles,
261
+ hiddenPages,
262
+ navPages,
263
+ pageLabels,
264
+ warnings,
265
+ };
266
+ };