Markdown
Markdown pages let you use .md files as first-class pages in Void's file-based routing. They compile to static HTML at build time with zero client JavaScript, integrate with the layout chain, and can embed interactive framework components as islands.
The @void/md package is opt-in as it contains markdown processing and syntax highlighting dependencies that are not necessarily needed in every app.
Setup
Install @void/md alongside your framework adapter:
npm install @void/md @void/reactnpm install @void/md @void/vuenpm install @void/md @void/sveltenpm install @void/md @void/solidAdd the plugin to your Vite config after the framework adapter:
// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidReact } from '@void/react/plugin';
import { voidMarkdown } from '@void/md/plugin';
export default defineConfig({
plugins: [voidPlugin(), voidReact(), voidMarkdown()],
});// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidVue } from '@void/vue/plugin';
import { voidMarkdown } from '@void/md/plugin';
export default defineConfig({
plugins: [voidPlugin(), voidVue(), voidMarkdown()],
});// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidSvelte } from '@void/svelte/plugin';
import { voidMarkdown } from '@void/md/plugin';
export default defineConfig({
plugins: [voidPlugin(), voidSvelte(), voidMarkdown()],
});// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidSolid } from '@void/solid/plugin';
import { voidMarkdown } from '@void/md/plugin';
export default defineConfig({
plugins: [voidPlugin(), voidSolid(), voidMarkdown()],
});That's it. Any .md file in pages/ is now a routable page.
Page Anatomy
A markdown page has three optional parts: a script block, frontmatter, and the body.
<script>
import Counter from "../components/Counter.vue" with { island: "visible" }
</script>
---
title: Getting Started
description: Learn how to use Void
---
# Getting Started
Welcome to Void. Here's an interactive demo:
<Counter /><script>block: extracted before markdown compilation. Island imports usewith { island: "..." }syntax (see islands). Any other code in the block becomes a client script that runs in the browser.- Frontmatter: YAML metadata between
---fences. Layouts can read it throughuseFrontmatter(). - Body: standard markdown plus GFM. Uppercase tags such as
<Counter />reference imported components and render as islands.
Client Scripts
The <script> block can contain both island imports and regular JavaScript. Island imports (with { island }) go through the SSR + hydration pipeline as usual. Everything else is bundled as a client module and executed when the page loads.
<script>
import Counter from "./Counter.vue" with { island: "visible" }
import { format } from "date-fns"
document.querySelector('.date').textContent = format(new Date(), 'PPP')
</script>
# My Post
<Counter />
Published: <span class="date"></span>In this example:
Counteris an island, so it is SSR'd and hydrated on the clientdate-fnsand thequerySelectorcall are client code, so they are bundled and run on page load
Each page's client script is code-split into its own chunk via dynamic import, so only the current page's script is loaded. Pages without client code ship zero extra JS.
Client scripts work in both dev (served on-demand, HMR support) and production builds.
File Structure
Markdown pages live in pages/ alongside regular pages and route the same way:
- pages/
- layout.island.tsx
- index.island.tsx
- docs/
- layout.island.tsxdocs layout (sidebar, TOC)
- getting-started.md
- configuration.md
- guides/
- deployment.md
A .md file inherits layouts, supports companion .server.ts files for dynamic data, and auto-prerenders when static. The rules are the same as any other page.
Frontmatter Access
Use useFrontmatter() in layout components to read the current page's frontmatter:
import { useFrontmatter } from '@void/md';
export default function DocsLayout({ children }) {
const fm = useFrontmatter();
return (
<div>
<h1>{fm.title}</h1>
{children}
</div>
);
}<script setup>
import { useFrontmatter } from '@void/md';
const fm = useFrontmatter();
// fm.title, fm.description, etc.
</script>
<template>
<h1>{{ fm.title }}</h1>
<slot />
</template><script>
import { useFrontmatter } from "@void/md";
const fm = useFrontmatter();
</script>
<h1>{fm.title}</h1>
<slot />import { useFrontmatter } from '@void/md';
export default function DocsLayout(props) {
const fm = useFrontmatter();
return (
<div>
<h1>{fm.title}</h1>
{props.children}
</div>
);
}Pages Virtual Module
Import @void/md/pages to get metadata for all markdown pages at build time. Use it to build sidebars, navigation, or search indexes:
import pages from '@void/md/pages';
// [{ path: "/docs/getting-started", title: "Getting Started", frontmatter: {...}, headings: [...] }, ...]Each entry has this shape:
interface MdPage {
path: string; // route path
title: string; // from frontmatter.title or first h1
frontmatter: Record<string, unknown>; // full parsed frontmatter
headings: { depth: number; slug: string; text: string }[]; // extracted headings
}The array is sorted by path and updates on HMR in dev when .md files are added, removed, or changed.
Default CSS Theme
The markdown plugin provides a minimal default theme. Unlike Vitepress, this theme is CSS only - it is designed to be built on top of. The theme ships two entry points depending on your needs:
Full theme (reset + baseline + content)
For standalone markdown sites that need a complete stylesheet, use this package. It includes a modern CSS reset, baseline body styles, and all markdown content styles:
@import '@void/md/theme.css';Content only (scoped to .void-md)
For embedding markdown in an existing app that already has its own reset and global styles, use this one. It only includes the .void-md-scoped content styles:
@import '@void/md/theme-content.css';Usage
Wrap your markdown content in a .void-md element to scope the styles:
<main class="void-md">
<slot />
</main>The theme covers:
- Prose: headings, paragraphs, lists, blockquotes, tables, inline code, links, horizontal rules, task lists,
<kbd>,<mark>, definition lists, and footnotes. Dark mode works throughprefers-color-schemeand thedata-themeattribute. - Code blocks: Shiki dual-theme highlighting with CSS-driven switching and zero JS.
- Containers: styles for
:::tip,:::warning,:::danger,:::info, and:::details. - GitHub alerts: support for
> [!NOTE],> [!TIP],> [!WARNING], and similar syntax.
CSS Variables
All styles are customizable via CSS variables set on .void-md. Override them to match your brand:
.void-md {
--vmd-link: #8b5cf6;
--vmd-link-hover: #7c3aed;
}| Variable | Light default | Dark default | Description |
|---|---|---|---|
--vmd-font-body | system-ui, -apple-system, "Segoe UI", Roboto, sans-serif | default | Body font stack |
--vmd-font-mono | ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace | default | Monospace font stack |
--vmd-text | #1a1a2e | #e2e8f0 | Primary text color |
--vmd-text-muted | #64748b | #94a3b8 | Secondary/muted text (blockquotes, line numbers, footnotes) |
--vmd-link | #2563eb | #60a5fa | Link color |
--vmd-link-hover | #1d4ed8 | #93bbfd | Link hover color |
--vmd-border | #e2e8f0 | #334155 | Borders (h2 underline, tables, inline code, <kbd>, horizontal rules) |
--vmd-bg-soft | #f8fafc | #1e293b | Soft background (table headers, inline code, code blocks, <kbd>) |
--vmd-line-height | 1.75 | default | Body line height |
Dark mode values apply automatically via prefers-color-scheme: dark (auto mode) and [data-theme="dark"] (explicit toggle). To force light mode on a dark-preference system, set data-theme="light" on <html>.
Users who want full control can skip the import and write their own CSS.
Markdown Features
All features produce static HTML at build time with zero client JS (except the copy button).
Containers
::: tip
Helpful advice here.
:::
::: warning
Watch out for this.
:::
::: danger
This will break things.
:::
::: info
Some context.
:::
::: details Click to expand
Hidden content here.
:::Custom titles work too: ::: tip Pro Tip.
GitHub Alerts
> [!NOTE]
> Useful information.
> [!TIP]
> Helpful advice.
> [!IMPORTANT]
> Key information.
> [!WARNING]
> Potential issues.
> [!CAUTION]
> Dangerous actions.Syntax Highlighting
Code blocks are highlighted at build time with Shiki using dual light and dark themes by default: github-light and github-dark. No client-side JavaScript is needed because theme switching is pure CSS.
Line Highlighting
Highlight specific lines with {lines} in the code fence meta:
```ts {1,3-5}
const a = 1; // highlighted
const b = 2;
const c = 3; // highlighted
const d = 4; // highlighted
const e = 5; // highlighted
```Diff, Focus, and Error Levels
Use inline comments to annotate lines:
export function hello() {
console.log('old');
console.log('new');
console.log('look here');
console.log('problem');
console.log('careful');
}Copy Button
Code blocks include a copy button. That is the only feature here that needs client-side JS, and it only adds a roughly 200 byte inline script for the clipboard API. The script is injected only when code blocks are present.
Line Numbers
Enable per code block with :line-numbers or disable with :no-line-numbers:
```ts :line-numbers
const a = 1;
const b = 2;
```Start from a specific number with :line-numbers=5.
Snippet Imports
Import code from external files:
<<< ./path/to/file.tsEmoji
Shortcodes convert to unicode: :tada: becomes 🎉, :rocket: becomes 🚀.
Attributes
Add classes, IDs, or attributes to any element:
# Heading {.custom-class #my-id}
Paragraph with attributes. {.note}Heading Anchors
All headings get permalink anchors automatically, enabling direct linking to any section.
Table of Contents
Use the [[toc]] directive to render an inline table of contents from the page's headings.
Images
Image paths are normalized automatically. Images are lazy-loaded by default.
Links
External links automatically get target="_blank" rel="noreferrer". Internal .md references resolve to their route paths.
Plugin Options
voidMarkdown({
shiki: {
themes: { light: 'github-light', dark: 'github-dark' }, // Shiki themes
langs: ['sql', 'graphql'], // additional languages
},
});| Option | Type | Default | Description |
|---|---|---|---|
shiki.themes | { light: string; dark: string} | { light: "github-light", dark: "github-dark" } | Shiki color themes |
shiki.langs | string[] | Common web languages | Additional languages to load |
Example: Docs Layout with Sidebar
Here's a full example of a docs layout using useFrontmatter() and @void/md/pages to build a sidebar:
// pages/docs/layout.island.tsx
import '@void/md/theme-content.css';
import { useFrontmatter } from '@void/md';
import pages from '@void/md/pages';
import { useRouter } from '@void/react';
const docPages = pages.filter((p) => p.path.startsWith('/docs/'));
export default function DocsLayout({ children }) {
const fm = useFrontmatter();
const { path } = useRouter();
return (
<div className="docs-layout">
<aside>
<nav>
{docPages.map((page) => (
<a key={page.path} href={page.path} className={page.path === path ? 'active' : ''}>
{page.title}
</a>
))}
</nav>
</aside>
<main className="void-md">
<h1>{fm.title}</h1>
{children}
</main>
</div>
);
}<!-- pages/docs/layout.island.vue -->
<script setup>
import { useFrontmatter } from '@void/md';
import pages from '@void/md/pages';
import { useRouter } from '@void/vue';
const fm = useFrontmatter();
const { path } = useRouter();
const docPages = pages.filter((p) => p.path.startsWith('/docs/'));
</script>
<style>
@import '@void/md/theme-content.css';
</style>
<template>
<div class="docs-layout">
<aside>
<nav>
<a
v-for="page in docPages"
:key="page.path"
:href="page.path"
:class="{ active: page.path === path }"
>
{{ page.title }}
</a>
</nav>
</aside>
<main class="void-md">
<h1>{{ fm.title }}</h1>
<slot />
</main>
</div>
</template><!-- pages/docs/layout.island.svelte -->
<script>
import "@void/md/theme-content.css";
import { useFrontmatter } from "@void/md";
import pages from "@void/md/pages";
import { useRouter } from "@void/svelte";
const fm = useFrontmatter();
const { path } = useRouter();
const docPages = pages.filter((p) => p.path.startsWith("/docs/"));
</script>
<div class="docs-layout">
<aside>
<nav>
{#each docPages as page}
<a href={page.path} class:active={page.path === path}>
{page.title}
</a>
{/each}
</nav>
</aside>
<main class="void-md">
<h1>{fm.title}</h1>
<slot />
</main>
</div>// pages/docs/layout.island.tsx
import '@void/md/theme-content.css';
import { useFrontmatter } from '@void/md';
import pages from '@void/md/pages';
import { useRouter } from '@void/solid';
import { For } from 'solid-js';
const docPages = pages.filter((p) => p.path.startsWith('/docs/'));
export default function DocsLayout(props) {
const fm = useFrontmatter();
const { path } = useRouter();
return (
<div class="docs-layout">
<aside>
<nav>
<For each={docPages}>
{(page) => (
<a href={page.path} classList={{ active: page.path === path() }}>
{page.title}
</a>
)}
</For>
</nav>
</aside>
<main class="void-md">
<h1>{fm.title}</h1>
{props.children}
</main>
</div>
);
}