Use when migrating ClickHouse docs pages from Docusaurus (clickhouse-docs) to Mintlify format in this repo, or when wiring up the migration script. Covers frontmatter rewrites, body transforms, component mapping, link normalization, and redirect generation. Slugs are canonical and must never be rewritten.
npx skills add https://github.com/ClickHouse/mintlify-docs-dev --skill migrate-docusaurus-to-mintlify
This skill describes the deterministic rules for converting a Docusaurus .md/.mdx page (source: ~/Desktop/clickhouse-docs/docs/**) into a Mintlify page in this repo. The reference implementation lives at ~/Desktop/clickhouse-main (a Mintlify-mapped snapshot Mintlify produced) — when in doubt, diff against it.
The migration is driven by a script at _migration/migrate.py (see the "Script" section). When the script can't decide, it leaves the original content with a <!-- MIGRATE: ... --> marker; resolve those by hand using the rules below.
~/Desktop/clickhouse-docs/<docusaurus_file> (looked up via slug-map.csv's docusaurus_file column). The in-repo Mintlify file's path tells you *where* the migrated output goes (docs have a new navigation layout in Mintlify) — its current contents may be mangled by the initial mapping pass and must be overwritten with a fresh transformation of the upstream source. The aim is complete preservation; the only changes are the Mintlify-necessary transforms in this skill (frontmatter, components, links, extension).slug:. Slugs are canonical Docusaurus URLs. If a Docusaurus page's slug differs from where Mintlify placed the file, that's a redirect, not a rename. (See feedback_never_rename_slugs.)slug: at build time — URLs come from file paths. Keep slug: in frontmatter as legacy documentation; never write code that resolves Mintlify pages via that field.root:, not href:, for landing-page links.| Docusaurus | Mintlify | Notes |
| --- | --- | --- |
| sidebar_label: | sidebarTitle: | rename, preserve value |
| slug: | slug: | keep |
| title: / description: / doc_type: / keywords: | same | keep |
| sidebar_position: | — | drop |
| sidebar_class_name: | — | drop |
| pagination_* | — | drop |
| hide_table_of_contents: | — | drop |
| tags: | — | drop unless intentionally used |
Preserve quoting style. Don't touch keys you don't recognize — flag them.
.md → .mdx if the body contains JSX (any import line, any <Component>, or any admonition/details that will be rewritten into JSX)..md stays .md.# Title line if it duplicates title: in frontmatter (it almost always does).## Foo {#foo} are preserved verbatim.Rewrite, don't blindly delete:
| Docusaurus import | Mintlify equivalent |
| --- | --- |
| from '@site/docs/<path>/_snippets/_x.md' | from '/snippets/<actual-location-of-x.mdx>' (snippets are flattened in this repo — look up the .mdx file by basename in the snippets/ tree; do not assume the same nested path) |
| from '@site/static/images/...' (image var) | delete the import; inline the path string into the component (see Image rule) |
| from '@theme/IdealImage' | from '/snippets/components/Image.jsx' (named: import { Image } from ...) |
| from '@theme/Tabs', from '@theme/TabItem' | delete (Tabs/Tab are first-class) |
| from '@theme/<Component>' | from '/snippets/components/<Component>.jsx' if that file exists in snippets/components/, else flag |
| from '@docusaurus/...' | delete + flag (e.g. useBrokenLinks has no Mintlify analogue) |
| from '@site/src/components/<X>' | map to /snippets/components/<X>.jsx if present, else flag |
Always check snippets/components/ before assuming a component is missing.
:::note → <Note>...</Note>
:::tip → <Tip>...</Tip>
:::info → <Info>...</Info>
:::warning → <Warning>...</Warning>
:::caution → <Warning>...</Warning>
:::danger → <Warning>...</Warning> (or <Danger> if the design system has it)
Titled admonitions appear in two forms — :::tip Title (space) and :::tip[Title] (square brackets). Treat both the same way: extract the title, wrap in the chosen tag, inject Title followed by a blank line as the first body line:
:::note Conclusion → <Info>
Body text. **Conclusion**
:::
Body text.
</Info>
<details>
<summary>X</summary>
body
</details>
→
<Accordion title="X">
body
</Accordion>
The summary may span multiple lines or include indentation — strip it to a single string for the title= attribute.
<Tabs groupId="..."> → <Tabs> (drop groupId)<TabItem value="x" label="Y"> → <Tab title="Y"> (drop value, rename label→title)</TabItem> → </Tab>Docusaurus marks runnable SQL with a second info-string token. There are two variants — they migrate differently:
sql runnable (token = runnable) → wrap in <RunnableCode>` (this is an actually-runnable example). sql runnable=false (token = runnable=false) → drop the token, leave a plain sql fence. This is a non-runnable example explicitly opting out.For the runnable case (component at snippets/components/RunnableCode/RunnableCode.jsx):
<RunnableCode>
SELECT 1
</RunnableCode>
Add the import once near the top of the file (matching the <Image> import convention):
import { RunnableCode } from "/snippets/components/RunnableCode/RunnableCode.jsx";
Preserve any other props/tokens on the original fence.
<Image img={someVar} ... /> → <Image img="/static/images/<original-path>.<ext>" ... /><original-path> is whatever the deleted import someVar from '@site/static/images/<...>' pointed at.!alt markdown images: leave alone unless they reference @site/..., in which case rewrite to a root-relative /static/images/... path.Mintlify's URL for any page is its file path relative to docs.json, with the extension stripped (and a trailing /index collapsed to the parent). That single fact drives every link rewrite. There is no /docs/ prefix to add and there are no slugs to preserve in URLs — the file's location IS its URL.
Build two lookups once before rewriting, both walking this repo:
slug_to_url: from each page's frontmatter slug: (the legacy Docusaurus slug) to its Mintlify URL = "/" + relpath_without_extension (with /index collapsed).path_to_url: from each page's filesystem path to the same URL.Then rewrite every link found in markdown (x), HTML (<a href="...">), and JSX URL-ish props:
Input looks like x, x, x.
slug: find the row in slug-map.csv, take its docusaurus_file, and resolve href against that path..md / .mdx (or treat dir/index.md and dir/ as equivalent).slug-map.csv to get the page's slug, then map slug → Mintlify URL.x. Preserve the fragment.If the resolved file isn't a known page, leave the link with a <!-- MIGRATE: unresolved relative link --> marker.
Input looks like x or x.
.md/.mdx and /index.md//index.mdx (Docusaurus accepts these forms in absolute links)./ — Docusaurus permits both, and frontmatter slugs occasionally include the trailing slash (e.g. /sql-reference/data-types/).x.If no slug matches either form, leave the original with a <!-- MIGRATE: unknown slug --> marker.
Input https://clickhouse.com/docs/<path> (with or without fragment).
https://clickhouse.com/docs to get a slug-shaped path, then run Case B.Leave alone.
Do not strip .md and add /docs/ as a generic step — that was the wrong mental model. The lookups handle every case.
_snippets/)Snippet partials live at docs//_snippets/*.md in Docusaurus and migrate to snippets//_snippets/*.mdx in Mintlify. They use the same body-transform rules as pages. They have no frontmatter / slug.
redirects.json (already exists in clickhouse-main).{"source": "/docs/<old>", "destination": "/docs/<new>"}.redirects array in docs.json; the source-of-truth here is redirects.json.<!-- MIGRATE: pick-destination --> marker)._migration/generate-slug-map.py writes _migration/slug-map.csv. It pairs every Docusaurus slug with its Mintlify URL so a reviewer can open both pages side-by-side.
How it builds rows:
--docusaurus, default ~/Desktop/clickhouse-docs) and collect every slug:.slug:.<mintlify-base> + that file's path without extension (with /index collapsed).Columns: docusaurus_slug, docusaurus_file, mintlify_file, old_url, new_url, status, migrated, manually_checked.
Statuses:
matched — exactly one Mintlify file carries that slug.ambiguous — multiple Mintlify files share the slug (a real bug to fix).unmatched — no Mintlify file has that slug (page deleted, or slug typo).Tracking columns:
source_hash — recomputed every time generate-slug-map.py runs. SHA-256 of the Docusaurus source file (first 16 hex chars). Reflects the current source content.migrated (default false) — true once the page has been transformed end-to-end per this skill. The migration script writes this; humans flip it for manual migrations.migrated_hash — the source_hash value at the moment the page was last migrated. The migration script writes this on every successful run.migrated_at — UTC ISO timestamp of the last migration. Diagnostic only.manually_checked (default false) — flip to true once a human has opened old_url and new_url side-by-side and confirmed parity. Never written by tools.Staleness rule: a page is up-to-date iff migrated == true AND migrated_hash == source_hash. Any drift means the Docusaurus source has changed since the last migration → the page should be re-migrated. _migration/migrate.py enforces this by default; pass --force to override.
The generator preserves all tracking columns (migrated, migrated_hash, migrated_at, manually_checked) when re-run, so it's safe to regenerate at any time without losing progress.
Regenerate any time pages move or slugs change:
python _migration/generate-slug-map.py
python _migration/generate-slug-map.py --docusaurus ~/Desktop/clickhouse-docs \
--mintlify-base https://private-7c7dfe99.mintlify.app
The same slug-keyed lookup that powers this CSV is what the migration script uses to rewrite Case-B links (see section 6).
Do not change these inside the migration pass:
docs.json navigation registration — pages must still be added to nav by hand or by a separate tool.clickhouse-main/static/images/.openapi/, styles/, logo/, links/.The migration script is _migration/migrate.py. Invocation:
python _migration/migrate.py <path> # one file or dir
python _migration/migrate.py --all # whole repo
python _migration/migrate.py <path> --dry-run
python _migration/migrate.py --all --force # re-migrate even up-to-date pages
Standard workflow (incremental):
python _migration/generate-slug-map.py # refresh source_hash for all pages
python _migration/migrate.py --all # process only pages whose source changed
generate-slug-map.py recomputes every page's source_hash; migrate.py skips any page where migrated=true AND migrated_hash == source_hash. After a Docusaurus repo pull, run both in sequence and only the changed pages are re-touched.
It builds the slug map by:
**/*.{md,mdx} and recording every frontmatter slug: → file path.~/Desktop/clickhouse-docs/docs/**/*.{md,mdx} and recording every Docusaurus slug:.redirects.json entries.Unresolvable cases (missing snippet target, unknown @theme/X import, ambiguous redirect destination) get <!-- MIGRATE: <reason> --> in the output and are listed in the script's exit summary. Resolve them by hand using the rules above.
clickhouse-docs.clickhouse-main, diff against it — that's the ground truth for ambiguous cases.:::, <details>, @site/, @theme/, and .md) / .md# to catch missed transforms.slug-map.csv, find the row whose mintlify_file matches the file you just migrated and flip migrated from false to true. The migration script does this automatically; if you migrate by hand, do it yourself.Take clickhouse/migrate-docusaurus-to-mintlify from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.