blume 0.1.0 → 0.1.2

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 (106) hide show
  1. package/dist/cli/index.js +1213 -473
  2. package/dist/cli/index.js.map +33 -29
  3. package/dist/types/core/bridge.d.ts +24 -0
  4. package/dist/types/core/config.d.ts +35 -0
  5. package/dist/types/core/data.d.ts +129 -0
  6. package/dist/types/core/define-components.d.ts +27 -0
  7. package/dist/types/core/define-meta.d.ts +16 -0
  8. package/dist/types/core/deployment-env.d.ts +9 -0
  9. package/dist/types/core/diagnostics.d.ts +17 -0
  10. package/dist/types/core/i18n-ui.d.ts +500 -0
  11. package/dist/types/core/load-module.d.ts +7 -0
  12. package/dist/types/core/package-root.d.ts +17 -0
  13. package/dist/types/core/project.d.ts +9 -0
  14. package/dist/types/core/schema.d.ts +3461 -0
  15. package/dist/types/core/sources/types.d.ts +107 -0
  16. package/dist/types/core/types.d.ts +245 -0
  17. package/dist/types/core/ui-packs/ar.d.ts +3 -0
  18. package/dist/types/core/ui-packs/bg.d.ts +3 -0
  19. package/dist/types/core/ui-packs/bn.d.ts +3 -0
  20. package/dist/types/core/ui-packs/ca.d.ts +3 -0
  21. package/dist/types/core/ui-packs/cs.d.ts +3 -0
  22. package/dist/types/core/ui-packs/da.d.ts +3 -0
  23. package/dist/types/core/ui-packs/de.d.ts +3 -0
  24. package/dist/types/core/ui-packs/el.d.ts +3 -0
  25. package/dist/types/core/ui-packs/es.d.ts +3 -0
  26. package/dist/types/core/ui-packs/fa.d.ts +3 -0
  27. package/dist/types/core/ui-packs/fi.d.ts +3 -0
  28. package/dist/types/core/ui-packs/fr.d.ts +3 -0
  29. package/dist/types/core/ui-packs/he.d.ts +3 -0
  30. package/dist/types/core/ui-packs/hi.d.ts +3 -0
  31. package/dist/types/core/ui-packs/hr.d.ts +3 -0
  32. package/dist/types/core/ui-packs/hu.d.ts +3 -0
  33. package/dist/types/core/ui-packs/id.d.ts +3 -0
  34. package/dist/types/core/ui-packs/index.d.ts +13 -0
  35. package/dist/types/core/ui-packs/it.d.ts +3 -0
  36. package/dist/types/core/ui-packs/ja.d.ts +3 -0
  37. package/dist/types/core/ui-packs/ko.d.ts +3 -0
  38. package/dist/types/core/ui-packs/nl.d.ts +3 -0
  39. package/dist/types/core/ui-packs/no.d.ts +3 -0
  40. package/dist/types/core/ui-packs/pl.d.ts +3 -0
  41. package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
  42. package/dist/types/core/ui-packs/pt.d.ts +3 -0
  43. package/dist/types/core/ui-packs/ro.d.ts +3 -0
  44. package/dist/types/core/ui-packs/ru.d.ts +3 -0
  45. package/dist/types/core/ui-packs/sk.d.ts +3 -0
  46. package/dist/types/core/ui-packs/sr.d.ts +3 -0
  47. package/dist/types/core/ui-packs/sv.d.ts +3 -0
  48. package/dist/types/core/ui-packs/th.d.ts +3 -0
  49. package/dist/types/core/ui-packs/tr.d.ts +3 -0
  50. package/dist/types/core/ui-packs/uk.d.ts +3 -0
  51. package/dist/types/core/ui-packs/vi.d.ts +3 -0
  52. package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
  53. package/dist/types/core/ui-packs/zh.d.ts +3 -0
  54. package/dist/types/core/version.d.ts +8 -0
  55. package/dist/types/index.d.ts +10 -0
  56. package/dist/types/migrate/mintlify/config.d.ts +2 -0
  57. package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
  58. package/dist/types/theme/fonts.d.ts +163 -0
  59. package/docs/advanced/custom-pages.mdx +91 -10
  60. package/docs/configuration/index.mdx +19 -0
  61. package/docs/content/components.mdx +103 -1
  62. package/package.json +13 -3
  63. package/src/ai/llms.ts +1 -2
  64. package/src/astro/examples.ts +106 -0
  65. package/src/astro/generate.ts +300 -75
  66. package/src/astro/index.ts +1 -1
  67. package/src/astro/islands.ts +1 -1
  68. package/src/astro/pages.ts +59 -0
  69. package/src/astro/templates.ts +180 -22
  70. package/src/cli/commands/validate.ts +1 -0
  71. package/src/components/content/CodeBlock.astro +28 -0
  72. package/src/components/content/Component.astro +65 -0
  73. package/src/components/content/Diff.astro +44 -0
  74. package/src/components/content/Tab.astro +8 -2
  75. package/src/components/content/diff.ts +95 -0
  76. package/src/components/layout/Favicon.astro +11 -4
  77. package/src/components/layout/PageLayout.astro +173 -0
  78. package/src/components/layout/ReferenceLayout.astro +6 -1
  79. package/src/components/layout/RootLayout.astro +9 -5
  80. package/src/components/layout/head-scripts.ts +19 -0
  81. package/src/core/data.ts +128 -0
  82. package/src/core/frontmatter.ts +43 -0
  83. package/src/core/links.ts +26 -10
  84. package/src/core/schema.ts +8 -0
  85. package/src/core/sources/filesystem.ts +1 -1
  86. package/src/core/sources/mdx-remote.ts +1 -2
  87. package/src/core/sources/mintlify.ts +1 -1
  88. package/src/core/sources/normalize.ts +19 -3
  89. package/src/core/sources/notion.ts +1 -1
  90. package/src/core/sources/sanity.ts +1 -1
  91. package/src/core/tsconfig-aliases.ts +201 -0
  92. package/src/index.ts +12 -0
  93. package/src/markdown/index.ts +69 -0
  94. package/src/migrate/fumadocs/config.ts +53 -4
  95. package/src/migrate/fumadocs/content.ts +1 -1
  96. package/src/migrate/fumadocs/groups.ts +230 -0
  97. package/src/migrate/fumadocs/index.ts +104 -19
  98. package/src/migrate/fumadocs/meta.ts +140 -10
  99. package/src/migrate/mintlify/snippets.ts +2 -1
  100. package/src/migrate/mintlify/transform.ts +1 -2
  101. package/src/migrate/nextra/index.ts +1 -1
  102. package/src/migrate/shared.ts +98 -1
  103. package/src/migrate/starlight/index.ts +1 -1
  104. package/src/registry/eject.ts +57 -10
  105. package/src/search/documents.ts +1 -2
  106. package/src/theme/entry.ts +29 -0
@@ -1,11 +1,17 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
 
4
- import matter from "gray-matter";
5
4
  import { dirname, join, relative } from "pathe";
6
5
  import { glob } from "tinyglobby";
7
6
 
8
- import { writeBlumeConfig } from "../shared.ts";
7
+ import matter from "../../core/frontmatter.ts";
8
+ import type { FolderMeta } from "../../core/schema.ts";
9
+ import {
10
+ ensureGitignore,
11
+ leftoverFiles,
12
+ rewriteFrameworkScripts,
13
+ writeBlumeConfig,
14
+ } from "../shared.ts";
9
15
  import { loadFumadocsConfig } from "./config.ts";
10
16
  import {
11
17
  inlineFumadocsIncludes,
@@ -16,7 +22,13 @@ import {
16
22
  unsupportedFumadocsComponents,
17
23
  } from "./content.ts";
18
24
  import { normalizeFumadocsPageMeta } from "./frontmatter.ts";
19
- import { translateFumadocsMeta } from "./meta.ts";
25
+ import { reshapeFumadocsGroups } from "./groups.ts";
26
+ import {
27
+ parseFumadocsPages,
28
+ renderMetaModule,
29
+ translateFumadocsMeta,
30
+ translateFumadocsSelfMeta,
31
+ } from "./meta.ts";
20
32
 
21
33
  export interface FumadocsMigrationResult {
22
34
  moved: number;
@@ -29,6 +41,23 @@ const PAGE_GLOB = "**/*.{md,mdx}";
29
41
  const META_GLOB = "**/meta.json";
30
42
  const IGNORE = ["**/node_modules/**"];
31
43
 
44
+ /**
45
+ * Old Next/Fumadocs scaffolding a migration leaves behind. Blume can't safely
46
+ * auto-delete these (they may hold custom code), so the ones that exist are
47
+ * surfaced as a "safe to delete" checklist once the migration is verified.
48
+ */
49
+ const FUMADOCS_LEFTOVERS = [
50
+ "next.config.ts",
51
+ "next.config.mjs",
52
+ "next.config.js",
53
+ "source.config.ts",
54
+ "source.config.mjs",
55
+ "source.config.js",
56
+ "mdx-components.tsx",
57
+ "next-env.d.ts",
58
+ "app",
59
+ ];
60
+
32
61
  interface PageResult {
33
62
  includeWarnings: string[];
34
63
  moved: number;
@@ -82,6 +111,24 @@ const movePage = async (
82
111
  };
83
112
  };
84
113
 
114
+ /** Write a `FolderMeta` to `dest` unless it already exists. */
115
+ const writeMeta = async (
116
+ dest: string,
117
+ meta: FolderMeta,
118
+ rel: string,
119
+ warnings: string[]
120
+ ): Promise<string[]> => {
121
+ if (Object.keys(meta).length === 0) {
122
+ return warnings;
123
+ }
124
+ if (existsSync(dest)) {
125
+ return [...warnings, `Skipped ${rel} (target already exists)`];
126
+ }
127
+ await mkdir(dirname(dest), { recursive: true });
128
+ await writeFile(dest, renderMetaModule(meta), "utf-8");
129
+ return warnings;
130
+ };
131
+
85
132
  /** Convert one `meta.json` into a typed `meta.ts`, or relocate it if unparseable. */
86
133
  const convertMeta = async (
87
134
  abs: string,
@@ -105,23 +152,32 @@ const convertMeta = async (
105
152
  ];
106
153
  }
107
154
 
108
- const { meta, warnings } = translateFumadocsMeta(parsed);
109
155
  const dir = dirname(rel) === "." ? "" : dirname(rel);
110
- if (Object.keys(meta).length > 0) {
111
- const dest = join(root, "docs", dir, "meta.ts");
112
- if (existsSync(dest)) {
113
- await rm(abs, { force: true });
114
- return [...warnings, `Skipped ${rel} (target already exists)`];
156
+ const docsDir = join(root, "docs", dir);
157
+ const dest = join(docsDir, "meta.ts");
158
+
159
+ // A `pages` array with `---Section---` separators can't round-trip through a
160
+ // flat `meta.ts` ordering, so rebuild its sections as group folders instead.
161
+ const structure = parseFumadocsPages((parsed as { pages?: unknown }).pages);
162
+ if (structure.hasSections && !existsSync(dest)) {
163
+ const self = translateFumadocsSelfMeta(parsed);
164
+ const reshape = await reshapeFumadocsGroups(structure, docsDir);
165
+ const meta: FolderMeta = { ...self.meta };
166
+ if (reshape.order.length > 0) {
167
+ meta.pages = reshape.order;
115
168
  }
116
- await mkdir(dirname(dest), { recursive: true });
117
- await writeFile(
118
- dest,
119
- `import { defineMeta } from "blume";\n\nexport default defineMeta(${JSON.stringify(meta, null, 2)});\n`,
120
- "utf-8"
121
- );
169
+ const result = await writeMeta(dest, meta, rel, [
170
+ ...self.warnings,
171
+ ...reshape.warnings,
172
+ ]);
173
+ await rm(abs, { force: true });
174
+ return result;
122
175
  }
176
+
177
+ const { meta, warnings } = translateFumadocsMeta(parsed);
178
+ const result = await writeMeta(dest, meta, rel, warnings);
123
179
  await rm(abs, { force: true });
124
- return warnings;
180
+ return result;
125
181
  };
126
182
 
127
183
  interface PageSummary {
@@ -221,14 +277,32 @@ export const migrateFumadocsProject = async (
221
277
  pageFiles.map((abs) => movePage(abs, base, root))
222
278
  );
223
279
  const pages = summarizePages(pageResults);
224
- const metaResults = await Promise.all(
225
- metaFiles.map((abs) => convertMeta(abs, base, root))
280
+ // Convert metas deepest-first and sequentially: a parent's group-folder
281
+ // reshape can move a child folder, so the child's own reshape must finish
282
+ // first (and the two can't race over the same `docs/` paths).
283
+ const orderedMetas = metaFiles.toSorted(
284
+ (a, b) => b.split("/").length - a.split("/").length
226
285
  );
227
- const metaWarnings = metaResults.flat();
286
+ const metaWarnings: string[] = [];
287
+ for (const abs of orderedMetas) {
288
+ // oxlint-disable-next-line no-await-in-loop -- sequential to avoid move races
289
+ metaWarnings.push(...(await convertMeta(abs, base, root)));
290
+ }
228
291
 
229
292
  await writeBlumeConfig(root, config);
230
293
  const cleanupWarnings = await cleanupSourceDirs(root);
231
294
 
295
+ // Tear down the old Next/Fumadocs scaffolding so the project builds as Blume:
296
+ // repoint the npm scripts, ignore Blume's outputs, and list the leftover
297
+ // framework files to delete by hand.
298
+ const scriptsRewritten = await rewriteFrameworkScripts(
299
+ root,
300
+ /\bnext\b/u,
301
+ /fumadocs/u
302
+ );
303
+ const gitignoreAdded = await ensureGitignore(root, [".blume/", "dist/"]);
304
+ const leftovers = leftoverFiles(root, FUMADOCS_LEFTOVERS);
305
+
232
306
  const warnings = [
233
307
  ...configWarnings,
234
308
  ...metaWarnings,
@@ -246,6 +320,17 @@ export const migrateFumadocsProject = async (
246
320
  `Components without a drop-in Blume equivalent need manual review: ${pages.unsupported.join(", ")}.`
247
321
  );
248
322
  }
323
+ if (scriptsRewritten) {
324
+ warnings.push("Repointed the dev/build/start scripts at the Blume CLI.");
325
+ }
326
+ if (gitignoreAdded.length > 0) {
327
+ warnings.push(`Added ${gitignoreAdded.join(", ")} to .gitignore.`);
328
+ }
329
+ if (leftovers.length > 0) {
330
+ warnings.push(
331
+ `Safe to delete once the migration looks right: ${leftovers.join(", ")}. Also drop the \`next\` plugin from tsconfig.json and the \`.next\`/\`.source\` lines from .gitignore.`
332
+ );
333
+ }
249
334
  warnings.push("Review blume.config.ts and the generated meta.ts files.");
250
335
 
251
336
  return { moved: pages.moved, warnings };
@@ -10,9 +10,16 @@ import type { FolderMeta } from "../../core/schema.ts";
10
10
  * - `root: true` -> `display: "page"` (the closest analogue to a page-tree root).
11
11
  * - `description` -> dropped (folders carry no description in Blume).
12
12
  * - `pages` entries: plain slugs are kept as ordering; `"..."` (the rest marker,
13
- * already Blume's default) is dropped silently; `"---Section---"` separators
14
- * and `"[Text](url)"` links have no folder-meta home and are dropped with a
15
- * warning.
13
+ * already Blume's default) is dropped silently; `"...folder"` (the *extract*
14
+ * operator, which inlines a folder's children) keeps the folder's place in the
15
+ * ordering as a plain `"folder"` key and warns that it renders as a normal
16
+ * group rather than flattened; `"---Section---"` separators and `"[Text](url)"`
17
+ * links have no folder-meta home and are dropped with a warning.
18
+ *
19
+ * When a `pages` array carries `---Section---` separators, the migrator instead
20
+ * takes the *structural* path ({@link parseFumadocsPages} + the group-folder
21
+ * reshape in `index.ts`), which rebuilds each section as a Blume group folder
22
+ * rather than flattening it through `filterPages`.
16
23
  */
17
24
 
18
25
  interface FumadocsMeta {
@@ -27,6 +34,8 @@ interface FumadocsMeta {
27
34
  const SEPARATOR = /^---(?<label>.*)---$/u;
28
35
  const LINK = /^(?:\[[^\]]*\])?\[(?<text>[^\]]*)\]\((?<href>[^)]*)\)$/u;
29
36
  const REST = "...";
37
+ /** Fumadocs *extract* operator: `...folder` inlines that folder's children. */
38
+ const EXTRACT = /^\.\.\.(?<folder>.+)$/u;
30
39
 
31
40
  interface PagesResult {
32
41
  pages: string[];
@@ -48,6 +57,20 @@ const filterPages = (raw: unknown): PagesResult => {
48
57
  if (value === REST || value === "") {
49
58
  continue;
50
59
  }
60
+ const extract = EXTRACT.exec(value);
61
+ if (extract) {
62
+ // Blume has no "flatten a folder's children inline" sidebar primitive, but
63
+ // the folder is a sibling here, so keep its position in the ordering and
64
+ // let it render as a normal group.
65
+ const folder = extract.groups?.folder?.trim() ?? "";
66
+ if (folder) {
67
+ pages.push(folder);
68
+ warnings.push(
69
+ `Sidebar extract "...${folder}" became a normal "${folder}" group (its pages are not flattened into the parent).`
70
+ );
71
+ }
72
+ continue;
73
+ }
51
74
  const separator = SEPARATOR.exec(value);
52
75
  if (separator) {
53
76
  const label = separator.groups?.label?.trim();
@@ -74,8 +97,17 @@ export interface FumadocsMetaConversion {
74
97
  warnings: string[];
75
98
  }
76
99
 
77
- /** Map a parsed Fumadocs `meta.json` object onto a Blume `FolderMeta`. */
78
- export const translateFumadocsMeta = (
100
+ /** Render a Blume `meta.ts` module source for a `FolderMeta`. */
101
+ export const renderMetaModule = (meta: FolderMeta): string =>
102
+ `import { defineMeta } from "blume";\n\nexport default defineMeta(${JSON.stringify(meta, null, 2)});\n`;
103
+
104
+ /**
105
+ * Translate a folder's own title/icon/display fields — everything in a Fumadocs
106
+ * `meta.json` *except* the `pages` ordering. Split out from
107
+ * {@link translateFumadocsMeta} so the structural path (which reshapes `pages`
108
+ * into group folders) can reuse the self fields without the flat `pages` array.
109
+ */
110
+ export const translateFumadocsSelfMeta = (
79
111
  value: unknown
80
112
  ): FumadocsMetaConversion => {
81
113
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -104,11 +136,109 @@ export const translateFumadocsMeta = (
104
136
  );
105
137
  }
106
138
 
107
- const { pages, warnings: pageWarnings } = filterPages(source.pages);
108
- if (pages.length > 0) {
109
- meta.pages = pages;
110
- }
111
- warnings.push(...pageWarnings);
139
+ return { meta, warnings };
140
+ };
112
141
 
142
+ /** Map a parsed Fumadocs `meta.json` object onto a Blume `FolderMeta`. */
143
+ export const translateFumadocsMeta = (
144
+ value: unknown
145
+ ): FumadocsMetaConversion => {
146
+ const { meta, warnings } = translateFumadocsSelfMeta(value);
147
+ if (value && typeof value === "object" && !Array.isArray(value)) {
148
+ const { pages, warnings: pageWarnings } = filterPages(
149
+ (value as FumadocsMeta).pages
150
+ );
151
+ if (pages.length > 0) {
152
+ meta.pages = pages;
153
+ }
154
+ warnings.push(...pageWarnings);
155
+ }
113
156
  return { meta, warnings };
114
157
  };
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Structural pages (separators -> sections)
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * One entry in a Fumadocs `pages` array, after classification: a `ref` (a plain
165
+ * page or folder name), an `extract` (`...folder`, whose children Fumadocs
166
+ * inlines), or a `link` (`[Text](url)`).
167
+ */
168
+ export type FumadocsPageItem =
169
+ | { kind: "extract"; name: string }
170
+ | { kind: "link"; href: string; text: string }
171
+ | { kind: "ref"; name: string };
172
+
173
+ /** A run of items under one `---Label---` separator. */
174
+ export interface FumadocsSection {
175
+ items: FumadocsPageItem[];
176
+ label: string;
177
+ }
178
+
179
+ export interface FumadocsPagesStructure {
180
+ /** Whether any `---Section---` separator was present. */
181
+ hasSections: boolean;
182
+ /** Items before the first separator; they stay at the folder's top level. */
183
+ lead: FumadocsPageItem[];
184
+ sections: FumadocsSection[];
185
+ }
186
+
187
+ /**
188
+ * Parse a Fumadocs `pages` array into its structural shape: the lead items
189
+ * (before any separator) and the sections each `---Label---` introduces. Unlike
190
+ * {@link filterPages}, separators are preserved (as section boundaries) so the
191
+ * migrator can rebuild them as Blume group folders. The rest marker (`"..."`) is
192
+ * dropped — Blume appends unlisted pages by default.
193
+ */
194
+ export const parseFumadocsPages = (raw: unknown): FumadocsPagesStructure => {
195
+ const lead: FumadocsPageItem[] = [];
196
+ const sections: FumadocsSection[] = [];
197
+ if (!Array.isArray(raw)) {
198
+ return { hasSections: false, lead, sections };
199
+ }
200
+
201
+ let current: FumadocsSection | null = null;
202
+ const push = (item: FumadocsPageItem): void => {
203
+ (current ? current.items : lead).push(item);
204
+ };
205
+
206
+ for (const entry of raw) {
207
+ if (typeof entry !== "string") {
208
+ continue;
209
+ }
210
+ const value = entry.trim();
211
+ if (value === REST || value === "") {
212
+ continue;
213
+ }
214
+ const separator = SEPARATOR.exec(value);
215
+ if (separator) {
216
+ current = {
217
+ items: [],
218
+ label: separator.groups?.label?.trim() || "Section",
219
+ };
220
+ sections.push(current);
221
+ continue;
222
+ }
223
+ const extract = EXTRACT.exec(value);
224
+ if (extract) {
225
+ const name = extract.groups?.folder?.trim();
226
+ if (name) {
227
+ push({ kind: "extract", name });
228
+ }
229
+ continue;
230
+ }
231
+ const link = LINK.exec(value);
232
+ if (link) {
233
+ push({
234
+ href: link.groups?.href ?? "",
235
+ kind: "link",
236
+ text: link.groups?.text ?? "",
237
+ });
238
+ continue;
239
+ }
240
+ push({ kind: "ref", name: value });
241
+ }
242
+
243
+ return { hasSections: sections.length > 0, lead, sections };
244
+ };
@@ -1,8 +1,9 @@
1
1
  import { readFile as readFileFromDisk } from "node:fs/promises";
2
2
 
3
- import matter from "gray-matter";
4
3
  import { dirname, relative, resolve } from "pathe";
5
4
 
5
+ import matter from "../../core/frontmatter.ts";
6
+
6
7
  const MARKDOWN_SNIPPET_IMPORT =
7
8
  /^import\s+(?<name>[$A-Z_a-z][$\w]*)\s+from\s+["'](?<source>[^"']+\.mdx?)["'];?\s*$/gmu;
8
9
  const NAMED_SNIPPET_IMPORT =
@@ -1,5 +1,4 @@
1
- import matter from "gray-matter";
2
-
1
+ import matter from "../../core/frontmatter.ts";
3
2
  import { stripUnknownPageMeta } from "../shared.ts";
4
3
  import {
5
4
  rewriteMintlifyCallouts,
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
 
4
- import matter from "gray-matter";
5
4
  import { basename, dirname, extname, join, relative } from "pathe";
6
5
  import { glob } from "tinyglobby";
7
6
 
7
+ import matter from "../../core/frontmatter.ts";
8
8
  import type { BlumeConfig, FolderMeta } from "../../core/schema.ts";
9
9
  import { writeBlumeConfig } from "../shared.ts";
10
10
  import {
@@ -1,4 +1,5 @@
1
- import { writeFile } from "node:fs/promises";
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
2
3
 
3
4
  import { join } from "pathe";
4
5
 
@@ -20,6 +21,102 @@ export const writeBlumeConfig = async (
20
21
  await writeFile(join(root, "blume.config.ts"), body, "utf-8");
21
22
  };
22
23
 
24
+ // ---------------------------------------------------------------------------
25
+ // Old-framework teardown
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** The Blume command each standard npm script maps to after a migration. */
29
+ const BLUME_SCRIPTS: Record<string, string> = {
30
+ build: "blume build",
31
+ dev: "blume dev",
32
+ start: "blume preview",
33
+ };
34
+
35
+ /**
36
+ * Rewrite a migrated project's npm scripts off the old framework's CLI. A
37
+ * `dev`/`build`/`start` script whose command invokes `cli` (e.g. `/\bnext\b/`)
38
+ * is repointed at the matching Blume command (`start` -> `blume preview`); a
39
+ * script whose command matches `remove` (e.g. a `fumadocs-mdx` postinstall) is
40
+ * dropped. Scripts that don't match either are left untouched, so custom tasks
41
+ * survive. Returns true when `package.json` changed.
42
+ */
43
+ export const rewriteFrameworkScripts = async (
44
+ root: string,
45
+ cli: RegExp,
46
+ remove?: RegExp
47
+ ): Promise<boolean> => {
48
+ const pkgPath = join(root, "package.json");
49
+ if (!existsSync(pkgPath)) {
50
+ return false;
51
+ }
52
+ let pkg: { scripts?: Record<string, unknown> };
53
+ try {
54
+ pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
55
+ } catch {
56
+ return false;
57
+ }
58
+ const { scripts } = pkg;
59
+ if (!scripts || typeof scripts !== "object") {
60
+ return false;
61
+ }
62
+
63
+ const next: Record<string, unknown> = {};
64
+ let changed = false;
65
+ for (const [name, command] of Object.entries(scripts)) {
66
+ const blume = BLUME_SCRIPTS[name];
67
+ if (typeof command === "string" && remove?.test(command)) {
68
+ changed = true;
69
+ } else if (
70
+ typeof command === "string" &&
71
+ blume &&
72
+ cli.test(command) &&
73
+ command !== blume
74
+ ) {
75
+ next[name] = blume;
76
+ changed = true;
77
+ } else {
78
+ next[name] = command;
79
+ }
80
+ }
81
+
82
+ if (changed) {
83
+ pkg.scripts = next;
84
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
85
+ }
86
+ return changed;
87
+ };
88
+
89
+ /** A `.gitignore` line, normalized for comparison (trailing slashes dropped). */
90
+ const gitignoreKey = (line: string): string => line.trim().replace(/\/+$/u, "");
91
+
92
+ /**
93
+ * Ensure `.gitignore` ignores each of `entries`, appending any that are missing
94
+ * (creating the file when absent). Trailing-slash differences (`dist` vs
95
+ * `dist/`) count as already present. Returns the entries actually added.
96
+ */
97
+ export const ensureGitignore = async (
98
+ root: string,
99
+ entries: string[]
100
+ ): Promise<string[]> => {
101
+ const path = join(root, ".gitignore");
102
+ const existing = existsSync(path) ? await readFile(path, "utf-8") : "";
103
+ const present = new Set(
104
+ existing.split("\n").map(gitignoreKey).filter(Boolean)
105
+ );
106
+ const added = entries.filter((entry) => !present.has(gitignoreKey(entry)));
107
+ if (added.length === 0) {
108
+ return [];
109
+ }
110
+ const gap = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
111
+ await writeFile(path, `${existing}${gap}${added.join("\n")}\n`, "utf-8");
112
+ return added;
113
+ };
114
+
115
+ /** Of the candidate project-relative paths, the ones that still exist — the old
116
+ * framework files a migration leaves behind for the user to remove by hand. */
117
+ export const leftoverFiles = (root: string, candidates: string[]): string[] =>
118
+ candidates.filter((candidate) => existsSync(join(root, candidate)));
119
+
23
120
  // ---------------------------------------------------------------------------
24
121
  // Callout components -> Blume `:::` directives
25
122
  // ---------------------------------------------------------------------------
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
 
4
- import matter from "gray-matter";
5
4
  import { join } from "pathe";
6
5
  import { glob } from "tinyglobby";
7
6
 
7
+ import matter from "../../core/frontmatter.ts";
8
8
  import { writeBlumeConfig } from "../shared.ts";
9
9
  import { loadStarlightConfig, mapStarlightConfig } from "./config.ts";
10
10
  import {
@@ -5,18 +5,25 @@ import { join, relative } from "pathe";
5
5
 
6
6
  import { resolveAskBackend } from "../ai/ask.ts";
7
7
  import { buildRawMarkdown } from "../ai/markdown.ts";
8
+ import { discoverExamples } from "../astro/examples.ts";
8
9
  import {
9
10
  buildRuntimeData,
10
11
  collectStaged,
11
12
  detectNeedsReact,
12
13
  } from "../astro/generate.ts";
13
- import { discoverPages } from "../astro/pages.ts";
14
+ import { discoverIslands } from "../astro/islands.ts";
15
+ import { customOgRoutes, discoverPages } from "../astro/pages.ts";
14
16
  import {
15
17
  askEndpointTemplate,
16
18
  astroConfigTemplate,
17
19
  catchAllPageTemplate,
18
20
  contentConfigTemplate,
19
21
  envTemplate,
22
+ exampleMapTemplate,
23
+ exampleWrapperTemplate,
24
+ exampleSlug,
25
+ islandMapTemplate,
26
+ islandWrapperTemplate,
20
27
  mixedbreadSearchEndpointTemplate,
21
28
  ogEndpointTemplate,
22
29
  rawMarkdownEndpointTemplate,
@@ -55,15 +62,28 @@ export const eject = async (root: string): Promise<string[]> => {
55
62
  const exportPdf = config.export.pdf;
56
63
  const exportEpub = config.export.epub;
57
64
 
58
- const [pages, needsReactRaw, userTheme, rawMarkdown] = await Promise.all([
59
- context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
60
- detectNeedsReact(root),
61
- context.themeFile
62
- ? readFile(context.themeFile, "utf-8")
63
- : Promise.resolve(""),
64
- buildRawMarkdown(project),
65
+ const [pages, needsReactRaw, userTheme, rawMarkdown, islands, examples] =
66
+ await Promise.all([
67
+ context.pagesRoot
68
+ ? discoverPages(context.pagesRoot)
69
+ : Promise.resolve([]),
70
+ detectNeedsReact(root),
71
+ context.themeFile
72
+ ? readFile(context.themeFile, "utf-8")
73
+ : Promise.resolve(""),
74
+ buildRawMarkdown(project),
75
+ discoverIslands(root),
76
+ discoverExamples(root),
77
+ ]);
78
+ // Island/example frameworks drive which Astro renderers the ejected config
79
+ // wires in; React also switches on for project `.tsx`/`.jsx` and Ask AI.
80
+ const frameworks = new Set<string>([
81
+ ...islands.islands.map((island) => island.framework),
82
+ ...examples.examples.map((example) => example.framework),
65
83
  ]);
66
- const needsReact = needsReactRaw || askEnabled;
84
+ const needsReact = needsReactRaw || askEnabled || frameworks.has("react");
85
+ const needsVue = frameworks.has("vue");
86
+ const needsSvelte = frameworks.has("svelte");
67
87
 
68
88
  // A project-relative context so generated files use portable paths.
69
89
  const relContext: ProjectContext = {
@@ -95,7 +115,10 @@ export const eject = async (root: string): Promise<string[]> => {
95
115
  contentRoutes: project.manifest.routes.map((route) => route.path),
96
116
  context: relContext,
97
117
  dataPath: "./src/generated/data.json",
118
+ examplesPath: "./src/generated/examples.ts",
98
119
  needsReact,
120
+ needsSvelte,
121
+ needsVue,
99
122
  pages: relPages,
100
123
  searchClientPath: "./src/generated/search-client.ts",
101
124
  themePath: "./src/generated/app.css",
@@ -129,6 +152,16 @@ export const eject = async (root: string): Promise<string[]> => {
129
152
  content: userComponentsTemplate(componentsImport),
130
153
  path: join(genDir, "components.ts"),
131
154
  },
155
+ // Island/example maps the catch-all imports; written even when empty so the
156
+ // relative import and the `blume:examples` alias always resolve.
157
+ {
158
+ content: islandMapTemplate(islands.islands),
159
+ path: join(genDir, "islands.ts"),
160
+ },
161
+ {
162
+ content: exampleMapTemplate(examples.examples),
163
+ path: join(genDir, "examples.ts"),
164
+ },
132
165
  {
133
166
  content: tailwindEntryTemplate({
134
167
  configTokens: buildThemeCss(config.theme),
@@ -166,7 +199,9 @@ export const eject = async (root: string): Promise<string[]> => {
166
199
 
167
200
  if (config.seo.og.enabled) {
168
201
  files.push({
169
- content: ogEndpointTemplate(),
202
+ content: ogEndpointTemplate(
203
+ customOgRoutes(pages, config.title, config.description)
204
+ ),
170
205
  path: join(srcDir, "pages", "og", "[...slug].png.ts"),
171
206
  });
172
207
  }
@@ -233,6 +268,18 @@ export const eject = async (root: string): Promise<string[]> => {
233
268
  }
234
269
  }
235
270
 
271
+ // Per-island and per-example live wrappers referenced by the maps above.
272
+ files.push(
273
+ ...islands.islands.map((island) => ({
274
+ content: islandWrapperTemplate(island),
275
+ path: join(genDir, "islands", `${island.name}.astro`),
276
+ })),
277
+ ...examples.examples.map((example) => ({
278
+ content: exampleWrapperTemplate(example),
279
+ path: join(genDir, "examples", `${exampleSlug(example.path)}.astro`),
280
+ }))
281
+ );
282
+
236
283
  // Materialize staged source bodies under `<root>/blume-staged/<source>/<ref>`,
237
284
  // matching the relative `staged` collection base in the ejected config.
238
285
  for (const [entryId, content] of staged) {
@@ -1,5 +1,4 @@
1
- import matter from "gray-matter";
2
-
1
+ import matter from "../core/frontmatter.ts";
3
2
  import { contentIndexable } from "../core/manifest.ts";
4
3
  import type { BlumeProject } from "../core/project-graph.ts";
5
4
  import { readEntryText } from "../core/sources/read.ts";