Thoughtworks Technology Radar Vol 34 put Typst in the Trial ring. It's the modern typesetting language people are jumping to from LaTeX, partly because compile times are milliseconds instead of seconds. The thing that's not obvious from the docs is how much your existing Markdown carries over: Typst's surface syntax is closer to Markdown than to LaTeX. To make that concrete, I wrote a 500-line vanilla JS converter that takes Markdown and prints the Typst equivalent.
🌐 Demo: https://sen.ltd/portfolio/markdown-to-typst/
📦 GitHub: https://github.com/sen-ltd/markdown-to-typst
Typst-is-Markdown-shaped
What LaTeX writes as \section{Hello} Typst writes as = Hello. \textbf{bold} becomes *bold*. \begin{itemize} \item foo becomes - foo. Roughly 70–80% of a normal README carries over unchanged. That's the angle: not "Typst has nice math" but "your existing prose works."
The tool surfaces this by putting your real Markdown side-by-side with the Typst output. Reading the right pane after pasting the left tells you immediately whether your stack is Typst-ready.
Design — line-oriented + sentinel-and-restore
converter.js ← mdToTypst + applyInline (DOM-free, 28 tests)
presets.js ← 4 sample documents
app.js ← UI glue
converter.js is a two-layer translator: line-oriented block parser, plus an inline pass on the result. The block parser tries handlers in order and takes the first match:
export function mdToTypst(md) {
const lines = md.split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const remaining = lines.slice(i);
const block =
tryFencedCode(remaining) ||
tryHorizontalRule(remaining) ||
tryHeading(remaining) ||
tryBlockquote(remaining) ||
tryList(remaining) ||
tryTable(remaining) ||
tryBlank(remaining) ||
tryParagraph(remaining);
out.push(block.typst);
i += block.consumed;
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
Each handler returns { typst, consumed } or null. The trial order matters — more specific patterns first: fenced code → horizontal rule → heading → blockquote → list → table → blank → paragraph (as the fallback). If you put paragraphs first, headings get swallowed as ordinary text.
Handler examples
Heading:
function tryHeading(lines) {
const m = lines[0].match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!m) return null;
const level = m[1].length;
const text = applyInline(m[2]);
return { typst: `${"=".repeat(level)} ${text}`, consumed: 1 };
}
# count maps directly to = count. This isn't a happy accident — Typst deliberately mirrors Markdown for the constructs Markdown got right.
Table:
function tryTable(lines) {
if (lines.length < 2) return null;
if (!/^\s*\|.+\|\s*$/.test(lines[0])) return null;
if (!/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(lines[1])) return null;
// … parse rows → emit #table(columns: N, [a], [b], [c], …)
}
Typst's #table takes column count and then positional cell arguments. The Markdown |---|---|---| divider line is discarded, header cells get wrapped as [*…*] to bold them.
Inline: the sentinel-and-restore trap
First version of my inline pass had this bug:
input: **description**
expected: *description* (Typst bold)
actual: _description_ (italic — wrong!)
What happened:
- Bold pass
**x** → *x*rewrote**description**to*description*. -
Italic pass
*x* → _x_then matched the result and rewrote it to_description_.
The bold-pass output collides with the italic-pass input. Classic Markdown-parser bug.
The fix is sentinel placeholders + restore at the end:
const boldStash = [];
text = text.replace(/\*\*([^*\n]+)\*\*/g, (_, body) => {
boldStash.push(body);
return `\x00BOLD${boldStash.length - 1}\x00`; // quarantine
});
// Now italic can run without false matches — `*x*` no longer exists.
text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, (_, pre, body) => `${pre}_${body}_`);
// Restore.
text = text.replace(/\x00BOLD(\d+)\x00/g, (_, i) => `*${boldStash[Number(i)]}*`);
\x00 is the NULL byte. It's never legitimately present in user text (if it is, you have other problems). The same trick stashes inline code spans so emphasis patterns don't reach inside them. Every Markdown parser does some version of this — and you only see why after you write one yourself and step on it.
The fix is locked in by tests:
test("mixed bold + italic", () => {
assert.equal(applyInline("**bold** and *italic*"), "*bold* and _italic_");
});
Links and images
// [text](url) → #link("url")[text]
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
`#link("${url.trim()}")[${label}]`
);
//  → #image("url")
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, _alt, url) =>
`#image("${url.trim()}")`
);
Typst's #link(url)[label] is "function call + content block." More verbose than [text](url) but easier to manipulate programmatically later — adding target="_blank" equivalents to every link, for example, is one regex on #link(...)[...] rather than parsing context-sensitive Markdown.
Typst's #image doesn't take alt text as a positional argument. Best practice is to use caption: […], which separates "visual" from "described." The tool currently drops alt text on conversion; that's a known gap, but it reflects what Typst's design says about accessibility (caption ≠ alt; both are valid distinct concerns).
What I deliberately didn't implement
Out of CommonMark scope by design:
- Setext-style headings (
===underlines) - Reference-style links (
[a][1]+ a trailing[1]: url) - Embedded HTML (
<div>…</div>) - Nested lists past depth 1
- Multi-paragraph list items
If you want a full CommonMark parser, use markdown-it or remark. This tool's job is "show me what Typst would do with my prose" — and for that, covering the constructs an average README uses is enough.
What I also didn't implement — actual Typst rendering
The tool only does syntax mapping, no PDF/SVG output. Reasons:
- Typst's WASM build (
@myriaddreamin/typst.ts) is 5–10 MB - Adding fonts pushes it further
- A CDN-loaded version violates the portfolio's "zero deps, no build" rule
- The use case "should I migrate to Typst?" is answered by reading the converted markup, not by another preview
If you want to render, the README points to typst compile and to typst.app.
28 tests
- headings (4): h1 / h2 / h6 / with emphasis
- paragraphs (2): plain / two paragraphs
- inline emphasis (4): bold / italic / mixed / underscore italic
- inline code (3): basic / with metachars / emphasis-inside-code-protected
- links / images (2): each
- lists (3): bullet / numbered / with emphasis
- blockquotes (2): single line / multi-line
-
horizontal rule (2):
---/*** - fenced code (3): plain / with language / emphasis-protected-inside
- tables (2): 2×2 / 3-col multi-row
- end-to-end (1): a README-shape document through every handler
The "emphasis can't leak into code spans" and "fenced code body is verbatim" tests are the ones that catch sentinel-and-restore regressions. They're the load-bearing tests.
Try it
- Demo: https://sen.ltd/portfolio/markdown-to-typst/
- GitHub: https://github.com/sen-ltd/markdown-to-typst
Paste your real README, see what Typst would have done with it. If 80% of the right pane looks correct, your migration cost is mostly figuring out the math syntax.
Takeaways
- Typst's surface syntax is intentionally Markdown-shaped. Headings, emphasis, lists, inline code translate ~1:1.
- Block parser trial order is the safety invariant. More specific patterns must be tried before more general ones, or you'll silently consume things as paragraphs.
-
Sentinel-and-restore is the canonical fix for "two regex passes fight over the same character." Bold-vs-italic on
*is the classic case, but the pattern recurs anywhere your output shares syntax with later input. -
Function-call inline forms (
#link(url)[label]) are more verbose than Markdown shortcuts but composable. Programmatic post-processing is easier on Typst than on Markdown. - Scope: the converter, not the renderer. Bundling a 5–10 MB Typst WASM would defeat the "is this migration realistic" question the tool exists to answer.
This is OSS portfolio #248 from SEN LLC (Tokyo), the second entry in the "Try the Tech Radar" series — picking blips from the Thoughtworks Technology Radar and shipping a demo for each. Previous: #247 TOON converter. Next up: Structured output from LLMs. https://sen.ltd/portfolio/

Top comments (0)