blume 1.0.2 → 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 (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/index.js +496 -295
  3. package/dist/cli/index.js.map +18 -17
  4. package/dist/types/core/config-input.d.ts +44 -22
  5. package/dist/types/core/config.d.ts +3 -3
  6. package/dist/types/core/data.d.ts +12 -0
  7. package/dist/types/core/i18n-ui.d.ts +136 -136
  8. package/dist/types/core/schema.d.ts +502 -384
  9. package/dist/types/core/types.d.ts +10 -0
  10. package/dist/types/openapi/references.d.ts +12 -7
  11. package/docs/advanced/api-reference.mdx +11 -3
  12. package/docs/advanced/custom-pages.mdx +2 -0
  13. package/docs/configuration/ai.mdx +6 -4
  14. package/docs/configuration/index.mdx +6 -8
  15. package/docs/configuration/seo.mdx +20 -1
  16. package/docs/content/components.mdx +26 -5
  17. package/docs/content/navigation.mdx +10 -0
  18. package/docs/content/syntax.mdx +116 -4
  19. package/package.json +1 -1
  20. package/skills/blume-migrate/SKILL.md +170 -0
  21. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  22. package/skills/blume-migrate/references/docusaurus.md +95 -0
  23. package/skills/blume-migrate/references/fumadocs.md +95 -0
  24. package/skills/blume-migrate/references/mintlify.md +155 -0
  25. package/skills/blume-migrate/references/monorepo.md +224 -0
  26. package/skills/blume-migrate/references/nextra.md +76 -0
  27. package/skills/blume-migrate/references/starlight.md +116 -0
  28. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  29. package/src/ai/agent-readability.ts +3 -3
  30. package/src/ai/mcp/data.ts +2 -2
  31. package/src/astro/component-slots.ts +3 -2
  32. package/src/astro/generate.ts +97 -30
  33. package/src/astro/templates.ts +140 -53
  34. package/src/blume-modules.d.ts +6 -0
  35. package/src/components/content/Callout.astro +8 -2
  36. package/src/components/content/Prompt.astro +25 -13
  37. package/src/components/layout/Header.astro +19 -10
  38. package/src/components/layout/Logo.astro +13 -1
  39. package/src/components/layout/PageFeedback.astro +1 -1
  40. package/src/components/layout/PageLayout.astro +11 -11
  41. package/src/components/layout/Pagination.astro +6 -6
  42. package/src/components/layout/ReferenceLayout.astro +1 -0
  43. package/src/components/layout/RootLayout.astro +10 -11
  44. package/src/components/layout/Search.astro +1 -1
  45. package/src/components/layout/nav-utils.ts +9 -7
  46. package/src/core/config-input.ts +47 -27
  47. package/src/core/config.ts +3 -3
  48. package/src/core/data.ts +9 -1
  49. package/src/core/navigation.ts +55 -13
  50. package/src/core/schema.ts +32 -15
  51. package/src/core/server-features.ts +1 -1
  52. package/src/core/sources/watch.ts +5 -0
  53. package/src/core/types.ts +10 -0
  54. package/src/deploy/adapter-output.ts +11 -1
  55. package/src/markdown/index.ts +2 -0
  56. package/src/markdown/language-icon.ts +2 -1
  57. package/src/markdown/table-wrap.ts +43 -0
  58. package/src/og/card.ts +39 -12
  59. package/src/og/index.ts +1 -1
  60. package/src/og/logo.ts +21 -0
  61. package/src/openapi/references.ts +19 -16
  62. package/src/registry/eject.ts +11 -5
  63. package/src/theme/entry.ts +50 -5
@@ -0,0 +1,76 @@
1
+ # Nextra → Blume
2
+
3
+ Nextra (on Next.js) declares navigation and per-page labels in `_meta` files — and crucially, **a folder's title and its pages' sidebar labels live in the _parent_ `_meta`**. Reconciling that cross-file inheritance is the main task. Nextra 4 was a near-rewrite, so **fingerprint the generation first** — it decides where config lives, where content lives, and what the components are called.
4
+
5
+ ## Detect & fingerprint the generation
6
+
7
+ - **`_meta.{js,mjs,cjs,ts,jsx,tsx,json}`** files — the strongest fingerprint (v4 also has a single-file `_meta.global.*` variant).
8
+ - **v2/v3 (Pages Router):** content in `pages/`, `_meta.json` (v2) or `_meta.{js,ts}` (v3), a `theme.config.{js,jsx,ts,tsx}`, components from `nextra-theme-docs` (v2) or `nextra/components` (v3).
9
+ - **v4 (App Router):** **no `theme.config`** — config lives as component props in `app/layout.{jsx,tsx}` (`<Layout>`, `<Navbar>`, `<Banner>`, `<Footer>`, `<Search>`). Content is either a **`content/`** directory (with a catch-all `app/[[...mdxPath]]/page.jsx` gateway) **or** Next page files: **`app/**/page.mdx`**.
10
+ - `nextra` + `next` deps; `next.config.*` wrapping `nextra({…})`.
11
+
12
+ **v4 app-directory repos need a restructure, not a `content.root`.** Pointing `content.root` at `app/` gives every route a trailing `/page` and scans `layout.jsx`/gateway files. Move each `app/foo/page.mdx` → `foo.mdx` (or `foo/index.mdx` when it has children), and exclude the non-MDX app files. The catch-all `page.jsx`, `mdx-components.*`, and `app/layout.*` are teardown artifacts — but **harvest `app/layout.*` first** (see Config).
13
+
14
+ ## Config
15
+
16
+ None of it maps automatically — read the config surface for the generation by hand and reconstruct in `blume.config.ts`:
17
+
18
+ - **v2/v3 — `theme.config.*`:** `logo` (JSX — extract text/image) → `logo`; `project.link` → `github` (renders the header repo link); `docsRepositoryBase` → `github` (owner/repo/branch — and any trailing sub-path → `github.dir`); `banner.text`/`banner.key` → `banner.content`/`banner.id` (`dismissible` maps); `primaryHue`/`primarySaturation` → pick an equivalent `theme.accent` color; `footer` → drop (report); `faviconGlyph` → drop (Blume's favicon is a file convention); `useNextSeoProps`/head → per-page `seo` frontmatter or drop.
19
+ - **v4 — `app/layout.*` props:** the same facts moved: `<Navbar logo projectLink>`, `<Layout docsRepositoryBase editLink sidebar={{…}} toc={{…}}>`, `<Banner>`, `<Footer>`. Map them the same way; sidebar/TOC tuning mostly drops (Blume's `toc` config covers min/max heading levels).
20
+ - **`next.config.*`:** harvest **`redirects()`** — static entries become Blume `redirects: [{ from, to }]`; wildcard/dynamic ones move to host config (report). A `latex: true` flag means math is in play (see Math below). Then delete the file.
21
+ - Root `_meta` entries with **`type: "page"`** → `navigation.tabs` (`{ label, path }`, where `path` is `/` for `index`, else `/<slug>`). `type: "page"` only maps at the **root**.
22
+
23
+ ## Navigation: `_meta` → `meta.ts` + frontmatter
24
+
25
+ **Every `_meta` file must be carried over — always convert it to `meta.ts` + frontmatter, never drop it in favor of filename inference.** `_meta` is Nextra's canonical nav source (ordering, labels, folder titles); the filesystem alone can't reproduce it.
26
+
27
+ For each `_meta` entry (`key` = slug, value = string title or `{ title, type, display, href, theme }`):
28
+
29
+ | Nextra `_meta` entry | Blume |
30
+ | --- | --- |
31
+ | ordinary page/folder (string or `{ title }`) | slug → parent `meta.ts` `pages` (ordering). A **page** title → that page's frontmatter `sidebar.label`. A **folder** title → that child folder's `meta.ts` `title` (title lives in the parent!). A JSX title → extract its text. |
32
+ | `display: "hidden"` | frontmatter `sidebar.hidden: true` |
33
+ | `type: "separator"` | drop → recreate as a `(Group)/` folder / `meta.ts` boundary if needed |
34
+ | `type: "menu"` (navbar dropdown) | drop → recreate via `navigation.selectors` if wanted |
35
+ | `href` (external link) | **`navigation.featured`** (`{ label, href, icon? }` — pinned above the sidebar on every route); only drop deep-nested ones (report) |
36
+ | `type: "page"` (subfolder, not root) | drop (only root → tabs) |
37
+ | `theme: { collapsed }` on a folder | `meta.ts` `collapsed` |
38
+ | `theme: { layout: "full" \| sidebar: false \| … }` | drop (report — no per-page layout switches) |
39
+ | `"*"` wildcard entry | apply its value as the default for unlisted siblings, then drop |
40
+
41
+ Write each folder's `meta.ts` with its `pages` order (from its own `_meta`) and its `title` (inherited from the parent's `_meta`). A v4 **`_meta.global.*`** file holds the whole tree in one place (folder entries carry `items`) — split it into per-folder `meta.ts` files with the same rules.
42
+
43
+ ## Frontmatter & titles — synthesize, don't just pass through
44
+
45
+ Most Nextra pages have **no frontmatter**; the title falls back `_meta` title → `sidebarTitle` → `title` → **first body H1** → filename. Blume renders frontmatter `title` as the page H1, so for **every page**: set frontmatter `title` (from the `_meta` title, else the body H1, else a humanized filename), **remove the body H1**, and map `sidebarTitle` → `sidebar.label`. `asIndexPage: true` (v4 folder-index marker) → make the file the folder's `index.mdx` and drop the key. Drop any other non-schema key and report it.
46
+
47
+ ## Components
48
+
49
+ - **Callouts:** `<Callout type="x">` → directive. Types: `default` (the prop default) → `:::note`, `info`→`:::info`, `warning`→`:::warning`, `error`→`:::danger`, `important` (v4) → `:::note`. Bare `<Callout>` → `:::note`. Drop `emoji` (there is no `title` prop to carry — Nextra callouts have none).
50
+ - **v2 flat names:** v2 exported `Tab` and `Card` (flat) from **`nextra-theme-docs`**; v3/v4 use `Tabs.Tab`/`Cards.Card` from `nextra/components`. Handle both: `<Cards>`/`<Cards.Card>`/`<Card>` → `<CardGroup>`/`<Card>`; `<Tabs items={[…]}>` + `<Tabs.Tab>`/`<Tab>` → `<Tabs>`/`<Tab title="…">` (move labels from the parent `items` onto each `<Tab>`). `Cards` `num` → `CardGroup` `cols`.
51
+ - **`<Steps>`:** Nextra's wraps **Markdown headings (h2–h6)**, one step per heading. Convert each heading to a `<Step title="…">` child (Blume `<Steps>`/`<Step>`) and delete the heading — this also keeps step titles out of the TOC, matching Nextra's behavior.
52
+ - **`<FileTree>`/`<FileTree.Folder>`/`<FileTree.File>`** → `<Tree>`/`<Tree.Folder>`/`<Tree.File>` (or a list-driven `<FileTree>`).
53
+ - **`<Bleed>`** (full-bleed) → no equivalent; drop the wrapper and report. **`<Table>`** → a plain Markdown table. **`<Banner>`** (v4, in layout) → the `banner` config.
54
+ - **GitHub alert blockquotes** (v4 renders them): `> [!NOTE]`/`[!TIP]`/`[!WARNING]`/`[!IMPORTANT]`/`[!CAUTION]` → `:::note`/`:::tip`/`:::warning`/`:::note`/`:::warning` directives — Blume renders them as plain blockquotes otherwise.
55
+ - **Strip or convert every import:** `nextra`, `nextra/*`, `nextra-theme-docs`, `nextra-theme-blog`, plus `next/image` (→ Markdown image or `<Frame>`), `next/link` (→ plain link), and local components (port or inline; report).
56
+
57
+ ## Code fences
58
+
59
+ Nextra's fence meta differs from Blume's — rewrite it: `filename="app.js"` → a space-separated title (` ```js app.js `); `showLineNumbers` → `lineNumbers`; line highlighting `{1,4-5}` carries over unchanged; **drop** word-highlight `/word/`, `copy`/`copy=false`, and inline-code `{:lang}` suffixes. ` ```sh npm2yarn ` fences → ` ```package-install `.
60
+
61
+ ## Math
62
+
63
+ Nextra enables math via `nextra({ latex: true })` (KaTeX or MathJax). In Blume, block math `$$…$$` renders in `.mdx` with **no config** (there is no `markdown.math` field). Convert Nextra's ` ```math ` fences → `$$…$$` blocks (they render as plain code blocks otherwise). Inline `$…$` is **not** supported — convert to display math or drop (report). MathJax-specific macros → report.
64
+
65
+ ## i18n
66
+
67
+ - **v2/v3:** locale **file suffixes** (`index.en.mdx`, `index.zh.mdx`) + `i18n` in `next.config` → restructure into locale **folders** (default locale at the content root, others under `<code>/`), then `i18n: { defaultLocale, locales: [{ code, label }] }`.
68
+ - **v4:** `content/<lang>/` dirs already match Blume's `dir` parser — map the locale list, no file moves.
69
+
70
+ ## Package.json & teardown
71
+
72
+ Repoint `dev`/`build`/`start` scripts to the Blume CLI; remove `next`/`nextra`/`nextra-theme-*` deps and add `blume`. Delete after harvesting: `next.config.*` (redirects first!), `theme.config.*` (v2/v3), `app/layout.*` + the catch-all gateway + `mdx-components.*` (v4).
73
+
74
+ ## Dropped — report these
75
+
76
+ Footer content; `primaryHue`-style theming beyond an accent color; `faviconGlyph`; `_meta` separators, menus, `newWindow`; per-page `theme` layout switches; `<Bleed>`; MathJax macros; word-highlight/copy fence meta; any icon you can't reconstruct as a Lucide name.
@@ -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();