Skip to content
helia-ui
Sections
HELIA HUB

Migrating from MkDocs

Every MkDocs Material feature in use across the HELIA product docs has a counterpart here. This page is the mapping, one section per feature, so a conversion is mechanical rather than a judgment call each time.

Start from templates/product-docs in this package: it is a working Starlight site with the plugin, the Tailwind entry, the product accent and a Pages workflow already wired. Copy it in as the product repository’s docs/, then move content into it.

Most of this page is mechanical, so the package ships it as a command. It reads a MkDocs docs_dir and writes a Starlight content collection:

Terminal window
npx helia-ui-mkdocs-convert --docs docs --out src/content/docs \
--base /PRODUCT-REPO-NAME --public public \
--sidebar src/generated/docs-sidebar.json
Options
OptionWhat it does
`--docs <dir>`MkDocs `docs_dir` to read. Required.
`--out <dir>`Content collection directory to write. Required.
`--base <path>`The site's base path, so a root-relative link keeps working under it.
`--config <file>``mkdocs.yml`. Defaults to the one beside `--docs`.
`--icons <file>`JSON map of extra `:material-*:` names, merged over the built-in table.
`--public <dir>`Where linked images and `docs/assets/**` are copied.
`--sidebar <file>`Writes the MkDocs nav as a Starlight sidebar fragment.
`--skip <path>`A page to leave alone; repeatable. Defaults to `index.md`.
`--keep <name>`An entry in `--out` not to delete; repeatable. Defaults to `index.mdx` and `api`.
`--dry-run`Convert and report, write nothing.
`--report`Print per-rewrite counts, unmapped icons and the hand-pass list.

It rewrites what the rest of this page maps: admonitions and collapsible admonitions to asides, content tabs to Tabs and TabItem, :material-*: shortcodes to Icon, Termynal blocks to AsciiTerminal, relative links to route form, HTML comments to MDX expression comments, and the MDX escaping that Markdown did not need. Front matter comes from the page’s H1 and lead paragraph. Attribute lists and markdown="1" attributes are dropped, void tags are closed, and fenced code is left alone.

The MkDocs tree stays the source of truth until the deploy switch, so the command is idempotent: it rewrites --out from scratch on every run, keeping only what --keep names. Anything that has had a hand pass lives in files the converter owns, so re-run it against a clean tree and re-apply the hand pass from the diff.

What it will not do by hand-waving, it prints:

  • Raw HTML blocks and Chart.js canvases, kept verbatim and listed per page. See Raw HTML and Charts.
  • {{ read_csv(...) }} macros, left as a marker for a DataTable island.
  • Unmapped icon names, emitted as a visible [unmapped icon: name] marker rather than guessed at, and counted in the report.
  • Pages with no H1, whose title is derived from the filename.
  • Nav entries with no page behind them, which would be a 404 after the switch.

A --8<-- snippet has no equivalent, so the run fails on one rather than dropping the content it pulled in.

!!! note "Title" becomes Starlight’s Aside. Material has twelve admonition types and Aside has four, so several Material types land on the same tone. Where the distinction matters, the package’s Callout has nine and takes a free-text title.

This table is the whole Material set, so a converter has one mapping to read and never has to decide.

Material admonition types and what they become
MaterialAside typePackage Callout tone
notenotenote
abstract, summary, tldrnotenote
infonotenote
tip, hinttiptip
importanttipimportant
success, check, donetipsuccess
question, help, faqnotenote
warning, caution, attentioncautionwarning
failure, fail, missingcautionwarning
danger, errordangercritical
bugdangercritical
examplenotenote
quote, citenone — use a blockquotenone

Aside is the default because it is the built-in: it is styled by the theme, it is translated, and it needs no import in Markdown. Reach for Callout when the page needs success, compatibility, experimental or deprecated, which have no Aside equivalent, or when the title is not one of the four fixed words.

Starlight 0.41 does not take a custom Aside type. Aside.astro validates the prop against a fixed ['note', 'tip', 'caution', 'danger'] and throws an AstroError on anything else, and the remark directive that turns :::note into an Aside drops a name it does not recognize, so :::success renders as nothing at all. A success skin in starlight.css would therefore have no element to attach to.

So success, check and done convert to Aside type="tip", which is the closest built-in and already reads as a positive tone. Where a page genuinely needs to distinguish a completed step from a suggestion, use the package’s Callout instead, which gained a success tone in 0.1.0-alpha.3:

<Callout title="Calibration complete" tone="success">
The sensor is ready to record.
</Callout>
<!-- MkDocs -->
!!! warning "Check the input rate"
The converter assumes 16 kHz.
{/* Starlight */}
:::caution[Check the input rate]
The converter assumes 16 kHz.
:::

The directive form needs no import. The component form (<Aside type="caution" title="...">) is equivalent and is what MDX pages already importing components tend to use.

=== "Label" becomes Tabs and TabItem. Material’s linked tabs — the ones that switch together across a page — are syncKey on Tabs.

<!-- MkDocs -->
=== "pip"
`sh
pip install product-name
`
=== "uv"
`sh
uv add product-name
`
{/* Starlight */}
<Tabs syncKey="installer">
<TabItem label="pip">```sh pip install product-name ```</TabItem>
<TabItem label="uv">```sh uv add product-name ```</TabItem>
</Tabs>

Blank lines matter: leave one after the opening <TabItem> and one before the closing tag, or the fenced block is parsed as JSX children rather than Markdown.

The package’s CodeTabs is a different thing and not the migration target. It is driven by a data array rather than by markup, and it has a select variant for long tab lists. Use it when the tab set comes from data; use Tabs when it is written in the page.

Material’s :material-gauge: shortcodes have no direct equivalent: Starlight’s own Icon carries a small set chosen for its UI, not a general icon library. This package therefore ships Icon, a build-time wrapper over Font Awesome’s free solid set — the same dependency Callout and AccordionGroup already use, so it adds nothing to install and nothing to the client.

import Icon from '@ambiqai/helia-ui/astro/Icon';
<Icon name="gauge-high" /> Latency
<Icon name="triangle-exclamation" label="Warning" />

gauge-high   microchip   chart-line   triangle-exclamation

Names are Font Awesome’s, in kebab case, and an unknown name fails the build rather than rendering nothing. The Material and Font Awesome vocabularies overlap heavily but not exactly, so the icons in use need a pass by hand; :material-memory: is memory, :material-speedometer: is gauge-high, :material-alert: is triangle-exclamation.

Omit label for an icon that sits beside text saying the same thing, which is most of them. Supply it when the icon is alone in a table cell or a heading.

Material rendered math with KaTeX, and so does this. Expressions move across unchanged. remark-math parses $…$ and $$…$$, rehype-katex renders it, and katex/dist/katex.min.css goes in customCss. All three go on Astro’s markdown config, not Starlight’s:

markdown: {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
},

Starlight’s own markdown option covers heading links and extra processed directories only; it takes no plugins. The template has this wired already.

Both are supported with no configuration. [^1] references, [^1]: definitions, and - [ ] / - [x] items all render as written.

--8<-- "file.md" becomes an import. There is no snippet plugin; MDX already does this, and the result is typed and traceable rather than a string substitution.

For shared prose, put the fragment in src/components/ as an .astro or .mdx file and import it. For shared code, import the real source file as raw text so the sample cannot drift from what it documents:

import example from '../../../examples/measure.py?raw';
<CodeBlock filename="measure.py" language="python" code={example} />

?raw is Vite’s, so it works anywhere in the site. It is the better form for code: the file stays runnable and testable.

Almost every table is plain Markdown and stays plain Markdown. Two cases are not.

A table that needs a caption, a scroll frame on narrow screens, or paging is the DataTable part. It is static markup with an optional pager and costs no JavaScript until pageSize is set:

import DataTable from '@ambiqai/helia-ui/astro/DataTable';
<DataTable
caption="Fields in result.json"
columns={['Field', 'Unit', 'Meaning']}
rows={[['mean', 'ms', 'Arithmetic mean across runs']]}
pageSize={10}
/>

A table that needs sorting, filtering or column controls — what simple-datatables does — is the React data table, hydrated as an island:

src/islands/ResultsTable.tsx
import { DataTable, type ColumnDef } from '@ambiqai/helia-ui/react/data-table';
<ResultsTable client:visible />

Prefer the Astro part. A sortable table is worth an island when a reader is expected to search it, and not otherwise.

Plotly and Chart.js become Recharts islands. The package ships the shadcn chart wrapper over Recharts as @ambiqai/helia-ui/react/chart, and the existing data moves over unchanged: what changes is the renderer, not the numbers.

There is no chart component to import beyond that wrapper, because a chart is too specific to a page to be a part. The conversion is a recipe instead.

Material sites drive charts from data attributes on an empty element, read at load by a chart-init.js that hands them to Plotly or Chart.js:

<!-- MkDocs -->
<div
class="chart"
data-chart-type="line"
data-chart-x="0,1,2,3,4,5"
data-chart-series='[{"name":"heliaRT","y":"5.4,5.2,5.6,5.1,5.3,5.2"},
{"name":"heliaAOT","y":"4.9,4.8,4.7,4.9,4.6,4.8"}]'
data-chart-colors="#1769e0,#00a7b5"
></div>

Everything there is a string the build cannot check: a typo in a series name or a missing value only shows up as an empty chart in a browser.

Where each chart-init.js attribute goes
MkDocs attributeBecomes
data-chart-typeThe Recharts element: LineChart, BarChart, AreaChart
data-chart-xThe shared key on each row, named by dataKey on XAxis
data-chart-seriesOne key per series on each row of the data array
series nameThe label on that series’ ChartConfig entry
data-chart-colorsA --chart-N token on that entry’s color
chart-init.jsNothing. The island is the initializer.

Three things are worth keeping straight. The parallel arrays become one array of rows, because Recharts takes a row per x value rather than a series per line. The per-trace hex colors become --chart-1 through --chart-5 from the token set, so a chart follows the theme instead of carrying a palette of its own. And chart-init.js itself is deleted rather than ported.

src/islands/LatencyOverWindows.tsx
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@ambiqai/helia-ui/react/chart';
const windows = [
{ window: 0, rt: 5.4, aot: 4.9 },
{ window: 1, rt: 5.2, aot: 4.8 },
// one row per x value, one key per series
];
const config = {
rt: { label: 'heliaRT', color: 'var(--chart-1)' },
aot: { label: 'heliaAOT', color: 'var(--chart-2)' },
} satisfies ChartConfig;
export default function LatencyOverWindows() {
return (
<ChartContainer config={config}>
<LineChart data={windows}>
<CartesianGrid vertical={false} />
<XAxis dataKey="window" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} unit="ms" />
<ChartTooltip content={<ChartTooltipContent />} />
<Line dataKey="rt" stroke="var(--color-rt)" dot={false} />
<Line dataKey="aot" stroke="var(--color-aot)" dot={false} />
</LineChart>
</ChartContainer>
);
}

ChartContainer turns each config key into a --color-<key> custom property, which is why the series reads stroke="var(--color-rt)" rather than repeating the token.

Drop it on the page with client:visible, so a chart below the fold costs nothing until it is scrolled to:

import LatencyOverWindows from '../../islands/LatencyOverWindows';
<LatencyOverWindows client:visible />

This exact example is rendered on Data display, where the site’s own React islands live. This page stays static: only the React-components section hydrates, which is what the docs build asserts.

Termynal becomes AsciiTerminal. A static transcript is the default and ships no JavaScript; animated types the command lines and reveals output by line, and honors prefers-reduced-motion by showing everything at once. See Code for the forms.

This is the part that does not map mechanically. MDX is stricter than Markdown: attributes are JSX, so class is className in components, style takes an object, and every tag must close. Most of the raw HTML in these pages exists because Material had no component for something — a two-column layout, a card, a badge — and those have parts here. Read them page by page and replace rather than convert.

mkdocs.yml’s nav becomes Starlight’s sidebar. A directory of pages is a group whose items hold an autogenerate object — { label: 'How-to', items: [{ autogenerate: { directory: 'how-to' } }] } — which orders by the sidebar.order frontmatter key; an explicit list is an items array of entries. Routes that change need a redirect, since the product sites are already linked to from the hub.

--sidebar writes that array for you, as a JSON fragment the Astro config spreads into sidebar. The template’s route test reads the same fragment and asserts every entry serves 200, so a nav entry that gained no page fails as a test rather than as a reader’s 404.