Markdown
Pulse includes a built-in markdown parser. Write content in .md files, load them with the md() helper, and render with the prose component. No external dependencies — zero browser bytes.
Basic usage
Import md from @invisibleloop/pulse/md and call it with a file path. It returns an async server fetcher — use it in server and optionally in meta:
import { md } from '@invisibleloop/pulse/md'
import { prose } from '@invisibleloop/pulse/ui'
const page = md('content/about.md')
export default {
route: '/about',
server: { page },
view: (state, { page }) => prose({ content: page.html }),
}
The fetcher returns { html, frontmatter }. The html is ready to pass directly to prose(). The frontmatter object contains the parsed key/value pairs from the --- block at the top of the file.
Frontmatter
Add a --- block at the top of any .md file to define metadata. Use the values in meta for SEO:
---
title: About Us
description: Learn about our company
date: 2024-01-15
---
# About Us
Welcome to our company…
const page = md('content/about.md')
export default {
route: '/about',
meta: {
title: async (ctx) => (await page(ctx)).frontmatter.title,
description: async (ctx) => (await page(ctx)).frontmatter.description,
},
server: { page },
view: (state, { page }) => prose({ content: page.html }),
}
page(ctx) in both meta and server reads the file only once per request — the result is cached on ctx._mdCache automatically.Dynamic routes
Use :param placeholders in the path — they are resolved from ctx.params at request time:
import { md } from '@invisibleloop/pulse/md'
import { prose } from '@invisibleloop/pulse/ui'
const post = md('content/blog/:slug.md')
export default {
route: '/blog/:slug',
meta: {
title: async (ctx) => (await post(ctx)).frontmatter.title,
description: async (ctx) => (await post(ctx)).frontmatter.description,
},
server: { post },
view: (state, { post }) => `
<main id="main-content">
${prose({ content: post.html })}
</main>
`,
onViewError: (err) => `
<main id="main-content">
<p>Post not found.</p>
</main>
`,
}
{ status: 404 }. Define onViewError on dynamic-route pages to render a friendly not-found message instead of a 500.parseMd()
For cases where you already have a markdown string (from a database, API, or generated content), use parseMd directly instead of the file helper:
import { parseMd } from '@invisibleloop/pulse/md'
import { prose } from '@invisibleloop/pulse/ui'
export default {
route: '/post/:id',
server: {
post: async (ctx) => {
const record = await db.posts.find(ctx.params.id)
const { html, frontmatter } = parseMd(record.body)
return { html, title: frontmatter.title ?? record.title }
}
},
view: (state, { post }) => prose({ content: post.html }),
}
What the parser renders
| Markdown | Output |
|---|---|
# Heading through ###### Heading | <h1>–<h6> with auto id anchors |
**bold**, *italic*, ~~strike~~ | <strong>, <em>, <del> |
`inline code` | <code> |
| Fenced code block with language tag | <pre><code class="language-X"> with syntax highlighting |
[text](url),  | <a>, <img> |
- item, 1. item | <ul>, <ol> (nested lists supported) |
> quote | <blockquote> |
GFM table (| col | col |) | <table> with <thead> and <tbody> |
--- (three or more) | <hr> |
Syntax highlighting
Fenced code blocks with a language tag are syntax-highlighted automatically — no client JS required. The highlighting runs on the server and emits <span class="tok-*"> elements. The colour tokens are defined in pulse-ui.css and respect the active theme.
```js
const greeting = 'Hello, world'
console.log(greeting)
```
Supported languages:
| Tag(s) | Highlighter |
|---|---|
js, javascript, ts, typescript, jsx, tsx | JavaScript / TypeScript |
html, xml, svg, vue, svelte | HTML / XML |
css, scss, less | CSS |
bash, sh, shell, zsh | Bash / Shell |
json, jsonc | JSON |
| Any other tag | Plain text (escaped, no colour tokens) |
The prose component
The prose component is designed specifically for markdown output. It applies typographic styles to all descendant elements — headings, paragraphs, lists, blockquotes, code blocks, tables, images — without requiring any classes on the elements themselves:
import { prose } from '@invisibleloop/pulse/ui'
// Wrap the html from parseMd or md()
prose({ content: page.html })
// Optional size modifier
prose({ content: page.html, size: 'lg' }) // 'sm' | 'base' | 'lg'
content slot is injected as raw HTML — only pass server-side content that you trust. Never pass user-submitted HTML directly; sanitise it first.Using URL paths
Pass a URL object to resolve paths relative to the spec file rather than the process working directory:
const page = md(new URL('./content/about.md', import.meta.url))