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,95 @@
1
+ # Docusaurus → Blume
2
+
3
+ Docusaurus is a React site; navigation comes from `sidebars.js` (explicit or autogenerated) plus per-folder `_category_.json`. Admonitions are already directive syntax, which eases the content transform — **but note the source major version**: v2 (MDX v1) and v3 (MDX v3) have different content hazards, called out below.
4
+
5
+ ## Detect
6
+
7
+ - **`docusaurus.config.{js,ts}`** at the root; a **`sidebars.{js,ts}`** file and per-folder **`_category_.json`** files.
8
+ - Content under **`docs/`** (often plus `blog/`, `src/pages/`, `static/`, `versioned_docs/`); `@docusaurus/*` deps.
9
+ - v2 vs v3: check the `@docusaurus/core` major in `package.json`.
10
+
11
+ ## Config: `docusaurus.config` → `blume.config.ts`
12
+
13
+ Read `themeConfig`, `presets`, and `plugins`:
14
+
15
+ | Docusaurus | Blume |
16
+ | --- | --- |
17
+ | `title` | `title` |
18
+ | `tagline` | `description` |
19
+ | `themeConfig.navbar.title` / `.logo` | `title` / `logo` (move the image into `public/`) |
20
+ | `themeConfig.navbar.items` (doc items) | `navigation.tabs` (for section links) |
21
+ | `themeConfig.navbar.items` (external/utility links — Blog, GitHub, Discord…) | **`navigation.featured`** (`{ label, href, icon? }`, pinned above the sidebar on every route); the GitHub link → the `github` config instead |
22
+ | `themeConfig.colorMode.defaultMode` | `theme.mode` (`respectPrefersColorScheme: true` → `"system"`) |
23
+ | `themeConfig.prism.theme` / `.darkTheme` | `markdown.codeBlocks.theme: { light, dark }` (map Prism theme names to Shiki themes, e.g. `github`/`github-dark`) |
24
+ | `themeConfig.metadata` / `themeConfig.image` | per-page `seo` frontmatter / `seo.og`; report what doesn't fit |
25
+ | `url` + `baseUrl` | **`url` → drop** (`deployment.site` auto-detects); `baseUrl` (when not `/`) → `deployment.base` |
26
+ | preset `docs.routeBasePath` — **including the default!** | Docusaurus serves docs at **`/docs/…` by default**; the "map only declared fields" rule does **not** apply here because the _URLs_ are load-bearing. Either keep them with top-level **`basePath: "/docs"`** (invisible to the sidebar), or intentionally move to root and emit a `redirects` entry per page. Decide explicitly and say which. (`routeBasePath: '/'` = docs-only mode — nothing to do.) |
27
+ | preset `docs.editUrl` | `github` (owner/repo/branch; a path after the branch → `github.dir`) |
28
+ | `themeConfig.footer` | drop → Footer override (`defineComponents` layout slot) |
29
+ | `themeConfig.announcementBar` | `banner` (`{ content, dismissible, id }` — `isCloseable` → `dismissible`; colors drop) |
30
+ | `i18n.locales` / `defaultLocale` | `i18n` — translated files live at `i18n/<locale>/docusaurus-plugin-content-docs/current/…`; move them to `<locale>/…` under `content.root` |
31
+ | `themeConfig.algolia` | drop — Blume ships built-in search (Orama); remove the Algolia dep |
32
+ | `@docusaurus/plugin-client-redirects` | **static `redirects: [{from, to}]` arrays convert 1:1** to Blume `redirects` (a `from` array = one entry per item); only `createRedirects` functions are truly dynamic → host rules |
33
+ | `@docusaurus/theme-mermaid` | delete the dep — ` ```mermaid ` renders natively (in `.mdx`) |
34
+ | `remark-math` + `rehype-katex` | delete — block `$$…$$` renders in `.mdx` with no config (no `markdown.math` field exists); **inline `$…$` is not supported** — convert or drop (report) |
35
+ | Multi-instance docs plugins (`plugin-content-docs` with `id`) | one folder (and usually one `navigation.tabs` entry) per instance |
36
+
37
+ ## Static assets — required move
38
+
39
+ Every Docusaurus repo serves **`static/`** at the site root (`static/img/foo.png` → `/img/foo.png`). Blume serves **`public/`**. **Move `static/*` (and any extra `staticDirectories`) into `public/`** — all root-relative references then keep resolving unchanged. Without this, every image and download 404s.
40
+
41
+ ## Navigation: `sidebars.js` + `_category_.json`
42
+
43
+ - **Autogenerated sidebar** (`{ type: 'autogenerated', dirName: '...' }`) → Blume's default filesystem navigation. Docusaurus strips numeric prefixes (`01-`) exactly like Blume, so the convention round-trips; no config needed.
44
+ - **Explicit sidebar** (arrays of doc IDs, categories, links) → restructure into folders where possible; use `navigation.sidebar` only for shapes files can't express. A category `{ type: 'category', label, items }` → a folder (label → `meta.ts` `title`); `collapsed` → `meta.ts` `collapsed` (**no `display` field in `meta.ts`** — if collapse matters, set the global `navigation.sidebar.display: "group"` once in config).
45
+ - **`_category_.json`** (also `.yml`) (`{ label, position, collapsed, collapsible, link, className, customProps }`) → a folder `meta.ts`: `label`→`title`, `position`→`order`, `collapsed`→`collapsed`. `link.type: 'generated-index'` → an `index` page in the folder — **and the old URL was `/docs/category/<slug>`**, so add a redirect and rewrite inbound links. `link.type: 'doc'` → make that doc the folder's `index`. `collapsible`/`className`/`customProps` → drop (report).
46
+ - **`src/pages/` — inventory, don't nuke.** Nearly every repo has a React landing page (`src/pages/index.tsx`) and often extra Markdown pages. Markdown pages → content pages; the React home page → rebuild as a docs index or a custom `.astro` page under `content.pages` — report either way.
47
+
48
+ ## Versioned docs
49
+
50
+ Docusaurus `versioned_docs/version-X/` + `versions.json` (+ `versioned_sidebars/`) → **recommend migrating the latest released version only**. Mind the URL scheme: by default the **latest release** serves at `/docs/` and the work-in-progress `docs/` folder serves at `/docs/next` (`lastVersion: 'current'` flips this) — pick the folder that matches what users see at `/docs/`. If older versions must stay, put each under its own folder and wire a `navigation.selectors` entry of `kind: "version"`.
51
+
52
+ ## Blog
53
+
54
+ A Docusaurus `blog/` → Blume `type: blog` pages. **Dates come from filenames/folders** (`2024-01-31-foo.md`) — extract each into `date` frontmatter and strip the date from the filename (the old dated URLs `/blog/2024/01/31/foo` need `redirects`). Strip `<!-- truncate -->` / `{/* truncate */}` markers. `authors.yml` refs → inline author objects in each post's `authors` frontmatter. RSS stays at `/blog/rss.xml` on both sides.
55
+
56
+ ## Content & components
57
+
58
+ - **`.md` vs `.mdx` — both majors need renames, for opposite reasons.** Blume parses `.md` as plain Markdown: no directives, no JSX, no `$$` math, no mermaid/package-install fences. **v3** treats `.md` as MDX (so a `.md` with imports/JSX/`{}` renders them as literal text in Blume); **v2** content is looser MDX v1. Rule: **rename any `.md` that contains admonitions, JSX, imports, or math to `.mdx`** — for typical Docusaurus repos that is most files.
59
+ - **Admonitions are directives — but check the version.** v3: `:::note`, `:::tip`, `:::info`, `:::warning`, `:::danger` pass through; `:::caution` → `:::warning` (or rely on Blume's alias); titles `:::note[Title]` work. **v2:** titles are space-separated (`:::note Your Title`) — rewrite to brackets or the title is silently lost; and v2's `:::warning` rendered **red/danger** — audit whether it should become `:::danger`.
60
+ - **Tabs:** `<Tabs>`/`<TabItem label="…" value="…">` → `<Tabs>`/`<Tab title="…">`. Drop `groupId`/`queryString`/`value`; strip the `@theme/Tabs` imports.
61
+ - **Theme JSX in content:** `<DocCardList/>` (standard on category index pages) → hand-write `Card`/`CardGroup` links or delete (a Blume group page lists its children); `<TOCInline/>` → drop (report); `<CodeBlock>` JSX → a fenced code block; `<Admonition>` → the matching directive; `<details>`/`<summary>` → `<Accordion>`/`<AccordionItem>` or leave as raw HTML.
62
+ - **`@theme/*` / `@site/*` imports** — strip `@theme/*` (Blume injects components globally); rewrite `@site/` asset/module paths to `/public` URLs or inline. **MDX partials** (`_partial.mdx` imports) → inline the partial's body (Blume's default `**/_*` exclude already hides the partial files themselves).
63
+ - **Code blocks:** `title="file.js"` → works as-is; `showLineNumbers` → `lineNumbers`; **magic comments** (`// highlight-next-line`, `highlight-start`/`end`) → `{ranges}` or `// [!code highlight]` — unconverted they ship as literal comments in every sample; ` ```bash npm2yarn ` → ` ```package-install `.
64
+ - **MDX v1 (v2 sources) pitfalls:** unescaped `<`/`{` in prose, HTML comments `<!-- -->` (→ `{/* */}`), string `style="…"` attributes (→ objects). Fix as build errors surface.
65
+
66
+ ## Frontmatter
67
+
68
+ | Docusaurus | Blume |
69
+ | --- | --- |
70
+ | `title` / `description` | pass through |
71
+ | `id` | usually drop (routing is filesystem-based); use `slug` to pin a route |
72
+ | `slug` | `slug` |
73
+ | `sidebar_label` | `sidebar.label` |
74
+ | `sidebar_position` | `sidebar.order` |
75
+ | `unlisted` | `hidden: true` + `noindex: true` |
76
+ | `last_update.date` | `lastModified` |
77
+ | `draft` | `draft` |
78
+ | `tags` | `search.tags` (or drop if only used for the blog tag index) |
79
+ | `image` | `seo.image` |
80
+ | `keywords` | drop → use `seo` |
81
+ | `sidebar_class_name` / `sidebar_custom_props` / `pagination_next` / `pagination_prev` / `pagination_label` / `hide_title` / `hide_table_of_contents` / `toc_min_heading_level` / `toc_max_heading_level` / `displayed_sidebar` / `custom_edit_url` | drop (report) |
82
+
83
+ Remove any duplicated H1 in the body — `title` renders the H1.
84
+
85
+ ## Icons
86
+
87
+ Docusaurus has no built-in icon-name system (icons are usually inline SVG/emoji/CSS). Where you add sidebar/tab icons, use Lucide names. Convert any FontAwesome/react-icons usage in content to Lucide `<Icon>` or drop it.
88
+
89
+ ## Teardown
90
+
91
+ Remove `@docusaurus/*` and Algolia deps; delete `docusaurus.config.*`, `sidebars.*`, `babel.config.js`, and `src/` theme customizations (after the `src/pages/` inventory above; re-implement what matters via `defineComponents` layout slots or `blume eject`). Repoint `start`/`build`/`serve` scripts → `blume dev`/`blume build`/`blume preview`; add `blume`.
92
+
93
+ ## Dropped — report these
94
+
95
+ Custom/swizzled theme components (layout slots or `blume eject`), footer columns, Algolia config, `sidebar_custom_props` and the other dropped frontmatter keys, `createRedirects` functions (→ host rules), React pages under `src/pages/`, `<TOCInline>`, per-category `className`/`customProps`, and any `@theme/*` component with no Blume equivalent.
@@ -0,0 +1,95 @@
1
+ # Fumadocs → Blume
2
+
3
+ Fumadocs is code-first: navigation comes from the folder tree + per-folder `meta.json`. This maps cleanly to Blume — most content passes through, and `meta.json` becomes `meta.ts`. Fumadocs v16 runs on Next.js, React Router, TanStack Start, Astro, or Waku — detect by the Fumadocs files, not by Next.js files, and adjust the teardown list to whichever host framework is present.
4
+
5
+ ## Detect
6
+
7
+ - Content under **`content/docs/`** (the Fumadocs convention).
8
+ - Per-folder **`meta.json`** files.
9
+ - `fumadocs-ui` / `fumadocs-core` / `fumadocs-mdx` deps and a `source.config.ts`.
10
+ - A `loader({ baseUrl: "/docs" })` call in `lib/source.ts` (or `app/source.ts`, `src/lib/source.ts`, `source.ts`).
11
+
12
+ ## Config
13
+
14
+ Fumadocs declares almost nothing Blume needs — only two things map:
15
+
16
+ - **Title** — read `package.json` `name`, prettified (drop scope, split on `-_`, Title-Case). For a generic monorepo name (`web`, `app`, `docs`…), use the repo-root directory name instead.
17
+ - **Route prefix** — the `baseUrl` in the source loader (required in Fumadocs, so it's always declared). If it's a real prefix (e.g. `/docs`), set top-level **`basePath: "/docs"`** — it prefixes every route while staying invisible to the sidebar, which matches Fumadocs' behavior. (A `content.sources` `prefix` also works but adds a wrapping nav group — only use it when you _want_ the group.) If `baseUrl` is `"/"`, serve from the site root (nothing to set).
18
+
19
+ Everything else is `defineConfig({ title })`.
20
+
21
+ **Also read `source.config.ts` `mdxOptions`** — remark/rehype plugins live there:
22
+
23
+ - `remark-math`/`rehype-katex` → nothing to configure: block math `$$…$$` renders in `.mdx` out of the box (there is **no** `markdown.math` field). Inline `$…$` is **not** supported — convert inline math to `$$…$$` or drop it (report).
24
+ - A Twoslash transformer → Blume supports the `twoslash` fence meta natively; drop the plugin.
25
+ - Custom Shiki transformers/themes or other plugins → report.
26
+
27
+ ## Navigation: `meta.json` → `meta.ts`
28
+
29
+ **Every `meta.json` becomes a `meta.ts` — this is the primary navigation carry-over for Fumadocs, always required, never optional.** `meta.json` is Fumadocs' canonical nav source; no filesystem-only shortcut reproduces its ordering, icons, and collapse state, so don't skip it in favor of filename inference. Move content from `content/docs/` to your chosen `content.root` (e.g. `docs/`), then convert each `meta.json` to a `meta.ts` (`defineMeta`):
30
+
31
+ | Fumadocs `meta.json` | Blume `meta.ts` |
32
+ | --- | --- |
33
+ | `title` | `title` |
34
+ | `icon` | `icon` — **convert the casing** (see Icons below) |
35
+ | `defaultOpen: false` | `collapsed: true` |
36
+ | `defaultOpen: true` | `collapsed: false` |
37
+ | `root: true` | a **`navigation.tabs` entry** (see below) — **not** a `meta.ts` field |
38
+ | `pages: [...]` slugs | `pages: [...]` (ordering) |
39
+ | `description` | **drop** (folders have no description) |
40
+ | `collapsible: false` | **drop** (report — no per-folder equivalent) |
41
+
42
+ `meta.ts` accepts **only** `title`, `icon`, `order`, `collapsed`, `pages` — there is **no `display` field** (writing one is a build error). Collapse rendering is global: when any folder needs collapsing, set `navigation.sidebar.display: "group"` once in `blume.config.ts`.
43
+
44
+ **`root: true` folders are Fumadocs' tab mechanism** — Fumadocs UI renders them as layout tabs and scopes the sidebar to the active one. That is exactly Blume's `navigation.tabs`: add `{ label, path, icon? }` per root folder (label/icon from its `meta.json`), pointing at the folder's route. Blume then scopes the sidebar by URL prefix the same way. Don't try to model root folders inside `meta.ts`.
45
+
46
+ Handle the `pages` array items:
47
+
48
+ - **`"..."`** (rest marker) / `""` → drop; Blume appends unlisted pages automatically. **`"z...a"`** (reversed rest) → no equivalent; list the pages explicitly in the intended order.
49
+ - **`"!page"`** (exclusion) → Blume _appends_ unlisted pages, so a dropped `!page` **resurfaces in the sidebar**. Set that page's frontmatter `sidebar.hidden: true` instead.
50
+ - **`"---Section---"`** (separator; also the `---[Icon]Label---` variant) → Blume has no flat separator. Turn each section into a **`(Section)/` group folder** (route-transparent — the `(…)` segment is stripped from URLs), and move the section's pages into it. If a section wraps a single existing folder, leave it in place and set that folder's `meta.ts` `title` instead.
51
+ - **`"...folder"`** (extract) → Blume can't flatten a folder inline; keep it as a normal group at that ordering position and report it.
52
+ - **`"[Text](url)"`** (link; also `[Icon][Text](url)` and `external:` variants) → a top-level/utility link belongs in **`navigation.featured`** (`{ label, href, icon? }` — pinned above the sidebar on every route). A link buried deep in one folder has no folder-meta home — drop and report (or model that one folder via an explicit `navigation.sidebar`).
53
+ - **`pagesIndex`** → make that page the folder's `index` (rename the file); report the route change and add a redirect.
54
+
55
+ ## Frontmatter
56
+
57
+ Fumadocs' core frontmatter (`title`, `description`, `icon`) matches Blume — but **convert `icon` casing** (see Icons). Drops: **`full`** (Fumadocs' full-width/no-TOC layout — no equivalent; report) and **`_openapi`** (generated API stubs — delete the whole page, see OpenAPI). Any other non-schema key is a build error, so drop and report.
58
+
59
+ ## Icons: lucide-react names → kebab-case
60
+
61
+ Fumadocs icons are strings resolved by the repo's own `icon` handler in `loader()` — in practice **PascalCase lucide-react export names** (`"BookOpen"`, `"HomeIcon"`). Blume resolves **kebab-case** Lucide names and silently renders nothing on a miss, so pass-through loses every icon. Read the loader's icon handler to confirm the scheme, then convert each name: strip any `Icon` suffix, kebab-case it (`BookOpen` → `book-open`, `HomeIcon` → `home`), and verify it exists at [lucide.dev/icons](https://lucide.dev/icons).
62
+
63
+ ## Components
64
+
65
+ - **Callouts:** `<Callout type="x">` → `:::` directive. Fumadocs types are `info` (the default), `warn`, `warning`, `error`, `success`, `idea`: `warn`/`warning`→`:::warning`, `error`→`:::danger`, `success`→`:::success`, `idea`→`:::tip`, `info` and **bare `<Callout>`** → `:::info`. `title` → `:::type[Title]`; drop `icon`.
66
+ - **Cards:** `<Cards>` → `<CardGroup>`. `<Card>` needs **prop surgery**, not pass-through: Fumadocs `icon` is a JSX element (`icon={<Cpu />}` imported from `lucide-react`) → Blume takes a string name (`icon="cpu"`); Fumadocs `description="…"` → Blume has no `description` prop — move the text into the card body.
67
+ - **Accordions (container/item inversion):** `<Accordions>` → `<Accordion>` (container); `<Accordion>` → `<AccordionItem>` (item).
68
+ - **File trees:** `<Files>` → **`<Tree>`** (the JSX container for `Tree.*` children — _not_ `<FileTree>`, which wraps a Markdown list); `<Folder>` → `<Tree.Folder>` (`defaultOpen` carries over); `<File>` → `<Tree.File>`. Or convert the whole block to a list-driven `<FileTree>`.
69
+ - **Tabs:** Fumadocs declares labels on the parent (`<Tabs items={['npm','pnpm']}>`) and selects with `<Tab value="npm">`. Blume's `<Tab>` carries its own `title`. Strip `items={[…]}` from `<Tabs>` and give each child `<Tab>` a `title` (from its `value`, or the positional `items` entry).
70
+ - **Pass through unchanged:** `<Steps>`/`<Step>`, `<TypeTable>` (Blume's was modeled on Fumadocs' — identical `type` record shape), `<GithubInfo>`.
71
+ - **`<Banner>`** (layout-mounted, not per-page) → the `banner` config field (`{ content, link, dismissible, id }`).
72
+ - **`<include>./partial.mdx</include>`** — Blume has no runtime include. **Inline** the partial's body (strip its frontmatter) at migration time; resolve nested includes recursively.
73
+ - **No equivalent — report:** `<DynamicCodeBlock>`, `<ImageZoom>` (Blume zooms content images by default), `<InlineTOC>`.
74
+ - **Strip or convert every import** — not just `fumadocs-*`: `lucide-react` imports (icon JSX → string names), `next/image`/`next/link` (→ Markdown image/link), and local components. **Inventory `mdx-components.tsx` before deleting it** — components registered there are used import-free in MDX bodies; port or inline each usage first.
75
+
76
+ ## Code fences
77
+
78
+ - ` ```npm ` fences (Fumadocs' remark-npm accepts both) → ` ```package-install `.
79
+ - Adjacent fences sharing `tab="…"` meta render as grouped tabs in Fumadocs → wrap them in a Blume `<CodeGroup>` and drop the `tab=` attrs.
80
+
81
+ ## OpenAPI
82
+
83
+ `fumadocs-openapi` writes **generated MDX stubs into the content tree** (`generateFiles()` output: pages containing `<APIPage>`/`<OpenAPIPage>` with `_openapi` frontmatter), plus `lib/openapi.ts` (`createOpenAPI`) and a generate script. Treat these exactly like Mintlify endpoint stubs: **delete the generated pages**, point `openapi: { enabled: true, sources: [{ spec }] }` at the spec (vendor it locally), add a `navigation.tabs` entry for the reference route, and remove `fumadocs-openapi`, `lib/openapi.ts`, and the generate script. Keep hand-written conceptual pages (intro/auth) under the reference route.
84
+
85
+ ## i18n
86
+
87
+ A `loader({ i18n })` setup (locale-suffixed files or locale dirs) → Blume `i18n: { defaultLocale, locales: [{ code, label }] }`. Locale **directories** match Blume's `dir` parser as-is; locale **file suffixes** (`page.cn.mdx`) need restructuring into locale folders. Report whichever transform you apply.
88
+
89
+ ## Package.json & teardown
90
+
91
+ Repoint scripts (`dev`→`blume dev`, `build`→`blume build`, `start`→`blume preview`), remove the `fumadocs-*` deps and the host framework's deps (`next`, `react-router`, `@tanstack/*`…), add `blume`. Safe to delete after harvesting (see above for what to read first): `source.config.*`, `mdx-components.tsx`, the app/route dir, and host-framework config (`next.config.*`, `next-env.d.ts`, the `next` tsconfig plugin — or the React Router/TanStack equivalents).
92
+
93
+ ## Dropped — report these
94
+
95
+ Folder `description` and `collapsible: false`; reversed rest (`z...a`) ordering; the extract (`...folder`) flatten semantics; deep-folder sidebar links; frontmatter `full`; `<DynamicCodeBlock>`/`<InlineTOC>`; custom Shiki transformers; any icon with no Lucide equivalent after casing conversion.
@@ -0,0 +1,155 @@
1
+ # Mintlify → Blume
2
+
3
+ The deepest migration. Mintlify declares its **entire navigation in `docs.json`**; Blume derives navigation from the filesystem. The bulk of the work is reshaping content into folders + tabs and translating config, callouts, and icons.
4
+
5
+ ## Detect
6
+
7
+ - `docs.json` (current) or `mint.json` (legacy) at the repo root — the config file.
8
+ - A `mintlify` dependency / `mintlify dev` script.
9
+ - Content is `.mdx` under the repo root (Mintlify has no `content.root` — pages live at the top level), with a `/snippets` folder and image dirs like `/images`.
10
+
11
+ Read `docs.json` first; it drives everything.
12
+
13
+ ## Config: `docs.json`/`mint.json` → `blume.config.ts`
14
+
15
+ Resolve `$ref` includes first (Mintlify splits config across files). Map only what's set:
16
+
17
+ | Mintlify | Blume | Notes |
18
+ | --- | --- | --- |
19
+ | `name` / `title` | `title` | |
20
+ | `description` | `description` | |
21
+ | `logo` (string or `{ light, dark, href }`) | `logo` (string, or `{ image: { light, dark, alt }, text, href }`) | Mintlify's `light`/`dark` nest under Blume's `image`; ensure files land in `public/`. **If the SVG is local and monochrome** (a solid black or white mark), rewrite its `fill`/`stroke` to `currentColor` and collapse `{ light, dark }` to the string shorthand `logo: "/logo.svg"` — one file that inherits the theme text color and flips with light/dark automatically. If the logo is a **wordmark** (brand name baked in), set `text: ""` so it doesn't render twice beside `title` |
22
+ | `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy **one** file into `public/` under the conventional name (`favicon.svg`/`icon.png`) | Blume auto-detects by filename; there is **no** favicon config field, and **no** light/dark favicon — a `{ light, dark }` source collapses to one; report the loss |
23
+ | `colors.primary` + `colors.light` | `theme.accent` (`{ light, dark }`) | accent is now **per-mode**: `accent: { light: <colors.primary>, dark: <colors.light> }`. If Mintlify sets only `primary`, collapse to the string shorthand `accent: "<colors.primary>"` (applies to both modes) |
24
+ | `colors.dark` | `theme.action` | |
25
+ | `appearance.default` | `theme.mode` (`light`/`dark`/`system`) | |
26
+ | `appearance.strict` | **drop** | `theme.strict` was removed — writing it is a config error |
27
+ | `background.color.{light,dark}` | `theme.background` (`{ light, dark }`) | one field now: `background: { light: …, dark: … }` (a bare string applies to both) |
28
+ | `background.image` | `theme.backgroundImage` (`{ light, dark }`) | same per-mode shape as `background` |
29
+ | `background.decoration` | **drop** | no Blume equivalent |
30
+ | `fonts.family` / `fonts.{heading,body}.family` | `theme.fonts.{display,body}` | only if the family is a curated Google-font slug (kebab-case, e.g. `space-grotesk`); otherwise drop and tell the user to add `@font-face` in `theme.css` |
31
+ | `banner` | `banner` (`{ content, dismissible, id, link }`) | **only** those keys — drop `banner.color`/`banner.type` |
32
+ | `styling.latex: true` | **drop the field** — block math `$$…$$` renders in `.mdx` with no config | there is **no** `markdown.math` field; inline `$…$` is **not** supported — convert inline math to `$$…$$` or drop it (report) |
33
+ | `styling.codeblocks.theme` | `markdown.codeBlocks.theme` (`{ light, dark }`) | |
34
+ | `search.prompt` | **drop** | no equivalent |
35
+ | `seo.metatags` | **drop** | no equivalent; use per-page `seo` frontmatter |
36
+ | `seo.indexing: "all"` | `search.indexing.includeHiddenPages: true` | |
37
+ | `variables` (`{{name}}`) | **inline into content** | Blume has no runtime `{{var}}` substitution — replace each `{{name}}` with its value in the pages |
38
+ | `integrations.posthog` (`{ apiKey, apiHost }`) | `analytics.posthog` (`{ key, host }`) | preserve the host verbatim (e.g. `us.posthog.com` — Blume's default is `us.i.posthog.com`) |
39
+ | `integrations` (GA, Plausible, Fathom, …) | `analytics.scripts` / `analytics.vercel` | one `scripts[]` entry per provider (`{ src, strategy, attributes }`); no first-class mapping beyond PostHog/Vercel |
40
+ | `contextual` (`["copy","chatgpt","claude",…]`) | **mostly free** | Copy-as-Markdown and Open-in-chat are default page actions; `mcp` needs `mcp.enabled` + server output (report as a follow-up) |
41
+ | `redirects` | `redirects: [{ from, to }]` | static only — see below |
42
+ | `navigation.languages` | `i18n` | see i18n below |
43
+
44
+ A minimal result is often just `defineConfig({ title, logo, theme: { accent } })`.
45
+
46
+ ## Icons: FontAwesome → Lucide (required)
47
+
48
+ Mintlify defaults to **FontAwesome**; Blume is **Lucide-only**. Convert every icon reference — frontmatter `icon`, nav-group `icon`, `<Icon>`, `<Card icon>` — to the closest Lucide name. Discard Mintlify's `iconType` (solid/regular/brands) entirely.
49
+
50
+ **Automate the frontmatter pass — don't hand-edit it.** `scripts/mintlify-codemod.mjs` (in this skill, zero-dependency) remaps every **frontmatter** `icon:` using the table below, drops brand/no-equivalent icons, and — in the same pass — drops/renames unsupported frontmatter keys (see [Frontmatter](#frontmatter)). It's deterministic and idempotent, and reports what it changed per file plus what it couldn't (unknown icons, OpenAPI-stub flags):
51
+
52
+ ```bash
53
+ # dry run first (report only), then apply:
54
+ node <this-skill>/scripts/mintlify-codemod.mjs <content-dir>
55
+ node <this-skill>/scripts/mintlify-codemod.mjs --write <content-dir>
56
+ ```
57
+
58
+ The codemod touches **only frontmatter**. Icons in MDX **body** (`<Icon icon="…">`, `<Card icon="…">`, nav-group icons in `docs.json`) it does not see — convert those by hand using the same table. Common mappings:
59
+
60
+ | FontAwesome | Lucide | | FontAwesome | Lucide |
61
+ | --- | --- | --- | --- | --- |
62
+ | `bolt` | `zap` | | `gear`/`cog` | `settings` |
63
+ | `circle-info`/`info` | `info` | | `wand-magic-sparkles`/`magic` | `sparkles` |
64
+ | `house` | `house` | | `magnifying-glass` | `search` |
65
+ | `gauge`/`gauge-high` | `gauge` | | `puzzle-piece` | `puzzle` |
66
+ | `envelope` | `mail` | | `file-lines` | `file-text` |
67
+ | `pen-to-square` | `square-pen` | | `trash-can` | `trash-2` |
68
+ | `xmark`/`times` | `x` | | `circle-check` | `circle-check` |
69
+ | `triangle-exclamation` | `triangle-alert` | | `circle-exclamation` | `circle-alert` |
70
+ | `arrow-right-from-bracket` | `log-out` | | `right-to-bracket` | `log-in` |
71
+ | `location-dot`/`map-marker` | `map-pin` | | `comments` | `messages-square` |
72
+ | `cube` | `box` | | `cubes`/`boxes` | `boxes` |
73
+ | `layer-group` | `layers` | | `diagram-project`/`sitemap` | `workflow`/`network` |
74
+ | `chart-line` | `chart-line` | | `chart-simple`/`chart-column` | `chart-column` |
75
+ | `flask` | `flask-conical` | | `robot` | `bot` |
76
+ | `screwdriver-wrench`/`toolbox` | `wrench` | | `circle-question`/`question` | `circle-help` |
77
+ | `life-ring` | `life-buoy` | | `shield-halved` | `shield` |
78
+ | `rocket`/`book`/`book-open`/`code`/`terminal`/`key`/`lock`/`user`/`users`/`database`/`server`/`cloud`/`bell`/`calendar`/`star`/`heart`/`tag`/`folder`/`globe`/`link`/`download`/`upload`/`check`/`copy`/`play`/`filter` | _(same name — verify)_ |
79
+
80
+ **Rules:** verify each Lucide name exists at [lucide.dev/icons](https://lucide.dev/icons) before writing it. **Brand icons** (`fa6-brands:*` — github, discord, x, slack, linkedin…) mostly have **no** Lucide equivalent: for GitHub use the `github` config (renders the header repo link); for other socials, drop the icon and report it (or add via a Footer override after `blume eject`). Where no Lucide counterpart exists, **drop the icon and report it** — an unknown icon name is a build error.
81
+
82
+ ## Navigation: `docs.json` `navigation` → filesystem + tabs
83
+
84
+ Mintlify's `navigation` object (`tabs`/`anchors`/`dropdowns`/`products`/`versions`/`languages`/`groups`/`pages`) is fully config-declared. **Prefer restructuring content into folders**, not porting the config verbatim:
85
+
86
+ - **`groups`** (`{ group, pages: [...] }`) → a folder per group. The `group` name → the folder's humanized name or a `meta.ts` `title`. Nested groups → nested folders. `expanded: false` → `meta.ts` `collapsed: true` (inverted). `tag` → the folder/page `sidebar.badge`. **Folder metadata is a `meta.ts` module** (`export default defineMeta({ title, icon, order, collapsed, pages })`), a TypeScript file — **not** JSON. Blume does not read a `meta.json`, so any folder-level config you carry over from the source (or hand-write to preserve a group's label/order/collapse) must be authored as `meta.ts`. (Render mode — flat/group/page — is **not** a `meta.ts` field; it's the global `navigation.sidebar.display` in `blume.config.ts`.)
87
+ - **Config-only nested groups don't exist on disk — you must materialize them, or they flatten silently.** A nested `{ group, pages }` almost never has a matching subfolder: its pages sit **flat in the parent directory** (e.g. `platform/analytics/getting-started.mdx`, `…/quick-reference.mdx`) and the grouping lives **only** in the `docs.json` `pages` array. If you leave the files where they are, filesystem-derived nav sees one flat folder and the inner group vanishes — Mintlify's `Analytics → Reference → {…}` becomes a flat `Analytics → {…}`. To preserve it you must **either** move those pages into a real subfolder (`platform/analytics/reference/`, with a `meta.ts` for the label/`collapsed`), **or** declare the shape in an explicit `navigation.sidebar`. Walk **every** `pages` array recursively and treat any nested `group` object as a folder-move to plan, not files already in place. When inventorying the nav (workflow step 2), record the config nesting depth separately from the on-disk depth — they diverge exactly here.
88
+ - **This is the canonical case for `navigation.sidebar` over folders.** Materializing a purely-presentational nested group as a subfolder changes URLs (`/platform/analytics/getting-started` → `/platform/analytics/reference/getting-started`) for no reason other than a visual grouping, forcing a `redirects` entry per page. When you want to keep the nesting **and** the URLs, an explicit `navigation.sidebar` group (`{ label, items }`) is the better trade — it nests the existing routes without moving any file. Pick per group; don't reflexively flatten.
89
+ - **`pages`** entries are page refs (paths without extension) → files at the corresponding path. An entry that's `"GET /path"` is an OpenAPI endpoint stub → **delete it** (Blume generates these; see OpenAPI).
90
+ - **`tabs`** (top-level `navigation.tabs`, e.g. `App` / `API` / `API reference`) → Blume's **header tabs** `navigation.tabs` (`{ label, path, icon? }`). **Keep them as tabs — do not flatten them into `navigation.sidebar` groups or sections.** Flattening is the easy translation error: it collapses the site's top-level structure into one global sidebar and loses the header tab bar entirely. A Mintlify top-level tab is a header tab; map it to one. Put each tab's pages in **one folder** and point the tab's `path` at it — the tab then scopes the sidebar automatically (under the tab's `path`, the sidebar shows only that folder). **Blume resolves the active tab by URL prefix** (the tab whose `path` is the longest prefix of the current route), so every page in a tab must live under that tab's single `path`. Mintlify tabs freely mix pages from any folder / arbitrary routes; Blume tabs scope by prefix, so a tab that pulls in routes from all over is **not portable as-is**. You have two choices, per tab:
91
+ - **Move the pages under one prefix** so they share the tab's `path` (a route change → add a `redirects` entry for each moved page). This preserves the exact tab grouping.
92
+ - **Accept the closest shape** — assign each shared/cross-cutting page to one tab's folder and link to it from the others — when moving routes isn't worth the churn.
93
+
94
+ Call this trade out explicitly in the migration report; don't silently pick one. **The scoping is bidirectional:** on the root/landing route Blume _hides_ every tab folder and lists only untabbed top-level pages — so a Mintlify home whose single sidebar showed everything becomes a lean root list plus one sidebar per tab. That's automatic; don't try to exclude tab folders from the root by hand.
95
+
96
+ - **`dropdowns`/`products`/`versions`** → `navigation.selectors` (`{ kind, label, items: [{ label, path, icon?, description?, tag? }] }`). Use `kind` `dropdown`/`product`/`version` accordingly.
97
+ - **`anchors` / `navigation.global.anchors` / `navbar.links`** (persistent header links — Blog, Changelog, Community, Contact/Support) → **`navigation.featured`** (`{ label, href, icon? }`). These pin to the **top of the sidebar, above every section, on every route** (not tab-scoped) — the right home for Mintlify's always-visible utility links. An external `href` opens in a new tab; an internal one (`/contact`) is build-time validated against your pages. Convert the FontAwesome `icon` to Lucide as usual. (This replaces the old "drop and report" for anchors.) The `navbar.primary` **CTA button** has no featured equivalent — see Dropped.
98
+ - **`languages`** → `i18n`, not a selector (see below).
99
+ - Only fall back to an explicit `navigation.sidebar` for a shape the filesystem genuinely can't express.
100
+
101
+ This reshaping **changes URLs** — a page moved from `getting-started/quickstart` into the API tab's folder becomes `/api/…`, an `index` promotion drops a segment, etc. Record each old→new path and add a `redirects` entry for it (see below); otherwise every existing link and bookmark 404s.
102
+
103
+ ## Content & component transforms
104
+
105
+ Rewrite each page's MDX:
106
+
107
+ - **Callouts → directives** (directives are **MDX-only** — Mintlify content is already `.mdx`, so this just works; never rename a page to `.md`): `<Note>`→`:::note`, `<Tip>`→`:::tip`, `<Warning>`→`:::warning`, `<Info>`→`:::info`, `<Check>`→`:::success`, `<Danger>`/`<Error>`→`:::danger`. `<Callout type="x">` maps by type (`caution`→warning, `check`→success). A `title` attr → `:::type[Title]`. Drop `icon`/color props.
108
+ - **Accordions — container/item inversion!** Mintlify nests `<Accordion title="…">` inside `<AccordionGroup>`. Blume inverts: `<AccordionGroup>`→`<Accordion>` (container), and each Mintlify `<Accordion title="…">`→`<AccordionItem title="…">` (item).
109
+ - **`<RequestExample>`/`<ResponseExample>`** → `<CodeGroup>` (titled-fence tabs).
110
+ - **These pass through — Blume ships them natively:** `<Columns>`/`<Column>`, `<Expandable>`, `<Tooltip>`, `<Frame>`, `<Panel>`, `<Card>`/`<CardGroup>`, `<Tabs>`/`<Tab>`, `<Steps>`/`<Step>`. Keep them as-is.
111
+ - **API fields → `TypeTable`.** Blume does **not** ship `<ParamField>`/`<ResponseField>`/`<RequestField>`. Convert a cluster of fields into one `<TypeTable>` (rows keyed by field name, each `{ type, required?, default?, description }`). For a fully spec'd API, prefer deleting the hand-written fields and using the [OpenAPI reference](#openapi) instead.
112
+ - **`<Update>`** (a Mintlify changelog entry) has no component form → convert to a `type: changelog` page, or use the `github-releases` source.
113
+ - **Snippets are inlined, not imported.** Blume has no `/snippets` import mechanism. For each `import X from "/snippets/x.mdx"` + `<X prop="v" />`, inline the snippet's body (substituting `{prop}` placeholders), then delete the import and the `/snippets` file. Named string imports (`import { foo } from "/snippets/vars.mdx"`) → inline the value at each `{foo}`.
114
+
115
+ ## Frontmatter
116
+
117
+ Mintlify page frontmatter → Blume's strict schema. **`scripts/mintlify-codemod.mjs --write` does the mechanical rows automatically** (rename, drop, icon-remap) and flags the judgment rows (`openapi`/`asyncapi`/`api`) for you — see [Icons](#icons-fontawesome--lucide-required) for the invocation. The table is both the mapping reference and a description of exactly what the codemod does:
118
+
119
+ | Mintlify | Blume | Codemod |
120
+ | --- | --- | --- |
121
+ | `title` / `description` | pass through | — |
122
+ | `sidebarTitle` | `sidebar.label` | renames |
123
+ | `icon` | remapped to a Lucide name in place (top-level `icon` is valid Blume frontmatter; keep it there) | remaps |
124
+ | `tag` | `sidebar.badge` | renames |
125
+ | `canonical` | `seo.canonical` | renames |
126
+ | `og:image` | `seo.image` | renames |
127
+ | `hidden: true` | valid top-level in Blume — **kept as-is**; add `noindex: true` yourself if the page must also leave the search index | left (do by hand) |
128
+ | `openapi`/`asyncapi`/`api` | usually an endpoint stub → **delete the page** (Blume generates operation pages); else `type: api` | **flags** for review — never auto-deletes a page |
129
+ | `mode`, `public`, `rss`, `groups`, `keywords`, `hideApiMarker`, `hideFooterPagination`, `iconType` | **drop** (report) | drops |
130
+
131
+ The codemod leaves the source key in place and reports a conflict rather than clobbering data when a rename target already exists (e.g. a page already has `sidebar.label`) or the value is too structured to move safely — resolve those by hand. Remove any duplicate H1 in the body — `title` renders the H1. (The codemod only edits frontmatter; it never touches the body.)
132
+
133
+ ## OpenAPI
134
+
135
+ Top-level `openapi`, `api.openapi`, or a per-group/per-tab `openapi` → `openapi: { enabled: true, sources: [{ spec, label?, route? }] }`. A Mintlify `{ source, directory }` object: `directory` → the source's `route`. **Delete every per-endpoint stub page** (frontmatter `openapi: "GET /path"` or a `"GET /path"` nav entry) — Blume's native renderer generates one real page per operation. **Add a `navigation.tabs` entry pointing at the reference `route` yourself** (Mintlify's API tab maps to it); the reference does not create a header tab automatically.
136
+
137
+ - **Vendor the spec.** Mintlify usually points at a spec **URL**. Copying that straight into `spec:` makes every build fetch it at build time — a single point of failure in CI/offline/behind a proxy, and a failed fetch silently drops the reference (leaving the tab pointing at a route that 404s). Prefer downloading it into the repo (`curl … -o openapi/<name>.json`) and pointing `spec` at that local path. If you keep the URL, report the dependency and consider a `prebuild` refresh-with-fallback.
138
+ - **Fix endpoint links.** Blume operation routes are `<route>/<slugified-tag>/<slugified-operationId>` (tag `Models` + id `listModels` → `/api-reference/models/listmodels`) — this differs from Mintlify's endpoint URLs, so **rewrite every inbound link to an operation**. `blume validate` resolves operation pages like any other route and flags the ones you miss.
139
+ - **Keep the "Introduction" page.** Mintlify commonly has a written intro/auth page in an "Introduction" group beside the "Endpoints" (openapi) group in the same tab. Keep it: a normal content page placed under the openapi `route` (e.g. `<root>/api-reference/introduction.mdx`) merges into the reference tab's sidebar alongside the generated operations. Delete only the per-endpoint stubs, not the conceptual pages.
140
+
141
+ ## Assets
142
+
143
+ Mintlify serves every top-level dir (e.g. `/images`) at the site root. Blume serves `public/` at the site root. **Move root asset dirs into `public/`** (`mv images public/images`) — every `/images/...` reference still resolves, unchanged. Move loose root files (`logo.png`, `favicon.png`) under `public/` too.
144
+
145
+ ## i18n
146
+
147
+ `navigation.languages` (≥2) → `i18n: { defaultLocale, locales: [{ code, label }] }`. The `default: true` language → `defaultLocale`. Translated content already lives in ISO-code directories, which match Blume's `dir` parser — no file moves. Remove any language selector; language switching is handled by i18n.
148
+
149
+ ## Dropped — report these
150
+
151
+ - **`navbar.primary`** (the prominent header **CTA button**, e.g. "Get Started"/"Sign Up") → no button equivalent; re-add via `navigation.tabs`, a `navigation.featured` link, or a Header override. (Plain `navbar.links` and `anchors` map to `navigation.featured` — see Navigation above, not here.)
152
+ - **`footer.socials`** → suggest the `github` config, or a Footer override.
153
+ - **Per-language banners** (`navigation.languages[].banner`) → no equivalent.
154
+ - **Dynamic redirects** (`:slug*`/`:id` params) → can't be static path-to-path; move to host rules (`_redirects`, `vercel.json`).
155
+ - **`<Update>`** changelog components, `iconType`, `background.decoration`, `search.prompt`, `seo.metatags`, non-curated fonts.
@@ -0,0 +1,224 @@
1
+ # Migrating into a real repo (monorepos, pnpm, Vercel)
2
+
3
+ The framework references (`mintlify.md`, etc.) cover translating _content and config_. This file covers the **host repo** the docs live in — the concerns that surface when the target isn't a bare docs folder but a pnpm/Turbo workspace deployed on Vercel. All six were real failures in a production migration; none are Blume bugs, they're integration steps.
4
+
5
+ **Golden rule for this whole file: detect, then edit surgically, then say what you changed and why.** Every edit here touches a file the repo owns (`pnpm-workspace.yaml`, `vercel.json`, `package.json`, the lockfile). Never guess a path depth or a workspace name — read it out of the repo first. Where a step can't be fully automated (the Astro patch, the Vercel Root Directory setting), leave a copy-pasteable manual instruction rather than a half-applied change.
6
+
7
+ ---
8
+
9
+ ## 1. Non-standard content layout → scope `content.root` + `include`
10
+
11
+ `content.root` defaults to `"docs"`, and `blume` runs from the directory that holds `blume.config.ts` (in a monorepo, that's the docs _package_, e.g. `apps/docs/`). Many repos **don't** have a `docs/` subfolder — the content sits directly under the app dir in topic folders (`apps/docs/api/`, `apps/docs/getting-started/`). With the default root, Blume looks for `apps/docs/docs/` and finds nothing.
12
+
13
+ **Detect by finding where `.md`/`.mdx` actually live** — never assume `docs/`. From the docs package dir:
14
+
15
+ ```bash
16
+ # top two path segments of every content file, deduped — your content folders
17
+ find . -path ./node_modules -prune -o \( -name '*.md' -o -name '*.mdx' \) -print \
18
+ | sed 's|^\./||' | cut -d/ -f1 | sort -u
19
+ ```
20
+
21
+ If that lists a real `docs` folder, keep the default. If it lists content folders directly (`api`, `getting-started`, `guides`), the content root is the package dir itself.
22
+
23
+ **Set `content.root` to the package dir and scope with explicit `include` globs — do _not_ use `content.root: "."` alone.** A bare `.` root scans the whole package: `src/`, `public/`, `README.md`, config files, and (in a monorepo) anything a loose glob reaches. That's slow and pulls non-content Markdown into the site. Instead pin `root` and list the real content folders in `content.include`:
24
+
25
+ ```ts blume.config.ts
26
+ import { defineConfig } from "blume";
27
+
28
+ export default defineConfig({
29
+ content: {
30
+ root: ".", // content sits directly under this package (apps/docs/), no docs/ subfolder
31
+ // include/exclude globs are relative to `content.root`. List the actual
32
+ // content folders so the scan never wanders into src/, public/, etc.
33
+ include: [
34
+ "api/**/*.{md,mdx}",
35
+ "getting-started/**/*.{md,mdx}",
36
+ "guides/**/*.{md,mdx}",
37
+ ],
38
+ exclude: ["**/node_modules/**", "**/_*", "**/.*"],
39
+ },
40
+ });
41
+ ```
42
+
43
+ Schema field names, exactly: **`content.root`** (string, relative to the project/cwd), **`content.include`** (array of globs, relative to `content.root`, default `["**/*.{md,mdx}"]`), **`content.exclude`** (array of globs, relative to `content.root`, default `["**/_*", "**/.*"]`). Singular `include`/`exclude`, both arrays.
44
+
45
+ Report to the user: the detected content root, the folders you scoped `include` to, and anything Markdown you deliberately left out (a top-level `README.md`, a `CHANGELOG.md`) so they can confirm it isn't content.
46
+
47
+ ---
48
+
49
+ ## 2. pnpm `minimumReleaseAge` blocks installing fresh Blume
50
+
51
+ pnpm's supply-chain guard (`minimumReleaseAge`, often set to `1440` = 24h) refuses to install any package version published more recently than the window. Right after Blume publishes, that's **every** Blume release — so `pnpm add blume` / the workspace install fails or silently pins an older version.
52
+
53
+ **Do not disable the policy globally** — that removes the guard for the whole dependency tree. Add **only `blume`** to the exclude list.
54
+
55
+ **Detect** in `pnpm-workspace.yaml` (pnpm 10+, the primary location) and, for older setups, `.npmrc`:
56
+
57
+ ```bash
58
+ grep -rn "minimumReleaseAge\|minimum-release-age" pnpm-workspace.yaml .npmrc 2>/dev/null
59
+ ```
60
+
61
+ **Fix — `pnpm-workspace.yaml`** (surgical add; create the key if the policy is set but no exclude exists):
62
+
63
+ ```yaml
64
+ minimumReleaseAge: 1440
65
+ minimumReleaseAgeExclude:
66
+ - blume
67
+ ```
68
+
69
+ **Fix — `.npmrc`** form (if the policy lives there instead):
70
+
71
+ ```ini
72
+ minimum-release-age=1440
73
+ minimum-release-age-exclude[]=blume
74
+ ```
75
+
76
+ Report: "Added `blume` to `minimumReleaseAgeExclude` so the just-published version installs; the release-age guard still applies to everything else."
77
+
78
+ ---
79
+
80
+ ## 3. Frozen lockfile — regenerate and commit `pnpm-lock.yaml` in the same change
81
+
82
+ CI and Vercel install with `--frozen-lockfile` (pnpm's default in CI): if `package.json` and `pnpm-lock.yaml` disagree by even one dependency, the install **fails immediately** — before any build step runs. A migration always edits dependencies (add `blume`, drop the old framework), so the lockfile is guaranteed stale.
83
+
84
+ **After every dependency edit, regenerate the lockfile and stage it with the manifest:**
85
+
86
+ ```bash
87
+ pnpm install # NOT --frozen-lockfile — this rewrites pnpm-lock.yaml
88
+ git add package.json apps/docs/package.json pnpm-lock.yaml
89
+ ```
90
+
91
+ Rules:
92
+
93
+ - Run `pnpm install` (plain) from the **workspace root**, not the docs package, so the single root lockfile updates.
94
+ - The lockfile change belongs in the **same commit** as the `package.json` change. A commit that edits deps without the matching lockfile is a broken build for anyone who pulls it.
95
+ - Verify locally the way CI will: `pnpm install --frozen-lockfile` should succeed with no diff. If it errors "lockfile not up to date," you forgot the plain install.
96
+
97
+ ---
98
+
99
+ ## 4. Vercel monorepo recipe (root-aware install + build)
100
+
101
+ A plain `blume build` works locally, but Vercel in a workspace needs three things aligned: the install must run at the **workspace root** (so pnpm resolves the whole graph under a frozen lockfile), the build must run in the **docs package**, and Vercel must be pointed at the built `dist/`. `blume build` emits a static site to `dist/` **relative to where it runs** — so building in `apps/docs/` produces `apps/docs/dist/`.
102
+
103
+ This is a **copyable template**, not prose. Three pieces:
104
+
105
+ **a) Docs package `apps/docs/package.json`** — the standard Blume scripts:
106
+
107
+ ```json
108
+ {
109
+ "scripts": {
110
+ "dev": "blume dev",
111
+ "build": "blume build",
112
+ "preview": "blume preview"
113
+ },
114
+ "dependencies": {
115
+ "blume": "^1"
116
+ }
117
+ }
118
+ ```
119
+
120
+ **b) `apps/docs/vercel.json`** — root-aware install, local build, `dist` output:
121
+
122
+ ```json
123
+ {
124
+ "$schema": "https://openapi.vercel.sh/vercel.json",
125
+ "framework": null,
126
+ "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
127
+ "buildCommand": "pnpm run build",
128
+ "outputDirectory": "dist"
129
+ }
130
+ ```
131
+
132
+ **c) The one setting `vercel.json` can't hold — set it in the Vercel project (dashboard → Settings → General, or `vercel` CLI):**
133
+
134
+ - **Root Directory** = `apps/docs`. This makes install/build/output paths resolve from the docs package, which is what the `vercel.json` above assumes.
135
+ - **Node version** = 22 or newer (Blume requires it). Or pin it in the docs package: `"engines": { "node": ">=22" }`.
136
+
137
+ How the pieces fit, with Root Directory = `apps/docs`:
138
+
139
+ - `installCommand` does `cd ../..` to reach the **workspace root** and runs a frozen install of the whole graph. **Adjust `../..` to the app's actual depth** — an app at `packages/web/docs/` needs `cd ../../..`. Read the path; don't assume two levels.
140
+ - `buildCommand` runs in `apps/docs/` and invokes `blume build` (via the package's `build` script) → emits `apps/docs/dist/`.
141
+ - `outputDirectory: "dist"` is relative to the Root Directory (`apps/docs`), so it points at `apps/docs/dist/`.
142
+ - `framework: null` stops Vercel from auto-detecting Astro and overriding your build/output with its Astro preset (which expects a different output path). Blume's static output is plain files — Vercel just serves the folder.
143
+
144
+ If the repo uses **Turborepo**, you can instead build through the root pipeline — `"buildCommand": "cd ../.. && pnpm turbo build --filter=docs"` — for remote-cache reuse. Keep `outputDirectory: "dist"` and Root Directory `apps/docs`.
145
+
146
+ Report every piece you wrote and the two dashboard settings the user must set by hand (Root Directory, Node version) — those can't be committed.
147
+
148
+ **Redirect caveat on this deploy path:** a static build emits `vercel.json` redirect rules into `dist/`, but Vercel's **git-integration** builds read `vercel.json` only from the project's Root Directory — the copy inside `dist/` is honored only when the dist folder is deployed directly via the Vercel CLI. On the §4 setup, `blume.config.ts` redirects still work (Blume also emits per-route meta-refresh pages), but they're soft redirects, not real 3xx. If real 3xx responses matter (SEO for a large moved site), copy the generated redirect rules from `dist/vercel.json` after a build into the committed `apps/docs/vercel.json`'s `redirects` array, and note they must be re-synced when `redirects` change. (Most migrations are fine with the soft fallback — say which you chose.)
149
+
150
+ ---
151
+
152
+ ## 5. Astro/Vite version mismatch → pnpm patch (manual)
153
+
154
+ Blume pins **`astro ^7`**. If the workspace forces a specific **Vite** version — a root `pnpm.overrides` / `resolutions` entry, or a `vite` in the workspace catalog — that pinned Vite can diverge from the version Astro 7 expects, and Astro's internal build behavior mismatches. The symptom is a **build-time crash inside Astro/Vite** (a Rollup/Vite internal error, a missing-export or hook-signature error deep in `astro`/`vite` during `blume build`) that doesn't reproduce in a clean, override-free project.
155
+
156
+ **Detect the risk factor** before it bites:
157
+
158
+ ```bash
159
+ grep -rn "\"vite\"\|vite@\| vite:" pnpm-workspace.yaml package.json 2>/dev/null | grep -i "override\|resolution\|catalog\|vite"
160
+ ```
161
+
162
+ Blume is adding early version validation separately, so a future release will flag this at startup. Until then, **recognize the symptom and apply a pinned patch** — pnpm's `patch` workflow writes a committed diff that pnpm reapplies on every install (survives CI and frozen installs):
163
+
164
+ ```bash
165
+ # 1. Open the offending version in an editable temp dir (use the exact version
166
+ # pnpm resolved — check `pnpm why astro`):
167
+ pnpm patch astro@7.0.6
168
+
169
+ # 2. Edit files in the printed temp path to reconcile the Astro/Vite interface
170
+ # (the exact edit depends on the mismatch; the crash's stack trace names the
171
+ # file and call site). Then commit the patch:
172
+ pnpm patch-commit '<the temp path pnpm printed>'
173
+ ```
174
+
175
+ `patch-commit` writes `patches/astro@7.0.6.patch` and registers it under `patchedDependencies` in `pnpm-workspace.yaml` (pnpm 10+) or root `package.json` (`pnpm.patchedDependencies` on pnpm 9):
176
+
177
+ ```yaml
178
+ # pnpm-workspace.yaml
179
+ patchedDependencies:
180
+ astro@7.0.6: patches/astro@7.0.6.patch
181
+ ```
182
+
183
+ Commit **both** `patches/astro@7.0.6.patch` and the `patchedDependencies` entry, then re-run `pnpm install` and `blume build`.
184
+
185
+ This is a **manual step — do not fabricate the patch contents.** Leave the user this exact recipe plus the crash's stack trace, and tell them: the cleaner long-term fix is to relax the workspace's Vite pin so Astro 7 resolves its own compatible Vite, and drop the patch once Blume ships version validation. Don't fail silently — if a build crashes inside Astro/Vite and a Vite override exists, name this as the likely cause.
186
+
187
+ ---
188
+
189
+ ## 6. Ultracite / oxfmt formatting → oxfmt patch (ship the bundled patch)
190
+
191
+ If the repo uses (or the user wants to adopt) **[Ultracite](https://www.ultracite.ai)** for formatting — an `ultracite check` / `ultracite fix` script, an `ultracite` dev dep, or oxlint/oxfmt in the toolchain — it will format the migrated `.md`/`.mdx` **and break Blume's `:::` directives.** Ultracite's formatter is **oxfmt**, and under `proseWrap` oxfmt joins the opening/closing `:::` fence line into the surrounding prose, which invalidates the directive (`:::note` … `:::` callouts, tabs, steps — the exact syntax you convert callouts _into_ in step 5). Blume's own repo hits this and pins a patched oxfmt; a migrated repo needs the same patch or every directive silently degrades to literal text on the next `ultracite fix`.
192
+
193
+ The fix is a committed **pnpm patch** (`patches/oxfmt@0.55.0.patch`), shipped with this skill at `assets/oxfmt@0.55.0.patch`. Unlike the Astro/Vite patch, this one is a **known, deterministic diff** — copy it in, don't regenerate it:
194
+
195
+ ```bash
196
+ # 1. Copy the shipped patch into the target repo's patches/ dir (keep the exact filename):
197
+ mkdir -p patches
198
+ cp "<skill>/assets/oxfmt@0.55.0.patch" patches/oxfmt@0.55.0.patch
199
+ ```
200
+
201
+ Then register it under `patchedDependencies` — `pnpm-workspace.yaml` on pnpm 10+, or root `package.json` (`pnpm.patchedDependencies`) on pnpm 9:
202
+
203
+ ```yaml
204
+ # pnpm-workspace.yaml
205
+ patchedDependencies:
206
+ oxfmt@0.55.0: patches/oxfmt@0.55.0.patch
207
+ ```
208
+
209
+ Commit **both** the patch file and the `patchedDependencies` entry, then re-run `pnpm install`.
210
+
211
+ - **The patch is pinned to `oxfmt@0.55.0`** (the version Ultracite 7.8.x resolves). pnpm requires an exact version match — if `pnpm why oxfmt` reports a different version, the patch won't apply. Bump the key to the resolved version (the hunk is a one-line prose-wrap guard and usually still applies cleanly); if it doesn't, tell the user and fall back to keeping directive-heavy files out of the formatter's globs.
212
+ - **Not on pnpm?** The patch mechanism is pnpm-specific. For npm/yarn, either pin oxfmt and apply the diff with `patch-package`, or exclude `.md`/`.mdx` from Ultracite formatting so it never touches the directives — report whichever you chose.
213
+
214
+ ---
215
+
216
+ ## Checklist for a workspace migration
217
+
218
+ - [ ] Located real content folders (not assumed `docs/`); set `content.root` + scoped `content.include`.
219
+ - [ ] `minimumReleaseAge` present? Added `blume` to `minimumReleaseAgeExclude` only.
220
+ - [ ] Ran plain `pnpm install`; committed `pnpm-lock.yaml` with the `package.json` change; verified `pnpm install --frozen-lockfile` is clean.
221
+ - [ ] Wrote `apps/docs/vercel.json` + package scripts; told the user to set Root Directory + Node 22 in the Vercel project.
222
+ - [ ] Checked for a workspace Vite override; if the build crashes inside Astro/Vite, gave the pnpm-patch recipe.
223
+ - [ ] Uses Ultracite/oxfmt? Shipped `patches/oxfmt@0.55.0.patch` + registered it under `patchedDependencies` so formatting doesn't mangle `:::` directives.
224
+ - [ ] Reported every repo-specific edit and every manual step left to the user.