diff --git a/.agents/skills/blume-migrate/SKILL.md b/.agents/skills/blume-migrate/SKILL.md new file mode 100644 index 0000000..e5e0e84 --- /dev/null +++ b/.agents/skills/blume-migrate/SKILL.md @@ -0,0 +1,170 @@ +--- +name: blume-migrate +description: Migrate an existing documentation site (Mintlify, Docusaurus, Fumadocs, Nextra, Starlight, or any docs framework) to Blume, the markdown-first docs framework on Astro. Translate the source config to blume.config.ts, restructure content into Blume's filesystem-derived navigation, rewrite JSX callouts to directives, convert icons to Lucide, and inline snippets. Use when the user asks to migrate/convert/port a docs repo to Blume, or when the repo has a docs.json/mint.json, docusaurus.config.*, meta.json with fumadocs, _meta.* with nextra, or an astro.config.* with starlight(). +--- + +# Migrate to Blume + +Blume is a **markdown-first** documentation framework on Astro/Vite. You drop Markdown/MDX into a folder and get navigation, search, theming, Open Graph images, and a component library with no app boilerplate — **the framework is the template**. There is no starter to clone; the only thing a project owns is its content and a `blume.config.ts`. + +Your job is to convert a source docs repo into an **idiomatic** Blume project — not a 1:1 transliteration. Read this file, detect the source framework, open the matching `references/.md` for the exact mappings, and work the loop below. Report everything you drop or approximate. + +## Migration philosophy + +- **Target idiomatic Blume, not a mechanical port.** Prefer filesystem-derived navigation over an exhaustive explicit `navigation.sidebar`. Prefer `:::` directives over JSX callouts. Prefer Blume defaults over restating them in config. +- **Every field has a default; `{}` is a valid config.** Map only what the source _declares_. If the source uses a framework default, don't write it. +- **Drop chrome that has no Blume equivalent — and say so.** Navbar CTAs, footer columns, custom theming, dynamic redirects, and unmappable icons get reported to the user, not silently discarded or faked. +- **Convert, don't preserve.** Blume's page frontmatter schema is **strict** — unknown keys are build errors. A source-only frontmatter key must be mapped to a Blume key or removed (and reported), never left to "maybe validate." + +## Migration workflow + +1. **Detect the source framework** and read its reference file: + - `docs.json` / `mint.json` → **Mintlify** (`references/mintlify.md`) — the deepest, config-declared nav. + - `docusaurus.config.*` → **Docusaurus** (`references/docusaurus.md`). + - `meta.json` + `fumadocs-*` deps (content under `content/docs/`) → **Fumadocs** (`references/fumadocs.md`). + - `_meta.{js,ts,json}` + `nextra` deps → **Nextra** (`references/nextra.md`). + - `astro.config.*` calling `starlight({…})` → **Starlight** (`references/starlight.md`). + - Anything else → apply this file's mental model directly; there's no framework-specific reference, so inventory by hand. + - **Also note the host repo, independent of source framework:** a pnpm/Turbo workspace, a non-`docs/` content layout, or a Vercel deploy each need integration steps (`content.root` scoping, `minimumReleaseAge`, lockfile, `vercel.json`, an Astro/Vite patch) — all in `references/monorepo.md`. Read it whenever the target isn't a bare single-package docs folder. +2. **Inventory the repo** before changing anything: the config file(s), the content tree, the nav definition, snippets/partials/includes, static assets, OpenAPI/AsyncAPI specs, redirects, i18n locales, custom components, and icon usage. Note what's declared vs. defaulted. +3. **Write `blume.config.ts`** with `defineConfig` from `blume`. Map only declared fields (see the reference's mapping table); rely on defaults everywhere else. A minimal result is `defineConfig({ title: "…" })`. +4. **Restructure content.** Choose `content.root` (default `docs`) — **detect where `.md`/`.mdx` actually live, don't assume a `docs/` folder.** Many repos keep content directly under an app dir (`apps/docs/api/`, `.../getting-started/`) with no `docs/` subfolder; when so, set `content.root` to that dir and scope `content.include` to the real content folders rather than leaving a bare `content.root: "."` that scans everything (see `references/monorepo.md` §1). Order with numeric prefixes (`01-intro.mdx`), group without a URL segment via `(group)/` folders, and add a `meta.ts` (`defineMeta`) only where filesystem order isn't enough. **A source that already declares per-folder navigation in a sidecar file — Fumadocs `meta.json`, Nextra `_meta.*` — _is_ that case: convert each one to a `meta.ts`, carrying over its title/icon/order/collapse, rather than dropping it and hoping filenames reproduce the intent. Filesystem inference is the fallback for folders that declare no per-folder nav, never a reason to discard one that does.** Reach for an explicit `navigation.sidebar` only when the source nav genuinely can't be expressed by files. **Reshaping into folder-per-tab moves URLs** — track every old→new path as you go; you'll turn them into `redirects` in step 5. +5. **Rewrite pages.** Map frontmatter to Blume's strict schema; convert callout JSX to `:::` directives — **directives (and math/mermaid/package-install fences) are MDX-only, so rename any `.md` page that needs them to `.mdx`**; rename components; inline snippets/partials (Blume has no import-based includes); fix asset paths; **rewrite internal links** to their new routes (including OpenAPI operation links — see the OpenAPI section, their slugs differ from most sources); **add a `redirects` entry for every route you moved** in step 4; **convert every icon name to Lucide** (Blume is Lucide-only — no FontAwesome/Tabler). Remove any duplicated H1 in the body (`title` renders the H1; bodies start at `##`). **If the source has a hand-maintained changelog and the repo is open source on GitHub, offer to swap it for the `github-releases` source** (see "Changelogs" below) rather than porting the entries. For **Mintlify**, run the bundled codemod first — `node /scripts/mintlify-codemod.mjs --write ` deterministically remaps icons and drops/renames unsupported frontmatter keys, and reports the rest (unknown icons, OpenAPI-stub flags) for you to finish by hand (see `references/mintlify.md`). +6. **Adopt `package.json`.** Repoint `dev`/`build`/`start` → `blume dev`/`blume build`/`blume preview`, remove the old framework's deps, add `blume`. A config-only source (e.g. a bare Mintlify `docs.json`) has no manifest — scaffold one. **In a pnpm workspace:** if `pnpm-workspace.yaml`/`.npmrc` sets `minimumReleaseAge`, add **only** `blume` to `minimumReleaseAgeExclude` (don't disable the guard) so the just-published version installs. **Always regenerate the lockfile in the same change:** after editing deps run a plain `pnpm install` (from the workspace root) and commit `pnpm-lock.yaml` alongside `package.json` — CI/Vercel use `--frozen-lockfile`, so a stale lockfile fails the build before it starts. **If the repo uses (or the user wants) [Ultracite](https://www.ultracite.ai) for formatting:** its oxfmt formatter mangles the `:::` directives you just wrote unless you ship the bundled `assets/oxfmt@0.55.0.patch` and register it under `patchedDependencies` — see `references/monorepo.md` §6. See `references/monorepo.md` §2–3. +7. **Wire up the host repo & deploy (non-trivial repos).** For a monorepo on Vercel, emit the root-aware install/build recipe and `apps/docs/vercel.json`, and tell the user the two settings you can't commit (Vercel Root Directory, Node 22). If the workspace pins Vite and `blume build` crashes inside Astro/Vite, apply the pnpm-patch workaround. All copy-pasteable in `references/monorepo.md` §4–5. +8. **Verify.** Run `blume build --strict` (frontmatter schema, duplicate routes, config — **without `--strict` a build exits 0 despite content errors**, silently dropping invalid pages) and `blume validate --strict` (internal links, heading anchors, assets — the link checker lives in `validate`, not `build`), fix diagnostics, then `blume dev` for a visual pass. End with a written summary of what was migrated, dropped, and approximated — **and every repo-specific edit you made** (pnpm-workspace, vercel.json, config globs) with the reason, plus any manual step left to the user (the Astro patch, Vercel dashboard settings). + +## The Blume mental model + +The single biggest shift for most sources — especially Mintlify — is that **navigation is derived from the filesystem**, not declared in config. + +### Navigation is the file tree + +- **Folders become groups, files become pages.** A page's sidebar label is its frontmatter `title`; a group's label is the humanized folder name. +- **Ordering resolves highest-priority-first:** an explicit `navigation.sidebar` (replaces the whole tree) → a folder's `meta.ts` `pages` array → a page's frontmatter `sidebar.order` → the filesystem (`index` first, then numeric filename prefix like `01-`, then alphabetical). +- **`meta.ts` refines one folder** (`defineMeta({ title, icon, order, collapsed, pages })`). The `pages` array lists children by slug (numeric prefix and parentheses stripped); children you omit fall back to their own `sidebar.order`, then filesystem order (`index` still sorts first) — so a partial `pages` list is safe, but list every child when the source declared a complete order. +- **Sidebar render mode is global, not per-folder.** `navigation.sidebar.display` in `blume.config.ts` is `"flat"` (default), `"group"` (collapsible), or `"page"` (drill-in sub-panel) and applies to **every** group at once. (It used to live on each folder's `meta.ts` as `display`; that field was **removed** — writing it in a `meta.ts` is now a build error. Set it once in config instead.) An explicit `navigation.sidebar` item may still override its own group's `display`. +- **An explicit `navigation.sidebar` replaces filesystem generation entirely.** Use it only for a nav shape files can't express. Its items are a page route string, a group (`{ label, items }`), or a link (`{ label, href }`). +- **Config-declared nesting has no on-disk counterpart — materialize it or it flattens silently.** When a source (Mintlify `groups`, Nextra `_meta`, a Docusaurus sidebar…) declares a nested group, its pages usually sit **flat in one folder** and the grouping lives only in config. Filesystem-derived nav sees the flat folder and drops the inner group. To keep the nesting you must **either** move those pages into a real subfolder (`meta.ts` for label/`collapsed`) — which changes their URLs, so add `redirects` — **or** declare the group in an explicit `navigation.sidebar`, which nests the existing routes without moving a file. Walk config `pages`/nav arrays **recursively** during inventory and record where config nesting depth exceeds on-disk depth; that gap is exactly what gets lost. + +### Tabs and selectors + +- **`navigation.tabs`** (`{ label, path, icon? }`) render top-of-header sections and **scope the sidebar by route** — the folder at a tab's `path` becomes the section, so this needs no config beyond the tabs themselves; structure content as **one folder per tab**. **A source's top-level tabs (Mintlify `navigation.tabs`, a top-level product/section switcher) map to these header tabs — keep them as tabs; don't flatten them into a single global `navigation.sidebar`.** Blume picks the active tab by **URL prefix** (longest tab `path` that prefixes the route), so every page in a tab must live under that tab's single `path`; a source tab that mixes arbitrary routes isn't portable as-is — either move its pages under one prefix (route change → add `redirects`) or accept the closest shape, and say which in the report (details in `references/mintlify.md`). The filtering runs both ways: on a route **under** a tab's `path`, the sidebar shows **only** that tab's folder (a tab also highlights when the current route is under it); on a **root or untabbed** route (or a tab whose `path` is `/`), the tab folders are **hidden** and the sidebar shows only the loose pages that belong to no tab (full tree as a fallback, so it's never blank). Consequence for migrations: once you add tabs, the landing sidebar automatically drops the sectioned content — that's intended, not lost pages; don't hand-build excludes for it. +- **`navigation.selectors`** (`{ kind, label, items: [{ label, path, icon?, description?, tag? }] }`, `kind` = `dropdown`/`product`/`version`/`language`) partition a whole site (products, versions) via a header dropdown keyed on the current route. +- **`navigation.featured`** (`{ label, href, icon? }`) pins links to the **top of the sidebar, above every section** — a blog, changelog, or support page that should always be one click away. These are the **exception to tab scoping**: unlike the generated tree, featured links show on **every** route and breakpoint. `href` points anywhere — an external URL opens in a new tab, an internal route (`/contact`) is validated against your pages at build time. `icon` is a Lucide name (or image path/URL/inline SVG), as everywhere else. This is the home for a source's always-visible header/utility links (Mintlify anchors, Blog/Contact links) — see `references/mintlify.md`. + +### Routes and pathing + +- A route is the content path relative to `content.root`, with **numeric prefixes stripped** (`01-intro.mdx` → `/intro`) and **`(group)/` folders adding no segment**. An `index` file maps to its folder's route. Frontmatter `slug` overrides the generated route. + +### `blume.config.ts` shape + +`defineConfig({...})` — every field optional, all with defaults: + +- **Site:** `title`, `description`, `logo` (string SVG, or `{ image: string | { light, dark, alt }, text, href }`), `banner` (`{ content, link, dismissible, id }` — no color/type). A logo renders beside `title` in the header, so a **wordmark logo doubles the brand** ("Acme Acme") — set `text: ""` to render the mark alone. **Prefer the string form over `{ light, dark }`:** if you have the logo SVG locally and it's monochrome (solid black or white), rewrite its `fill`/`stroke` to `currentColor` and use `logo: "/logo.svg"` — it then inherits the theme's text color and adapts to light/dark automatically, so you don't need separate light/dark files. +- **`theme`:** `accent` (a color string for both modes, or `{ light, dark }` per mode), `action` (color), `mode` (`light`/`dark`/`system`), `radius`, `fonts` (`{ body, display, mono }` — curated Google-font slugs), `background` and `backgroundImage` (each a string, or `{ light, dark }` per mode). The old `accentDark`/`backgroundDark`/`backgroundImageDark` fields were **merged into these per-mode objects** — a bare string still applies to both modes, so only reach for `{ light, dark }` when the two modes differ. There is **no** `theme.strict` and **no** `theme.css` config field — custom CSS goes in a project-root **`theme.css` file** (auto-picked-up), and a source's "strict appearance" flags drop. +- **`content`:** `root` (default `"docs"`, relative to the project dir where `blume` runs), `include`/`exclude` (arrays of globs **relative to `content.root`**; defaults `["**/*.{md,mdx}"]` / `["**/_*", "**/.*"]`), `sources` (staged sources: `filesystem`, `github-releases`, `notion`, `sanity`, `mdx-remote`, `custom` — OpenAPI is **not** one of these; it's the top-level `openapi` field), `pages` (custom `.astro` dir), `defaultType`. When docs sit directly under the project dir (no `docs/` subfolder), set `root` there and **scope `include` to the real content folders** instead of scanning everything — `references/monorepo.md` §1. +- **`basePath`** (top-level): a site-wide mount point (e.g. `"/docs"`) prepended to **every** route while staying invisible to the sidebar (no wrapper group). This is the right target for a source that served all docs under a prefix (Docusaurus `routeBasePath`, a Fumadocs `baseUrl` of `/docs`) — distinct from a per-source `prefix` (which adds a nav group) and from `deployment.base` (host subdirectory). +- **`navigation`:** `tabs`, `selectors`, `featured` (links pinned above the sidebar on every route), `sidebar` (`{ display, items }` — `display` is the global render mode above; `items` is an explicit tree), `repo`. **Avoid an explicit `navigation.sidebar` unless you have to** — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for `sidebar.items` only for a shape files genuinely can't express (see "Config-declared nesting" above). +- **`search`** (Orama default, Pagefind opt-in), **`ai`** (llms.txt, Ask AI), **`mcp`**, **`openapi`**, **`redirects`**, **`seo`**, **`markdown`**, **`analytics`**, **`deployment`**, **`i18n`**, **`toc`**, **`lastModified`**, **`github`**. +- **Don't set `deployment.site`.** Blume auto-fills it: the dev server's `localhost` URL in dev, and the deployment URL (`VERCEL_PROJECT_PRODUCTION_URL`/`VERCEL_URL`) on Vercel. Hardcoding it in `blume.config.ts` overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a `url`/`site` field. (Sitemap still generates in production because the deploy URL is present there.) +- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` **collapses to one** — pick a single file and report the loss. + +The schema is exported from `blume/schema`; the full field reference is in `node_modules/blume/docs/configuration/`. + +### Icons are Lucide, period + +Blume resolves **bare kebab-case [Lucide](https://lucide.dev) names** everywhere an icon is accepted — frontmatter `icon`, `sidebar.icon`, `meta.ts` `icon`, `navigation.tabs`/`selectors` icons, and `Card`/`Step`/`Icon`/etc. props. There is **no** FontAwesome or Tabler support and **no** `iconType` prop. Names must be **kebab-case** (`book-open`, not `BookOpen`) — a PascalCase React-component name (common in Fumadocs/lucide-react sources) does not resolve and renders nothing. When migrating a source that uses another icon set (Mintlify defaults to FontAwesome), **map each name to its closest Lucide equivalent**; where none exists, drop the icon and report it. Verify a name exists at [lucide.dev/icons](https://lucide.dev/icons) before writing it. + +### Page frontmatter (strict — unknown keys are build errors) + +```yaml +--- +title: Install # renders as the page H1 — remove any duplicate H1 in the body +description: Install Blume and scaffold your first project. +type: doc # doc (default) | blog | changelog | api +icon: download # a Lucide name +sidebar: + label: Install # overrides title in the sidebar + order: 2 + icon: download + badge: New + hidden: false +seo: + title: … + description: … + image: /og/install.png + canonical: https://… + noindex: false +search: + exclude: false + tags: [api] +slug: install # override the generated route +draft: false +lastModified: 2026-06-20 # pin the "last updated" date +--- +``` + +Also valid: `date`/`authors` (blog/changelog feeds), `changelog` (changelog metadata), `deprecated`, `hidden`, `noindex`. + +### Authoring features (no imports needed in `.mdx`) + +- **The rich features are MDX-only.** Directives, `package-install`, mermaid, and math are wired into the MDX processor; in a plain `.md` file a `:::note` stays **literal text** — and the build stays green. **Rename any `.md` file that uses (or should use) these to `.mdx` during migration.** This bites hardest on Docusaurus/Starlight sources, whose `.md` content is full of `:::` admonitions. Plain Markdown (headings, tables, fenced code with titles/highlighting) is fine in `.md`. +- **Callouts as directives:** `:::note`, `:::tip`, `:::warning`, `:::danger`, `:::info`, `:::success`, with an optional title in brackets: `:::warning[Heads up]`. Aliases `caution`→warning, `error`→danger, `important`→note, `warn`→warning. +- **No-import MDX components:** `Callout`, `Card`/`CardGroup`, `Columns`/`Column`, `Steps`/`Step`, `Tabs`/`Tab`, `Accordion`/`AccordionItem`, `Expandable`, `FileTree`, `Tree`/`Tree.Folder`/`Tree.File`, `CodeGroup`, `Frame`, `Panel`, `Tooltip`, `Tile`, `Badge`, `Icon`, `TypeTable`/`AutoTypeTable`, `Color`, `YouTube`, `Visibility`, `GithubInfo`, `Component`, `CodeBlock`, `Diff`, `Prompt`, `Math`. (**Not** shipped — convert away: `` → the `:::warning` directive, and the `ParamField`/`ResponseField`/`RequestField` field family → `TypeTable` rows or the OpenAPI reference. See the reference files for targets.) +- **Fenced-code superpowers:** ` ```package-install ` → package-manager tabs; ` ```mermaid ` → a rendered diagram; code-block titles (` ```ts server.ts `), line numbers (`lineNumbers`), and highlighting (`{1,4-5}`, `// [!code ++]`). +- **Math:** block math `$$…$$` renders in `.mdx` with **no config** (there is no `markdown.math` field). Inline `$…$` is **not** supported — a bare `$` stays literal text; convert inline math to display math or drop it (report). + +### OpenAPI + +`openapi: { enabled: true, sources: [{ spec, label?, route? }] }` generates **one real page per operation** — with routing, sidebar, search, and OG images for free. **The reference does not get a header tab automatically** — add a `navigation.tabs` entry pointing at the reference's `route` (reference routes are valid tab targets) or the API reference is unreachable from the header. **Never hand-migrate generated API-reference pages** (per-endpoint stub pages in the source): delete them and point `openapi.sources` at the spec. (`renderer: "scalar"` keeps the Scalar embed instead; AsyncAPI uses the same embed.) + +- **Vendor the spec by default.** A remote `spec:` URL makes every build depend on fetching it at build time — a single point of failure in CI, offline, or behind a proxy, and a failed fetch skips the whole reference. Prefer committing the spec into the repo (`openapi/.json`) and pointing `spec` at the local path; if you keep the URL, say so and consider a `prebuild` step that refreshes the local copy with a fallback. +- **Operation routes have their own slug scheme** — `//` (e.g. tag `Models`, id `listModels` → `/api-reference/models/listmodels`). This rarely matches the source's endpoint links (Mintlify/others kebab-case differently), so **rewrite every inbound link to an operation**. `blume validate` resolves operation pages like any other route, so it catches the ones you miss. +- **Keep hand-written conceptual pages.** Sources often pair a written "Introduction/Authentication" page with the endpoint group in the same tab. A normal content page placed under the openapi `route` merges into the reference tab's sidebar — so keep those (auth, errors, rate limits) and delete only the per-endpoint stubs. + +### Changelogs + +If the source ships a **hand-maintained changelog** (a `changelog.mdx`, a folder of dated entries, Mintlify `` blocks) **and the project is open source on GitHub**, offer to replace it with the **`github-releases`** content source — release notes become the changelog automatically, with no files to maintain. It's an offer, not an automatic rewrite: some teams keep a curated changelog that doesn't map 1:1 to GitHub releases, so confirm the release notes are the source of truth before deleting their pages. + +Add it under `content.sources` alongside the filesystem source: + +```ts +content: { + sources: [ + { include: ["docs/**/*.mdx"], root: ".", type: "filesystem" }, + { + owner: "haydenbleasel", + repo: "ultracite", + prefix: "changelog", + type: "github-releases", + }, + ], +}, +``` + +- Each release materializes as a `type: changelog` page under `//` (`prefix: "changelog"` → `/changelog/…`); omit `prefix` to mount at the root. +- Optional fields: `limit` (cap materialized releases, newest-first, default 100), `prereleases` (include prereleases), `drafts` (include drafts — needs a token with repo write access), `pollInterval` (dev polling seconds; omit to freeze for the session). +- A **private** repo reads a token from `GITHUB_TOKEN`; it is never inlined in config. A public repo needs no token. +- **Delete the old changelog pages** once the source is wired (and add `redirects` from their old routes to the new `//…` slugs). Pin a header/sidebar link with `navigation.featured` if the source had one. + +### Redirects are static + +A `redirects: [{ from, to, status? }]` array **in `blume.config.ts`** maps old URLs when you restructure routes — Blume serves these itself, so any reorganization that moves a page (folder-per-tab, materialized nested groups, renamed slugs, index promotion) is fixed by adding an entry there; no host config needed. **Restructuring is the main source of these:** every page you moved in step 4 (folder-per-tab, renamed slugs, index promotion) needs an entry, or old URLs 404. `status` defaults to **301 (permanent — browsers cache it indefinitely)**; that's correct for genuine moves, but never use 301/308 for redirects you might reverse. Dynamic/wildcard patterns (`:slug*`) can't be modeled as static path-to-path; move those to host-level config (`_redirects`, `vercel.json`) and report them. + +## Verification & reporting + +1. Run **`blume build --strict`** — it validates the frontmatter schema, duplicate routes, and config, and `--strict` makes diagnostics fail the build (without it, `blume build` **exits 0 despite content errors** and silently drops invalid pages). Then run **`blume validate --strict`** — links, heading anchors, and assets live here, not in `build` (add `--external` to also check outbound HTTP links). OpenAPI operation pages are real routes to `validate`, so dead links to them are caught too. Iterate until both are clean. +2. Run `blume dev` and review the site visually — nav structure, tabs, theme, rendered components. +3. **Write a migration summary** covering: what was migrated (config, N pages, nav, OpenAPI), what was **dropped** (navbar CTAs, footers, custom theming, dynamic redirects, unmappable icons, unsupported components), and suggested follow-ups (`blume eject` for full control, `blume add` to vendor a component for customization). + +## Full documentation + +The mapping details live in `references/`: one file per source framework (`mintlify.md`, `docusaurus.md`, `fumadocs.md`, `nextra.md`, `starlight.md`), plus **`monorepo.md`** for host-repo integration (content-layout detection, pnpm `minimumReleaseAge`, frozen-lockfile regeneration, the Vercel monorepo recipe, and the Astro/Vite patch). The Mintlify icon + frontmatter pass is automated by **`scripts/mintlify-codemod.mjs`** (zero-dependency, deterministic, idempotent; `--write` to apply). The authoritative Blume docs are bundled in the installed package at **`node_modules/blume/docs`** (or `apps/docs/content/docs` in a repo checkout). The most relevant pages: + +- `configuration/index.mdx` — every `blume.config.ts` field. +- `content/navigation.mdx` — the sidebar/tabs/selectors model. +- `content/meta.mdx` — `meta.ts` and display modes. +- `content/syntax.mdx` — directives, code features, math. +- `content/components.mdx` — the component library and APIs. +- `reference/frontmatter.mdx` — the strict page schema. diff --git a/.agents/skills/blume-migrate/assets/oxfmt@0.55.0.patch b/.agents/skills/blume-migrate/assets/oxfmt@0.55.0.patch new file mode 100644 index 0000000..4909461 --- /dev/null +++ b/.agents/skills/blume-migrate/assets/oxfmt@0.55.0.patch @@ -0,0 +1,20 @@ +diff --git a/dist/markdown-Bowe09rB.js b/dist/markdown-Bowe09rB.js +index 0fe63ff712e07c9c2188711fa0940f7a7d8f7d74..44435d91a2f5a6711512981c9bcaddedc1760be4 100644 +--- a/dist/markdown-Bowe09rB.js ++++ b/dist/markdown-Bowe09rB.js +@@ -1141,7 +1141,14 @@ function Yf(e, r, t) { + case "sentence": return Yi(e, t); + case "word": return Li(e); + case "whitespace": { +- let { next: i } = e, u = i && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(i.value) ? "never" : r.proseWrap; ++ let { next: i, previous: oxfmtFencePrev } = e; ++ // Preserve line breaks that sit directly against a `:::` container ++ // directive fence, so `proseWrap: "never"` keeps the opening/closing ++ // fence on their own lines instead of joining them into the prose (which ++ // breaks the directive). Ordinary prose still wraps per proseWrap. ++ // See prettier/prettier#19040. ++ let oxfmtIsFence = (w) => w != null && typeof w.value === "string" && w.value.startsWith(":::"); ++ let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtIsFence(i) ? "preserve" : i && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(i.value) ? "never" : r.proseWrap; + return qt(e, n.value, u); + } + case "emphasis": { diff --git a/.agents/skills/blume-migrate/references/docusaurus.md b/.agents/skills/blume-migrate/references/docusaurus.md new file mode 100644 index 0000000..e144040 --- /dev/null +++ b/.agents/skills/blume-migrate/references/docusaurus.md @@ -0,0 +1,95 @@ +# Docusaurus → Blume + +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. + +## Detect + +- **`docusaurus.config.{js,ts}`** at the root; a **`sidebars.{js,ts}`** file and per-folder **`_category_.json`** files. +- Content under **`docs/`** (often plus `blog/`, `src/pages/`, `static/`, `versioned_docs/`); `@docusaurus/*` deps. +- v2 vs v3: check the `@docusaurus/core` major in `package.json`. + +## Config: `docusaurus.config` → `blume.config.ts` + +Read `themeConfig`, `presets`, and `plugins`: + +| Docusaurus | Blume | +| --- | --- | +| `title` | `title` | +| `tagline` | `description` | +| `themeConfig.navbar.title` / `.logo` | `title` / `logo` (move the image into `public/`) | +| `themeConfig.navbar.items` (doc items) | `navigation.tabs` (for section links) | +| `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 | +| `themeConfig.colorMode.defaultMode` | `theme.mode` (`respectPrefersColorScheme: true` → `"system"`) | +| `themeConfig.prism.theme` / `.darkTheme` | `markdown.codeBlocks.theme: { light, dark }` (map Prism theme names to Shiki themes, e.g. `github`/`github-dark`) | +| `themeConfig.metadata` / `themeConfig.image` | per-page `seo` frontmatter / `seo.og`; report what doesn't fit | +| `url` + `baseUrl` | **`url` → drop** (`deployment.site` auto-detects); `baseUrl` (when not `/`) → `deployment.base` | +| 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.) | +| preset `docs.editUrl` | `github` (owner/repo/branch; a path after the branch → `github.dir`) | +| `themeConfig.footer` | drop → Footer override (`defineComponents` layout slot) | +| `themeConfig.announcementBar` | `banner` (`{ content, dismissible, id }` — `isCloseable` → `dismissible`; colors drop) | +| `i18n.locales` / `defaultLocale` | `i18n` — translated files live at `i18n//docusaurus-plugin-content-docs/current/…`; move them to `/…` under `content.root` | +| `themeConfig.algolia` | drop — Blume ships built-in search (Orama); remove the Algolia dep | +| `@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 | +| `@docusaurus/theme-mermaid` | delete the dep — ` ```mermaid ` renders natively (in `.mdx`) | +| `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) | +| Multi-instance docs plugins (`plugin-content-docs` with `id`) | one folder (and usually one `navigation.tabs` entry) per instance | + +## Static assets — required move + +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. + +## Navigation: `sidebars.js` + `_category_.json` + +- **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. +- **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). +- **`_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/`**, so add a redirect and rewrite inbound links. `link.type: 'doc'` → make that doc the folder's `index`. `collapsible`/`className`/`customProps` → drop (report). +- **`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. + +## Versioned docs + +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"`. + +## Blog + +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 */}` markers. `authors.yml` refs → inline author objects in each post's `authors` frontmatter. RSS stays at `/blog/rss.xml` on both sides. + +## Content & components + +- **`.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. +- **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`. +- **Tabs:** ``/`` → ``/``. Drop `groupId`/`queryString`/`value`; strip the `@theme/Tabs` imports. +- **Theme JSX in content:** `` (standard on category index pages) → hand-write `Card`/`CardGroup` links or delete (a Blume group page lists its children); `` → drop (report); `` JSX → a fenced code block; `` → the matching directive; `
`/`` → ``/`` or leave as raw HTML. +- **`@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). +- **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 `. +- **MDX v1 (v2 sources) pitfalls:** unescaped `<`/`{` in prose, HTML comments `` (→ `{/* */}`), string `style="…"` attributes (→ objects). Fix as build errors surface. + +## Frontmatter + +| Docusaurus | Blume | +| --- | --- | +| `title` / `description` | pass through | +| `id` | usually drop (routing is filesystem-based); use `slug` to pin a route | +| `slug` | `slug` | +| `sidebar_label` | `sidebar.label` | +| `sidebar_position` | `sidebar.order` | +| `unlisted` | `hidden: true` + `noindex: true` | +| `last_update.date` | `lastModified` | +| `draft` | `draft` | +| `tags` | `search.tags` (or drop if only used for the blog tag index) | +| `image` | `seo.image` | +| `keywords` | drop → use `seo` | +| `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) | + +Remove any duplicated H1 in the body — `title` renders the H1. + +## Icons + +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 `` or drop it. + +## Teardown + +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`. + +## Dropped — report these + +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/`, ``, per-category `className`/`customProps`, and any `@theme/*` component with no Blume equivalent. diff --git a/.agents/skills/blume-migrate/references/fumadocs.md b/.agents/skills/blume-migrate/references/fumadocs.md new file mode 100644 index 0000000..8c4ad22 --- /dev/null +++ b/.agents/skills/blume-migrate/references/fumadocs.md @@ -0,0 +1,95 @@ +# Fumadocs → Blume + +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. + +## Detect + +- Content under **`content/docs/`** (the Fumadocs convention). +- Per-folder **`meta.json`** files. +- `fumadocs-ui` / `fumadocs-core` / `fumadocs-mdx` deps and a `source.config.ts`. +- A `loader({ baseUrl: "/docs" })` call in `lib/source.ts` (or `app/source.ts`, `src/lib/source.ts`, `source.ts`). + +## Config + +Fumadocs declares almost nothing Blume needs — only two things map: + +- **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. +- **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). + +Everything else is `defineConfig({ title })`. + +**Also read `source.config.ts` `mdxOptions`** — remark/rehype plugins live there: + +- `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). +- A Twoslash transformer → Blume supports the `twoslash` fence meta natively; drop the plugin. +- Custom Shiki transformers/themes or other plugins → report. + +## Navigation: `meta.json` → `meta.ts` + +**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`): + +| Fumadocs `meta.json` | Blume `meta.ts` | +| --- | --- | +| `title` | `title` | +| `icon` | `icon` — **convert the casing** (see Icons below) | +| `defaultOpen: false` | `collapsed: true` | +| `defaultOpen: true` | `collapsed: false` | +| `root: true` | a **`navigation.tabs` entry** (see below) — **not** a `meta.ts` field | +| `pages: [...]` slugs | `pages: [...]` (ordering) | +| `description` | **drop** (folders have no description) | +| `collapsible: false` | **drop** (report — no per-folder equivalent) | + +`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`. + +**`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`. + +Handle the `pages` array items: + +- **`"..."`** (rest marker) / `""` → drop; Blume appends unlisted pages automatically. **`"z...a"`** (reversed rest) → no equivalent; list the pages explicitly in the intended order. +- **`"!page"`** (exclusion) → Blume _appends_ unlisted pages, so a dropped `!page` **resurfaces in the sidebar**. Set that page's frontmatter `sidebar.hidden: true` instead. +- **`"---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. +- **`"...folder"`** (extract) → Blume can't flatten a folder inline; keep it as a normal group at that ordering position and report it. +- **`"[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`). +- **`pagesIndex`** → make that page the folder's `index` (rename the file); report the route change and add a redirect. + +## Frontmatter + +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. + +## Icons: lucide-react names → kebab-case + +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). + +## Components + +- **Callouts:** `` → `:::` directive. Fumadocs types are `info` (the default), `warn`, `warning`, `error`, `success`, `idea`: `warn`/`warning`→`:::warning`, `error`→`:::danger`, `success`→`:::success`, `idea`→`:::tip`, `info` and **bare ``** → `:::info`. `title` → `:::type[Title]`; drop `icon`. +- **Cards:** `` → ``. `` needs **prop surgery**, not pass-through: Fumadocs `icon` is a JSX element (`icon={}` 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. +- **Accordions (container/item inversion):** `` → `` (container); `` → `` (item). +- **File trees:** `` → **``** (the JSX container for `Tree.*` children — _not_ ``, which wraps a Markdown list); `` → `` (`defaultOpen` carries over); `` → ``. Or convert the whole block to a list-driven ``. +- **Tabs:** Fumadocs declares labels on the parent (``) and selects with ``. Blume's `` carries its own `title`. Strip `items={[…]}` from `` and give each child `` a `title` (from its `value`, or the positional `items` entry). +- **Pass through unchanged:** ``/``, `` (Blume's was modeled on Fumadocs' — identical `type` record shape), ``. +- **``** (layout-mounted, not per-page) → the `banner` config field (`{ content, link, dismissible, id }`). +- **`./partial.mdx`** — Blume has no runtime include. **Inline** the partial's body (strip its frontmatter) at migration time; resolve nested includes recursively. +- **No equivalent — report:** ``, `` (Blume zooms content images by default), ``. +- **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. + +## Code fences + +- ` ```npm ` fences (Fumadocs' remark-npm accepts both) → ` ```package-install `. +- Adjacent fences sharing `tab="…"` meta render as grouped tabs in Fumadocs → wrap them in a Blume `` and drop the `tab=` attrs. + +## OpenAPI + +`fumadocs-openapi` writes **generated MDX stubs into the content tree** (`generateFiles()` output: pages containing ``/`` 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. + +## i18n + +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. + +## Package.json & teardown + +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). + +## Dropped — report these + +Folder `description` and `collapsible: false`; reversed rest (`z...a`) ordering; the extract (`...folder`) flatten semantics; deep-folder sidebar links; frontmatter `full`; ``/``; custom Shiki transformers; any icon with no Lucide equivalent after casing conversion. diff --git a/.agents/skills/blume-migrate/references/mintlify.md b/.agents/skills/blume-migrate/references/mintlify.md new file mode 100644 index 0000000..652ef81 --- /dev/null +++ b/.agents/skills/blume-migrate/references/mintlify.md @@ -0,0 +1,155 @@ +# Mintlify → Blume + +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. + +## Detect + +- `docs.json` (current) or `mint.json` (legacy) at the repo root — the config file. +- A `mintlify` dependency / `mintlify dev` script. +- 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`. + +Read `docs.json` first; it drives everything. + +## Config: `docs.json`/`mint.json` → `blume.config.ts` + +Resolve `$ref` includes first (Mintlify splits config across files). Map only what's set: + +| Mintlify | Blume | Notes | +| --- | --- | --- | +| `name` / `title` | `title` | | +| `description` | `description` | | +| `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` | +| `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 | +| `colors.primary` + `colors.light` | `theme.accent` (`{ light, dark }`) | accent is now **per-mode**: `accent: { light: , dark: }`. If Mintlify sets only `primary`, collapse to the string shorthand `accent: ""` (applies to both modes) | +| `colors.dark` | `theme.action` | | +| `appearance.default` | `theme.mode` (`light`/`dark`/`system`) | | +| `appearance.strict` | **drop** | `theme.strict` was removed — writing it is a config error | +| `background.color.{light,dark}` | `theme.background` (`{ light, dark }`) | one field now: `background: { light: …, dark: … }` (a bare string applies to both) | +| `background.image` | `theme.backgroundImage` (`{ light, dark }`) | same per-mode shape as `background` | +| `background.decoration` | **drop** | no Blume equivalent | +| `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` | +| `banner` | `banner` (`{ content, dismissible, id, link }`) | **only** those keys — drop `banner.color`/`banner.type` | +| `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) | +| `styling.codeblocks.theme` | `markdown.codeBlocks.theme` (`{ light, dark }`) | | +| `search.prompt` | **drop** | no equivalent | +| `seo.metatags` | **drop** | no equivalent; use per-page `seo` frontmatter | +| `seo.indexing: "all"` | `search.indexing.includeHiddenPages: true` | | +| `variables` (`{{name}}`) | **inline into content** | Blume has no runtime `{{var}}` substitution — replace each `{{name}}` with its value in the pages | +| `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`) | +| `integrations` (GA, Plausible, Fathom, …) | `analytics.scripts` / `analytics.vercel` | one `scripts[]` entry per provider (`{ src, strategy, attributes }`); no first-class mapping beyond PostHog/Vercel | +| `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) | +| `redirects` | `redirects: [{ from, to }]` | static only — see below | +| `navigation.languages` | `i18n` | see i18n below | + +A minimal result is often just `defineConfig({ title, logo, theme: { accent } })`. + +## Icons: FontAwesome → Lucide (required) + +Mintlify defaults to **FontAwesome**; Blume is **Lucide-only**. Convert every icon reference — frontmatter `icon`, nav-group `icon`, ``, `` — to the closest Lucide name. Discard Mintlify's `iconType` (solid/regular/brands) entirely. + +**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): + +```bash +# dry run first (report only), then apply: +node /scripts/mintlify-codemod.mjs +node /scripts/mintlify-codemod.mjs --write +``` + +The codemod touches **only frontmatter**. Icons in MDX **body** (``, ``, nav-group icons in `docs.json`) it does not see — convert those by hand using the same table. Common mappings: + +| FontAwesome | Lucide | | FontAwesome | Lucide | +| --- | --- | --- | --- | --- | +| `bolt` | `zap` | | `gear`/`cog` | `settings` | +| `circle-info`/`info` | `info` | | `wand-magic-sparkles`/`magic` | `sparkles` | +| `house` | `house` | | `magnifying-glass` | `search` | +| `gauge`/`gauge-high` | `gauge` | | `puzzle-piece` | `puzzle` | +| `envelope` | `mail` | | `file-lines` | `file-text` | +| `pen-to-square` | `square-pen` | | `trash-can` | `trash-2` | +| `xmark`/`times` | `x` | | `circle-check` | `circle-check` | +| `triangle-exclamation` | `triangle-alert` | | `circle-exclamation` | `circle-alert` | +| `arrow-right-from-bracket` | `log-out` | | `right-to-bracket` | `log-in` | +| `location-dot`/`map-marker` | `map-pin` | | `comments` | `messages-square` | +| `cube` | `box` | | `cubes`/`boxes` | `boxes` | +| `layer-group` | `layers` | | `diagram-project`/`sitemap` | `workflow`/`network` | +| `chart-line` | `chart-line` | | `chart-simple`/`chart-column` | `chart-column` | +| `flask` | `flask-conical` | | `robot` | `bot` | +| `screwdriver-wrench`/`toolbox` | `wrench` | | `circle-question`/`question` | `circle-help` | +| `life-ring` | `life-buoy` | | `shield-halved` | `shield` | +| `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)_ | + +**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. + +## Navigation: `docs.json` `navigation` → filesystem + tabs + +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: + +- **`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`.) +- **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. +- **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. +- **`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). +- **`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: + - **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. + - **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. + + 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. + +- **`dropdowns`/`products`/`versions`** → `navigation.selectors` (`{ kind, label, items: [{ label, path, icon?, description?, tag? }] }`). Use `kind` `dropdown`/`product`/`version` accordingly. +- **`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. +- **`languages`** → `i18n`, not a selector (see below). +- Only fall back to an explicit `navigation.sidebar` for a shape the filesystem genuinely can't express. + +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. + +## Content & component transforms + +Rewrite each page's MDX: + +- **Callouts → directives** (directives are **MDX-only** — Mintlify content is already `.mdx`, so this just works; never rename a page to `.md`): ``→`:::note`, ``→`:::tip`, ``→`:::warning`, ``→`:::info`, ``→`:::success`, ``/``→`:::danger`. `` maps by type (`caution`→warning, `check`→success). A `title` attr → `:::type[Title]`. Drop `icon`/color props. +- **Accordions — container/item inversion!** Mintlify nests `` inside ``. Blume inverts: ``→`` (container), and each Mintlify ``→`` (item). +- **``/``** → `` (titled-fence tabs). +- **These pass through — Blume ships them natively:** ``/``, ``, ``, ``, ``, ``/``, ``/``, ``/``. Keep them as-is. +- **API fields → `TypeTable`.** Blume does **not** ship ``/``/``. Convert a cluster of fields into one `` (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. +- **``** (a Mintlify changelog entry) has no component form → convert to a `type: changelog` page, or use the `github-releases` source. +- **Snippets are inlined, not imported.** Blume has no `/snippets` import mechanism. For each `import X from "/snippets/x.mdx"` + ``, 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}`. + +## Frontmatter + +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: + +| Mintlify | Blume | Codemod | +| --- | --- | --- | +| `title` / `description` | pass through | — | +| `sidebarTitle` | `sidebar.label` | renames | +| `icon` | remapped to a Lucide name in place (top-level `icon` is valid Blume frontmatter; keep it there) | remaps | +| `tag` | `sidebar.badge` | renames | +| `canonical` | `seo.canonical` | renames | +| `og:image` | `seo.image` | renames | +| `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) | +| `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 | +| `mode`, `public`, `rss`, `groups`, `keywords`, `hideApiMarker`, `hideFooterPagination`, `iconType` | **drop** (report) | drops | + +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.) + +## OpenAPI + +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. + +- **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/.json`) and pointing `spec` at that local path. If you keep the URL, report the dependency and consider a `prebuild` refresh-with-fallback. +- **Fix endpoint links.** Blume operation routes are `//` (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. +- **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. `/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. + +## Assets + +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. + +## i18n + +`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. + +## Dropped — report these + +- **`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.) +- **`footer.socials`** → suggest the `github` config, or a Footer override. +- **Per-language banners** (`navigation.languages[].banner`) → no equivalent. +- **Dynamic redirects** (`:slug*`/`:id` params) → can't be static path-to-path; move to host rules (`_redirects`, `vercel.json`). +- **``** changelog components, `iconType`, `background.decoration`, `search.prompt`, `seo.metatags`, non-curated fonts. diff --git a/.agents/skills/blume-migrate/references/monorepo.md b/.agents/skills/blume-migrate/references/monorepo.md new file mode 100644 index 0000000..440f1e8 --- /dev/null +++ b/.agents/skills/blume-migrate/references/monorepo.md @@ -0,0 +1,224 @@ +# Migrating into a real repo (monorepos, pnpm, Vercel) + +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. + +**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. + +--- + +## 1. Non-standard content layout → scope `content.root` + `include` + +`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. + +**Detect by finding where `.md`/`.mdx` actually live** — never assume `docs/`. From the docs package dir: + +```bash +# top two path segments of every content file, deduped — your content folders +find . -path ./node_modules -prune -o \( -name '*.md' -o -name '*.mdx' \) -print \ + | sed 's|^\./||' | cut -d/ -f1 | sort -u +``` + +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. + +**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`: + +```ts blume.config.ts +import { defineConfig } from "blume"; + +export default defineConfig({ + content: { + root: ".", // content sits directly under this package (apps/docs/), no docs/ subfolder + // include/exclude globs are relative to `content.root`. List the actual + // content folders so the scan never wanders into src/, public/, etc. + include: [ + "api/**/*.{md,mdx}", + "getting-started/**/*.{md,mdx}", + "guides/**/*.{md,mdx}", + ], + exclude: ["**/node_modules/**", "**/_*", "**/.*"], + }, +}); +``` + +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. + +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. + +--- + +## 2. pnpm `minimumReleaseAge` blocks installing fresh Blume + +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. + +**Do not disable the policy globally** — that removes the guard for the whole dependency tree. Add **only `blume`** to the exclude list. + +**Detect** in `pnpm-workspace.yaml` (pnpm 10+, the primary location) and, for older setups, `.npmrc`: + +```bash +grep -rn "minimumReleaseAge\|minimum-release-age" pnpm-workspace.yaml .npmrc 2>/dev/null +``` + +**Fix — `pnpm-workspace.yaml`** (surgical add; create the key if the policy is set but no exclude exists): + +```yaml +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - blume +``` + +**Fix — `.npmrc`** form (if the policy lives there instead): + +```ini +minimum-release-age=1440 +minimum-release-age-exclude[]=blume +``` + +Report: "Added `blume` to `minimumReleaseAgeExclude` so the just-published version installs; the release-age guard still applies to everything else." + +--- + +## 3. Frozen lockfile — regenerate and commit `pnpm-lock.yaml` in the same change + +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. + +**After every dependency edit, regenerate the lockfile and stage it with the manifest:** + +```bash +pnpm install # NOT --frozen-lockfile — this rewrites pnpm-lock.yaml +git add package.json apps/docs/package.json pnpm-lock.yaml +``` + +Rules: + +- Run `pnpm install` (plain) from the **workspace root**, not the docs package, so the single root lockfile updates. +- 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. +- 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. + +--- + +## 4. Vercel monorepo recipe (root-aware install + build) + +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/`. + +This is a **copyable template**, not prose. Three pieces: + +**a) Docs package `apps/docs/package.json`** — the standard Blume scripts: + +```json +{ + "scripts": { + "dev": "blume dev", + "build": "blume build", + "preview": "blume preview" + }, + "dependencies": { + "blume": "^1" + } +} +``` + +**b) `apps/docs/vercel.json`** — root-aware install, local build, `dist` output: + +```json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "cd ../.. && pnpm install --frozen-lockfile", + "buildCommand": "pnpm run build", + "outputDirectory": "dist" +} +``` + +**c) The one setting `vercel.json` can't hold — set it in the Vercel project (dashboard → Settings → General, or `vercel` CLI):** + +- **Root Directory** = `apps/docs`. This makes install/build/output paths resolve from the docs package, which is what the `vercel.json` above assumes. +- **Node version** = 22 or newer (Blume requires it). Or pin it in the docs package: `"engines": { "node": ">=22" }`. + +How the pieces fit, with Root Directory = `apps/docs`: + +- `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. +- `buildCommand` runs in `apps/docs/` and invokes `blume build` (via the package's `build` script) → emits `apps/docs/dist/`. +- `outputDirectory: "dist"` is relative to the Root Directory (`apps/docs`), so it points at `apps/docs/dist/`. +- `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. + +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`. + +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. + +**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.) + +--- + +## 5. Astro/Vite version mismatch → pnpm patch (manual) + +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. + +**Detect the risk factor** before it bites: + +```bash +grep -rn "\"vite\"\|vite@\| vite:" pnpm-workspace.yaml package.json 2>/dev/null | grep -i "override\|resolution\|catalog\|vite" +``` + +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): + +```bash +# 1. Open the offending version in an editable temp dir (use the exact version +# pnpm resolved — check `pnpm why astro`): +pnpm patch astro@7.0.6 + +# 2. Edit files in the printed temp path to reconcile the Astro/Vite interface +# (the exact edit depends on the mismatch; the crash's stack trace names the +# file and call site). Then commit the patch: +pnpm patch-commit '' +``` + +`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): + +```yaml +# pnpm-workspace.yaml +patchedDependencies: + astro@7.0.6: patches/astro@7.0.6.patch +``` + +Commit **both** `patches/astro@7.0.6.patch` and the `patchedDependencies` entry, then re-run `pnpm install` and `blume build`. + +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. + +--- + +## 6. Ultracite / oxfmt formatting → oxfmt patch (ship the bundled patch) + +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`. + +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: + +```bash +# 1. Copy the shipped patch into the target repo's patches/ dir (keep the exact filename): +mkdir -p patches +cp "/assets/oxfmt@0.55.0.patch" patches/oxfmt@0.55.0.patch +``` + +Then register it under `patchedDependencies` — `pnpm-workspace.yaml` on pnpm 10+, or root `package.json` (`pnpm.patchedDependencies`) on pnpm 9: + +```yaml +# pnpm-workspace.yaml +patchedDependencies: + oxfmt@0.55.0: patches/oxfmt@0.55.0.patch +``` + +Commit **both** the patch file and the `patchedDependencies` entry, then re-run `pnpm install`. + +- **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. +- **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. + +--- + +## Checklist for a workspace migration + +- [ ] Located real content folders (not assumed `docs/`); set `content.root` + scoped `content.include`. +- [ ] `minimumReleaseAge` present? Added `blume` to `minimumReleaseAgeExclude` only. +- [ ] Ran plain `pnpm install`; committed `pnpm-lock.yaml` with the `package.json` change; verified `pnpm install --frozen-lockfile` is clean. +- [ ] Wrote `apps/docs/vercel.json` + package scripts; told the user to set Root Directory + Node 22 in the Vercel project. +- [ ] Checked for a workspace Vite override; if the build crashes inside Astro/Vite, gave the pnpm-patch recipe. +- [ ] Uses Ultracite/oxfmt? Shipped `patches/oxfmt@0.55.0.patch` + registered it under `patchedDependencies` so formatting doesn't mangle `:::` directives. +- [ ] Reported every repo-specific edit and every manual step left to the user. diff --git a/.agents/skills/blume-migrate/references/nextra.md b/.agents/skills/blume-migrate/references/nextra.md new file mode 100644 index 0000000..089a95c --- /dev/null +++ b/.agents/skills/blume-migrate/references/nextra.md @@ -0,0 +1,76 @@ +# Nextra → Blume + +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. + +## Detect & fingerprint the generation + +- **`_meta.{js,mjs,cjs,ts,jsx,tsx,json}`** files — the strongest fingerprint (v4 also has a single-file `_meta.global.*` variant). +- **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). +- **v4 (App Router):** **no `theme.config`** — config lives as component props in `app/layout.{jsx,tsx}` (``, ``, ``, `