blume 1.0.3 → 1.0.4

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 (51) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli/index.js +441 -259
  3. package/dist/cli/index.js.map +12 -11
  4. package/dist/types/core/config-input.d.ts +27 -8
  5. package/dist/types/core/data.d.ts +12 -0
  6. package/dist/types/core/i18n-ui.d.ts +136 -136
  7. package/dist/types/core/schema.d.ts +420 -350
  8. package/dist/types/core/types.d.ts +10 -0
  9. package/dist/types/openapi/references.d.ts +12 -7
  10. package/docs/advanced/api-reference.mdx +11 -3
  11. package/docs/configuration/seo.mdx +20 -1
  12. package/docs/content/components.mdx +1 -2
  13. package/docs/content/navigation.mdx +10 -0
  14. package/docs/content/syntax.mdx +116 -4
  15. package/package.json +1 -1
  16. package/skills/blume-migrate/SKILL.md +170 -0
  17. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  18. package/skills/blume-migrate/references/docusaurus.md +95 -0
  19. package/skills/blume-migrate/references/fumadocs.md +95 -0
  20. package/skills/blume-migrate/references/mintlify.md +155 -0
  21. package/skills/blume-migrate/references/monorepo.md +224 -0
  22. package/skills/blume-migrate/references/nextra.md +76 -0
  23. package/skills/blume-migrate/references/starlight.md +116 -0
  24. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  25. package/src/astro/component-slots.ts +3 -2
  26. package/src/astro/generate.ts +82 -23
  27. package/src/astro/templates.ts +93 -34
  28. package/src/components/content/Callout.astro +8 -2
  29. package/src/components/content/Prompt.astro +25 -13
  30. package/src/components/layout/Header.astro +4 -8
  31. package/src/components/layout/Logo.astro +13 -1
  32. package/src/components/layout/PageFeedback.astro +1 -1
  33. package/src/components/layout/PageLayout.astro +4 -8
  34. package/src/components/layout/Pagination.astro +6 -6
  35. package/src/components/layout/RootLayout.astro +4 -8
  36. package/src/components/layout/Search.astro +1 -1
  37. package/src/components/layout/nav-utils.ts +9 -7
  38. package/src/core/config-input.ts +29 -8
  39. package/src/core/data.ts +9 -1
  40. package/src/core/navigation.ts +55 -13
  41. package/src/core/schema.ts +17 -1
  42. package/src/core/sources/watch.ts +5 -0
  43. package/src/core/types.ts +10 -0
  44. package/src/markdown/index.ts +2 -0
  45. package/src/markdown/language-icon.ts +2 -1
  46. package/src/markdown/table-wrap.ts +43 -0
  47. package/src/og/card.ts +39 -12
  48. package/src/og/index.ts +1 -1
  49. package/src/og/logo.ts +21 -0
  50. package/src/openapi/references.ts +19 -16
  51. package/src/theme/entry.ts +50 -5
@@ -0,0 +1,116 @@
1
+ # Starlight → Blume
2
+
3
+ Starlight is an Astro integration configured via `starlight({…})` in `astro.config.*`, with content in `src/content/docs`. Both are Astro, so this is a natural fit — content can mostly stay in place, with one big caveat: **most Starlight content is `.md`, and Blume's `:::` directives are MDX-only** (see Asides below).
4
+
5
+ ## Detect
6
+
7
+ - `astro.config.{mjs,mts,ts,js,cjs}` importing and calling **`starlight({…})`** (`import starlight from "@astrojs/starlight"`).
8
+ - Content under **`src/content/docs/`**; a `src/content.config.ts` (current) or `src/content/config.ts` (pre-Astro-5).
9
+ - `@astrojs/starlight` dep.
10
+
11
+ Keep content where it is — set `content.root: "src/content/docs"`.
12
+
13
+ ## Config: `starlight({…})` → `blume.config.ts`
14
+
15
+ **Harvest the surrounding `astro.config.*` too, not just the `starlight()` call:** top-level Astro `redirects` → Blume `redirects`; `site` → leave unset (Blume auto-detects); other integrations → report.
16
+
17
+ | Starlight option | Blume |
18
+ | --- | --- |
19
+ | `title` | `title` (if per-locale/computed, set manually) |
20
+ | `description` | `description` |
21
+ | `logo` (`{ src }` or `{ light, dark, alt }`) | `logo` — move the referenced file into `public/` |
22
+ | `logo.replacesTitle` | `logo: { text: "" }` (renders the mark alone) |
23
+ | `favicon` | copy the file into `public/` (drop the config field — Blume auto-detects) |
24
+ | `social` (array of `{ label, icon, href }`; pre-0.33 legacy: `{ github: url }` object) | derive **`github: { owner, repo }`** from the GitHub entry; other socials drop (report) |
25
+ | `editLink.baseUrl` (`…/edit/<branch>/<subdir?>`) | `github: { owner, repo, branch }` — **and any repo sub-path after the branch → `github.dir`** (a docs-in-subfolder repo breaks every edit link without it) |
26
+ | `sidebar` (array) | filesystem nav / `navigation.sidebar` (see below) |
27
+ | `tableOfContents` (`false` or `{ minHeadingLevel, maxHeadingLevel }`) | `toc` — identical shape, 1:1 |
28
+ | `markdown.headingLinks: false` | `markdown.headingAnchors: false` |
29
+ | `expressiveCode.themes` | `markdown.codeBlocks.theme: { light, dark }` — EC assigns by theme _type_, so match each theme by its darkness, not by array position |
30
+ | `expressiveCode.styleOverrides` | drop → restyle via a root `theme.css` file |
31
+ | `head` | **drop and report** (there is no `seo.metatags`); analytics `<script>` entries → the `analytics` config |
32
+ | `lastUpdated: true` | `lastModified: true` |
33
+ | `customCss` | drop → move into a root **`theme.css`** file (auto-picked-up; not a config field) |
34
+ | `components` (overrides) | Blume's layout slots via `defineComponents({ layout: { Header, Search, Sidebar, TableOfContents, Footer, … } })` — a near-1:1 map; reach for `blume eject` only beyond those |
35
+ | `plugins` | **map, don't blanket-drop** — see Plugins below |
36
+ | `pagination: false`, `pagefind`/Pagefind options, `titleDelimiter`, `credits`, `disable404Route`, `routeMiddleware` | drop (report) |
37
+ | `locales` / `defaultLocale` | `i18n` (see below) |
38
+
39
+ ## Navigation: `sidebar`
40
+
41
+ Starlight's `sidebar` array → prefer letting Blume generate from the filesystem; use `navigation.sidebar` only for shapes files can't express:
42
+
43
+ - `{ label, autogenerate: { directory: "d" } }` → **structure the folder and rely on filesystem nav** (label → the folder's `meta.ts` `title`). Do **not** map it to a sidebar item with `root:` — in an explicit Blume sidebar that renders a single page link, not the directory's children.
44
+ - string `"guides/intro"` → `"/guides/intro"`; `{ label?, slug }` → `/<slug>`.
45
+ - `{ label, items: [...] }` → `{ label, items: [...] }` (recursive) — or better, a real folder.
46
+ - `{ label?, link }` → `{ label, href: link }`.
47
+ - `badge` (string or `{ text, variant }`) → `badge` (text only; variant drops). `collapsed` (bool) → `collapsed`. Item `attrs` and `translations` → drop (report).
48
+
49
+ ## Frontmatter
50
+
51
+ | Starlight | Blume |
52
+ | --- | --- |
53
+ | `title` / `description` / `draft` / `slug` | pass through |
54
+ | `sidebar.{label,order,hidden}` | pass through |
55
+ | `sidebar.badge` | `sidebar.badge` (text only) |
56
+ | `sidebar.attrs` | **remove** (strict schema — build error if left) |
57
+ | `pagefind: false` | `search.exclude: true` |
58
+ | `lastUpdated` (date/string) | `lastModified` |
59
+ | `lastUpdated: false` (boolean) | **remove** (Blume's `lastModified` takes a date; a boolean is a build error) |
60
+ | `prev` / `next` (booleans or labels) | **drop** (report — no per-page pagination toggle; `hideFooterPagination` does not exist) |
61
+ | `template: splash` / `hero` | **no equivalent** — rebuild as a custom `.astro` page under `content.pages`; a `hero` on a normal page drops (report) |
62
+ | `banner`, `tableOfContents`, `editUrl`, `head` | drop (report) |
63
+
64
+ ## Asides → directives (and the `.md` trap)
65
+
66
+ Starlight's primary callout syntax is the `:::note`/`:::tip`/`:::caution`/`:::danger` directive **in plain `.md` files**. The directive names map perfectly (Blume aliases `caution`→warning; `error`→danger) and `[Title]` syntax carries over — **but Blume only parses directives in `.mdx`**. In a `.md` file, `:::note` renders as literal text and the build stays green. So: **rename every `.md` file that contains asides (or math, or mermaid/package-install fences) to `.mdx`** — for a typical Starlight repo that's most of the content; renaming everything to `.mdx` is usually simpler and safe.
67
+
68
+ - `<Aside type="…" title="…">` (the component form) → the same directives; bare `<Aside>` → `:::note`.
69
+ - An aside custom icon (`:::tip{icon="heart"}`) → drop the attr (report).
70
+
71
+ ## Components
72
+
73
+ - **Renames:** `<CardGrid>` → `<CardGroup>`; `<LinkCard>` → `<Card>` (its `description` prop drops — fold into the body); `<TabItem label="…">` → `<Tab title="…">`. `<Tabs>` and `<Card>` stay; a `<Tabs syncKey="…">` → strip the prop (Blume tabs sync by default).
74
+ - **`<Badge>` needs conversion, not pass-through:** Starlight puts content in a `text` prop and uses variants `note`/`tip`/`caution`/`danger`/`success`/`default` with sizes `small`/`medium`/`large`. Blume's `<Badge>` renders **children** with variants `default`/`accent`/`success`/`warning`/`danger` and sizes `xs`/`sm`/`md`/`lg`. Move `text` into the children; remap variant (`note`→`default`, `tip`→`accent`, `caution`→`warning`, `danger`→`danger`, `success`→`success`) and size (`small`→`sm`, `medium`→`md`, `large`→`lg`).
75
+ - **Convert yourself:** `<Steps>` → Blume `<Steps>`/`<Step>`; `<FileTree>` → Blume `<FileTree>`; `<Code code={…}>` → a fenced code block; `<LinkButton>` → a Markdown link or `<Card>`.
76
+ - Strip `import … from "@astrojs/starlight/*"` and `astro:assets` lines.
77
+
78
+ ## Code blocks: Expressive Code meta → Blume
79
+
80
+ Starlight content is full of Expressive Code fence meta; Blume understands some of it and **promotes unknown bare tokens into the code-block title**, so unconverted meta produces garbage headers. Convert per fence:
81
+
82
+ - `title="file.js"` → works as-is (or use the space-title shorthand). Line ranges `{2-3}` → work as-is.
83
+ - `ins=`/`del=` line marks → `// [!code ++]` / `// [!code --]` comments; `mark=` → `{ranges}` or `// [!code highlight]`.
84
+ - `showLineNumbers` → `lineNumbers`.
85
+ - **Drop:** `frame="terminal"`, `collapse=`, `wrap`, `"string"` and `/regex/` text markers (report if they carried meaning).
86
+ - ` ```diff lang="js" ` → a normal ` ```js ` fence with `[!code ++]`/`[!code --]` markers.
87
+
88
+ ## Plugins — map, don't drop
89
+
90
+ - `starlight-openapi` → Blume's native `openapi.sources` (delete any generated pages; add the `navigation.tabs` entry).
91
+ - `starlight-blog` → `type: blog` pages.
92
+ - `starlight-versions` → `navigation.selectors` with `kind: "version"`.
93
+ - `starlight-image-zoom` → delete (Blume zooms content images by default).
94
+ - `starlight-links-validator` → delete (`blume validate` covers it).
95
+ - Anything else → report.
96
+
97
+ ## Assets
98
+
99
+ Starlight co-locates images in `src/assets/` with **relative** references (`../../assets/foo.png`) or `astro:assets` imports; Blume serves `public/` at the site root with absolute URLs. Move `src/assets/*` into `public/`, rewrite relative image paths and `~/`/`@/` aliases to absolute `/…` URLs, and replace `<Image>` imports with Markdown images (or `<Frame>`).
100
+
101
+ ## i18n
102
+
103
+ Starlight has **two** layouts:
104
+
105
+ - **`root` locale** (default language at `src/content/docs/`, others in `fr/`, `de/`… subdirs) → matches Blume's `dir` parser as-is: `i18n: { defaultLocale, locales: [{ code, label }] }`, no file moves.
106
+ - **No `root` locale** (every language in a subdir, including the default — `en/…`) → Blume expects the default locale **at the content root**, so move the default locale's files up one level. Starlight served them at `/en/…`, so add a `redirects` entry per page.
107
+
108
+ Don't restate Blume defaults (`hideDefaultLocalePrefix: true`, `parser: "dir"` are already the defaults). Starlight's untranslated-page fallback matches Blume's `fallbackLocale` default.
109
+
110
+ ## Teardown
111
+
112
+ Remove `@astrojs/starlight` (and plugin deps) from deps, delete the Starlight bits of `astro.config.*` **after harvesting redirects**, delete `src/content.config.ts` / `src/content/config.ts`, repoint scripts to the Blume CLI, add `blume`. A `src/content/docs/404.md` has no direct Blume equivalent — report it (Blume ships its own 404).
113
+
114
+ ## Dropped — report these
115
+
116
+ Non-GitHub socials, badge variants, sidebar/item `attrs` + `translations`, `customCss` beyond `theme.css`, `head` entries, `routeMiddleware`, splash/hero pages (rebuild as custom pages), aside custom icons, EC frames/collapse/text markers, prev/next toggles, unmapped plugins, any `<Icon>` name with no Lucide equivalent.
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env node
2
+ // mintlify-codemod.mjs — deterministic, idempotent frontmatter codemod for the
3
+ // Mintlify → Blume migration. It rewrites ONLY the YAML frontmatter block of
4
+ // `.md`/`.mdx` files; the body is never touched. Two passes:
5
+ //
6
+ // 1. Icons — remap Mintlify (FontAwesome) `icon:` names to their closest
7
+ // Lucide equivalent (Blume is Lucide-only). Brand/no-equivalent
8
+ // icons are dropped and reported, never faked.
9
+ // 2. Fields — drop frontmatter keys Blume's strict schema rejects, and rename
10
+ // Mintlify-only keys to their Blume nesting (sidebarTitle →
11
+ // sidebar.label, tag → sidebar.badge, canonical → seo.canonical,
12
+ // og:image → seo.image). Ambiguous keys (openapi/asyncapi/api)
13
+ // are flagged for human review, not transformed.
14
+ //
15
+ // Design constraints:
16
+ // - ZERO dependencies — safe in a pnpm-strict workspace with no hoisting; runs
17
+ // with a bare `node`, needs nothing from the target repo's node_modules.
18
+ // - Deterministic — no Date/Math.random; files processed in sorted order.
19
+ // - Idempotent — running twice makes no further change. Source keys are gone
20
+ // after the first pass; remapped icon values are already Lucide (every
21
+ // mapped value that is also a source key maps to itself); a rename whose
22
+ // target already exists is reported, not re-applied.
23
+ // - Surgical — untouched lines keep their exact formatting; only the specific
24
+ // lines that change are edited, so diffs stay small and reviewable.
25
+ // - Reports every change per file (and every drop/flag), so nothing is silent.
26
+ //
27
+ // Usage:
28
+ // node mintlify-codemod.mjs <path...> # dry run — report only
29
+ // node mintlify-codemod.mjs --write <path...> # apply changes in place
30
+ // node mintlify-codemod.mjs --json <path...> # machine-readable report
31
+ // node mintlify-codemod.mjs --help
32
+ //
33
+ // Paths may be files or directories; directories are walked for .md/.mdx.
34
+
35
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
36
+ import path from "node:path";
37
+
38
+ // --- Mintlify (FontAwesome) → Lucide icon map ------------------------------
39
+ // Keys are lowercased Mintlify icon names; values are Lucide names. A value of
40
+ // `null` means "no Lucide equivalent" (brand icons, mostly) — the icon line is
41
+ // removed and reported. Every value that is itself a key maps to the same name,
42
+ // which keeps the pass idempotent (a second run finds nothing to change). The
43
+ // FontAwesome `x` (close) maps to Lucide `x`; the X/Twitter brand is
44
+ // `x-twitter` and has no Lucide form.
45
+ const ICONS = {
46
+ angular: null,
47
+ apple: null,
48
+ "arrow-right-from-bracket": "log-out",
49
+ aws: null,
50
+ bell: "bell",
51
+ bolt: "zap",
52
+ book: "book",
53
+ "book-open": "book-open",
54
+ boxes: "boxes",
55
+ calendar: "calendar",
56
+ "chart-column": "chart-column",
57
+ "chart-line": "chart-line",
58
+ "chart-simple": "chart-column",
59
+ check: "check",
60
+ "circle-check": "circle-check",
61
+ "circle-exclamation": "circle-alert",
62
+ "circle-info": "info",
63
+ "circle-question": "circle-help",
64
+ cloud: "cloud",
65
+ code: "code",
66
+ cog: "settings",
67
+ comments: "messages-square",
68
+ copy: "copy",
69
+ cube: "box",
70
+ cubes: "boxes",
71
+ database: "database",
72
+ "diagram-project": "workflow",
73
+ discord: null,
74
+ docker: null,
75
+ download: "download",
76
+ envelope: "mail",
77
+ facebook: null,
78
+ "file-lines": "file-text",
79
+ filter: "filter",
80
+ flask: "flask-conical",
81
+ folder: "folder",
82
+ gauge: "gauge",
83
+ "gauge-high": "gauge",
84
+ gear: "settings",
85
+ github: null,
86
+ gitlab: null,
87
+ globe: "globe",
88
+ google: null,
89
+ heart: "heart",
90
+ house: "house",
91
+ info: "info",
92
+ instagram: null,
93
+ java: null,
94
+ js: null,
95
+ key: "key",
96
+ "layer-group": "layers",
97
+ layers: "layers",
98
+ "life-ring": "life-buoy",
99
+ link: "link",
100
+ linkedin: null,
101
+ "location-dot": "map-pin",
102
+ lock: "lock",
103
+ magic: "sparkles",
104
+ "magnifying-glass": "search",
105
+ "map-marker": "map-pin",
106
+ medium: null,
107
+ microsoft: null,
108
+ node: null,
109
+ "node-js": null,
110
+ npm: null,
111
+ "pen-to-square": "square-pen",
112
+ php: null,
113
+ play: "play",
114
+ puzzle: "puzzle",
115
+ "puzzle-piece": "puzzle",
116
+ python: null,
117
+ question: "circle-help",
118
+ react: null,
119
+ "right-to-bracket": "log-in",
120
+ robot: "bot",
121
+ rocket: "rocket",
122
+ rust: null,
123
+ "screwdriver-wrench": "wrench",
124
+ search: "search",
125
+ server: "server",
126
+ settings: "settings",
127
+ shield: "shield",
128
+ "shield-halved": "shield",
129
+ sitemap: "network",
130
+ slack: null,
131
+ sparkles: "sparkles",
132
+ star: "star",
133
+ stripe: null,
134
+ tag: "tag",
135
+ telegram: null,
136
+ terminal: "terminal",
137
+ times: "x",
138
+ toolbox: "wrench",
139
+ "trash-can": "trash-2",
140
+ "triangle-exclamation": "triangle-alert",
141
+ twitter: null,
142
+ upload: "upload",
143
+ user: "user",
144
+ users: "users",
145
+ vuejs: null,
146
+ "wand-magic-sparkles": "sparkles",
147
+ whatsapp: null,
148
+ workflow: "workflow",
149
+ wrench: "wrench",
150
+ x: "x",
151
+ "x-twitter": null,
152
+ xmark: "x",
153
+ youtube: null,
154
+ zap: "zap",
155
+ };
156
+
157
+ // --- Frontmatter field policy ----------------------------------------------
158
+ // Top-level keys Blume's strict schema rejects: delete the whole block, report.
159
+ const DROP = new Set([
160
+ "groups",
161
+ "hideApiMarker",
162
+ "hideFooterPagination",
163
+ "iconType",
164
+ "keywords",
165
+ "mode",
166
+ "public",
167
+ "rss",
168
+ ]);
169
+
170
+ // Mintlify-only keys → Blume nested target. `[parent, child]`.
171
+ const RENAME = {
172
+ canonical: ["seo", "canonical"],
173
+ "og:image": ["seo", "image"],
174
+ ogImage: ["seo", "image"],
175
+ sidebarTitle: ["sidebar", "label"],
176
+ tag: ["sidebar", "badge"],
177
+ };
178
+
179
+ // Keys we deliberately do NOT auto-transform — they usually mean the page is an
180
+ // OpenAPI endpoint stub that should be deleted (Blume generates operation pages)
181
+ // or converted to `type: api`. Flag for the human; never guess.
182
+ const FLAG = new Set(["api", "asyncapi", "openapi"]);
183
+
184
+ // Which change kinds actually edit the file. Report-only kinds (flags,
185
+ // unknowns, conflicts, manual-rename notices) leave the bytes untouched.
186
+ const MUTATING = new Set(["drop", "icon-drop", "icon-remap", "rename"]);
187
+
188
+ // --- Frontmatter line model -------------------------------------------------
189
+
190
+ // Split a file into { fm, body, eol } or null when there's no `---` block.
191
+ const splitFrontmatter = (text) => {
192
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
193
+ const lines = text.split(/\r?\n/u);
194
+ if (lines[0] !== "---") {
195
+ return null;
196
+ }
197
+ let end = -1;
198
+ for (let i = 1; i < lines.length; i += 1) {
199
+ if (lines[i] === "---") {
200
+ end = i;
201
+ break;
202
+ }
203
+ }
204
+ if (end === -1) {
205
+ return null;
206
+ }
207
+ return { body: lines.slice(end + 1), eol, fm: lines.slice(1, end) };
208
+ };
209
+
210
+ // Match a top-level (column-0) `key:` line, honoring quoted keys. Because the
211
+ // key class allows `:`, backtracking resolves `og:image: x` to key `og:image`.
212
+ const topKey = (line) => {
213
+ const m = /^(?<q>["']?)(?<key>[^"'\s:][^"']*?)\k<q>\s*:(?<rest>\s.*|)$/u.exec(
214
+ line
215
+ );
216
+ if (!m || /^\s/u.test(line)) {
217
+ return null;
218
+ }
219
+ return { key: m.groups.key, value: (m.groups.rest ?? "").trim() };
220
+ };
221
+
222
+ // The line index range [start, endExclusive) of a top-level key's block.
223
+ const blockRange = (fm, start) => {
224
+ let end = start + 1;
225
+ while (end < fm.length && (fm[end] === "" || /^\s/u.test(fm[end]))) {
226
+ end += 1;
227
+ }
228
+ // Trim trailing blank lines back out so the gap before the next key survives.
229
+ while (end - 1 > start && fm[end - 1] === "") {
230
+ end -= 1;
231
+ }
232
+ return [start, end];
233
+ };
234
+
235
+ // Does the block starting at `start` carry a nested child `childKey:`?
236
+ const findChild = (fm, start, endExclusive, childKey) => {
237
+ for (let i = start + 1; i < endExclusive; i += 1) {
238
+ const m = /^\s+(?<q>["']?)(?<key>[^"'\s:][^"']*?)\k<q>\s*:/u.exec(fm[i]);
239
+ if (m && m.groups.key === childKey) {
240
+ return i;
241
+ }
242
+ }
243
+ return -1;
244
+ };
245
+
246
+ // --- The two passes ---------------------------------------------------------
247
+
248
+ // Remap `icon:` values in place. Mutates `fm`; returns change records.
249
+ const remapIcons = (fm) => {
250
+ const changes = [];
251
+ let i = 0;
252
+ while (i < fm.length) {
253
+ const m =
254
+ /^(?<indent>\s*)icon:\s*(?<q>["']?)(?<name>[^"'#]*?)\k<q>\s*(?<comment>#.*)?$/u.exec(
255
+ fm[i]
256
+ );
257
+ if (!m) {
258
+ i += 1;
259
+ continue;
260
+ }
261
+ const raw = m.groups.name.trim();
262
+ // Empty, an object, or a template ref — leave it for the human.
263
+ if (raw === "" || raw.startsWith("{") || raw.startsWith("$")) {
264
+ i += 1;
265
+ continue;
266
+ }
267
+ const key = raw.toLowerCase();
268
+ if (!(key in ICONS)) {
269
+ changes.push({ detail: raw, kind: "icon-unknown" });
270
+ i += 1;
271
+ continue;
272
+ }
273
+ const lucide = ICONS[key];
274
+ if (lucide === null) {
275
+ changes.push({ detail: raw, kind: "icon-drop" });
276
+ fm.splice(i, 1);
277
+ continue;
278
+ }
279
+ if (lucide !== raw) {
280
+ fm[i] = `${m.groups.indent}icon: ${lucide}`;
281
+ changes.push({ detail: `${raw} → ${lucide}`, kind: "icon-remap" });
282
+ }
283
+ i += 1;
284
+ }
285
+ return changes;
286
+ };
287
+
288
+ // Insert `child: value` under `parent`, creating the parent block if needed.
289
+ const setNested = (fm, parent, child, value) => {
290
+ for (let i = 0; i < fm.length; i += 1) {
291
+ const tk = topKey(fm[i]);
292
+ if (!tk || tk.key !== parent) {
293
+ continue;
294
+ }
295
+ if (tk.value !== "") {
296
+ // `parent: scalar` — can't nest under a scalar without clobbering it.
297
+ return { ok: false, reason: "parent-is-scalar" };
298
+ }
299
+ const [start, end] = blockRange(fm, i);
300
+ if (findChild(fm, start, end, child) !== -1) {
301
+ return { ok: false, reason: "child-exists" };
302
+ }
303
+ fm.splice(start + 1, 0, ` ${child}: ${value}`);
304
+ return { ok: true };
305
+ }
306
+ // No parent block — append one at the end of the frontmatter.
307
+ fm.push(`${parent}:`, ` ${child}: ${value}`);
308
+ return { ok: true };
309
+ };
310
+
311
+ // Drop unsupported keys, rename Mintlify-only keys, flag ambiguous ones.
312
+ const rewriteFields = (fm) => {
313
+ const changes = [];
314
+ // Walk from the bottom so splices don't shift indices we haven't visited.
315
+ for (let i = fm.length - 1; i >= 0; i -= 1) {
316
+ const tk = topKey(fm[i]);
317
+ if (!tk) {
318
+ continue;
319
+ }
320
+ if (DROP.has(tk.key)) {
321
+ const [start, end] = blockRange(fm, i);
322
+ fm.splice(start, end - start);
323
+ changes.push({ detail: tk.key, kind: "drop" });
324
+ continue;
325
+ }
326
+ if (FLAG.has(tk.key)) {
327
+ changes.push({ detail: tk.key, kind: "flag" });
328
+ continue;
329
+ }
330
+ const target = RENAME[tk.key];
331
+ if (!target) {
332
+ continue;
333
+ }
334
+ const [parent, child] = target;
335
+ const [start, end] = blockRange(fm, i);
336
+ if (end - start !== 1 || tk.value === "") {
337
+ // Multi-line or valueless source — too structured to move safely.
338
+ changes.push({
339
+ detail: `${tk.key} → ${parent}.${child}`,
340
+ kind: "rename-manual",
341
+ });
342
+ continue;
343
+ }
344
+ const placed = setNested(fm, parent, child, tk.value);
345
+ if (placed.ok) {
346
+ fm.splice(start, 1);
347
+ changes.push({
348
+ detail: `${tk.key} → ${parent}.${child}`,
349
+ kind: "rename",
350
+ });
351
+ } else {
352
+ // Target already set, or parent is a scalar — leave the source in place so
353
+ // no data is lost, and report it for manual resolution.
354
+ changes.push({
355
+ detail: `${tk.key} → ${parent}.${child} (${placed.reason})`,
356
+ kind: "rename-conflict",
357
+ });
358
+ }
359
+ }
360
+ return changes.toReversed();
361
+ };
362
+
363
+ // --- Driver -----------------------------------------------------------------
364
+
365
+ const transform = (text) => {
366
+ const split = splitFrontmatter(text);
367
+ if (!split) {
368
+ return { changed: false, changes: [], text };
369
+ }
370
+ const fm = [...split.fm];
371
+ const changes = [...remapIcons(fm), ...rewriteFields(fm)];
372
+ const rebuilt = ["---", ...fm, "---", ...split.body].join(split.eol);
373
+ const changed = rebuilt !== text && changes.some((c) => MUTATING.has(c.kind));
374
+ return { changed, changes, text: changed ? rebuilt : text };
375
+ };
376
+
377
+ const collectFiles = (paths) => {
378
+ const out = [];
379
+ const walk = (p) => {
380
+ const st = statSync(p);
381
+ if (st.isDirectory()) {
382
+ for (const name of readdirSync(p).toSorted()) {
383
+ if (name === "node_modules" || name.startsWith(".")) {
384
+ continue;
385
+ }
386
+ walk(path.join(p, name));
387
+ }
388
+ } else if (/\.mdx?$/u.test(p)) {
389
+ out.push(p);
390
+ }
391
+ };
392
+ for (const p of paths.toSorted()) {
393
+ walk(p);
394
+ }
395
+ return out;
396
+ };
397
+
398
+ const LABEL = {
399
+ drop: "dropped",
400
+ flag: "FLAG (review)",
401
+ "icon-drop": "icon dropped (no Lucide equivalent)",
402
+ "icon-remap": "icon",
403
+ "icon-unknown": "icon unknown (verify at lucide.dev)",
404
+ rename: "renamed",
405
+ "rename-conflict": "rename conflict (left in place)",
406
+ "rename-manual": "rename needs manual edit",
407
+ };
408
+
409
+ const HELP = [
410
+ "mintlify-codemod — icon + frontmatter pass for Mintlify → Blume",
411
+ "",
412
+ " node mintlify-codemod.mjs <path...> dry run (report only)",
413
+ " node mintlify-codemod.mjs --write <path...> apply in place",
414
+ " node mintlify-codemod.mjs --json <path...> JSON report",
415
+ "",
416
+ ].join("\n");
417
+
418
+ const main = () => {
419
+ const argv = process.argv.slice(2);
420
+ if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
421
+ process.stdout.write(`${HELP}\n`);
422
+ return;
423
+ }
424
+ const write = argv.includes("--write");
425
+ const asJson = argv.includes("--json");
426
+ const paths = argv.filter((a) => !a.startsWith("--"));
427
+ const files = collectFiles(paths);
428
+
429
+ const report = [];
430
+ let changedCount = 0;
431
+ for (const file of files) {
432
+ const before = readFileSync(file, "utf-8");
433
+ const { changed, changes, text } = transform(before);
434
+ if (changes.length === 0) {
435
+ continue;
436
+ }
437
+ if (changed && write) {
438
+ writeFileSync(file, text, "utf-8");
439
+ }
440
+ if (changed) {
441
+ changedCount += 1;
442
+ }
443
+ report.push({ changes, file: path.relative(process.cwd(), file) });
444
+ }
445
+
446
+ if (asJson) {
447
+ process.stdout.write(
448
+ `${JSON.stringify({ files: report, wrote: write }, null, 2)}\n`
449
+ );
450
+ return;
451
+ }
452
+
453
+ for (const entry of report) {
454
+ process.stdout.write(`\n${entry.file}\n`);
455
+ for (const c of entry.changes) {
456
+ process.stdout.write(` ${LABEL[c.kind] ?? c.kind}: ${c.detail}\n`);
457
+ }
458
+ }
459
+ const verb = write ? "changed" : "would change";
460
+ const hint = write ? "" : " Re-run with --write to apply.";
461
+ process.stdout.write(
462
+ `\n${report.length} file(s) with findings, ${changedCount} ${verb}.${hint}\n`
463
+ );
464
+ };
465
+
466
+ main();
@@ -31,8 +31,9 @@ export interface ComponentSlotPlan {
31
31
  }
32
32
 
33
33
  const EMPTY_MODULE = `// Generated by Blume. Do not edit.
34
- export const mdxComponents = {};
35
- export const layoutOverrides = {};
34
+ import type { ComponentOverride } from "blume/core/define-components.ts";
35
+ export const mdxComponents: Record<string, ComponentOverride> = {};
36
+ export const layoutOverrides: Record<string, ComponentOverride> = {};
36
37
  `;
37
38
 
38
39
  // A user-supplied attribute value interpolated into a generated .astro tag: a