The Problem
Most online resume builders fall into two camps:
SaaS tools that upload your resume to a server for PDF generation — your most sensitive personal data leaves your machine.
LaTeX/Typst templates that produce great output but require a local toolchain, package manager, and CLI fluency.
For non-technical users, option 2 is inaccessible. For privacy-conscious users, option 1 is unacceptable. SmartResume tries to solve both: professional typesetting quality, entirely in the browser.
You can try it at .
.
Two WASM binaries handle the pipeline:
| Binary | Purpose | Size |
|---|---|---|
typst_ts_web_compiler_bg.wasm | Parses .typ source, produces a document AST | ~8 MB |
typst_ts_renderer_bg.wasm | Renders the AST to PDF bytes and SVG elements | ~5 MB |
Both run inside a Web Worker to avoid blocking the main thread. This is critical — Typst compilation can take 200-400ms even for a single-page resume, and you don't want that on the UI thread.
Worker Initialization
// frontend/src/features/template-renderer/hooks/useTypstCompiler.ts
const workerRef = useRef<Worker>();
useEffect(() => {
const worker = new Worker(
new URL('../worker/typst.worker.ts', import.meta.url),
{ type: 'module' }
);
worker.postMessage({ type: 'init' });
workerRef.current = worker;
return () => worker.terminate();
}, []);
The worker loads the WASM binaries, fetches font files from CDN (Roboto, NotoSansCJK, Font Awesome), and preloads Typst template files from the /public/templates/ directory.
Compilation Message Protocol
The main thread and worker communicate via a simple message protocol:
Main Thread Web Worker
│ │
│──── set_source ────────────▶ │ (update .typ source)
│──── compile (id: 7) ───────▶ │ (trigger compilation)
│ │
│ ... user types, triggers │
│ another compile ... │
│──── compile (id: 8) ───────▶ │
│ │
│◀─── compile_done (id: 7) ─── │ ← stale, ignored
│◀─── compile_done (id: 8) ─── │ ← current, rendered
Each compile message carries a monotonically incrementing ID. When the worker finishes, it echoes the ID back. If the ID doesn't match the latest request, the result is discarded — a simple form of stale-result rejection without AbortController.
Template Mock Injection
Typst templates typically use #import directives to reference other files. In the WASM sandbox, there's no file system access, so the worker strips these imports and injects mock implementations:
// Injected into each template's source before compilation:
#let fa-icon(name, fill: black) = {
// Unicode character mapping — no external font needed
let icons = (
"github": "\u{f09b}",
"linkedin": "\u{f08c}",
"envelope": "\u{f0e0}",
// ...
)
text(fill: fill, raw(icons.at(name, default: "")))
}
#let linguify(key, default: none, ..args) = { default }
The Editor: ContentEditable Meets Markdown
The editing experience is block-based — similar to Notion. Each block is a heading, list item, or paragraph. The twist is that every block supports two editing modes:
WYSIWYG Mode (contentEditable)
A contenteditable div with formatting (bold, color, font size). The challenge with contentEditable is selection preservation across React re-renders. The solution:
// frontend/src/features/editor/utils/domUtils.ts
function saveSelection(container: HTMLElement): SelectionState | null {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
// Walk the DOM tree to find the caret position by character offset
const nodeStack: Node[] = [];
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
);
// ... build path to current node and offset within it
return { nodePath, offset };
}
function restoreSelection(
container: HTMLElement,
state: SelectionState
): void {
// Walk the stored path through child nodes to reach the target
// Then set the caret at the stored offset
}
Raw Markdown Mode
A hidden <textarea> activates when you click or press Enter in the margin zone next to a block. Type raw markdown, commit with Enter, cancel with Escape.
# Experience | 2020 - Present
**Senior Engineer** at Acme Corp
- Led a team of **5 engineers**
- Built [the platform]{#0075de}
The format uses custom extensions: [text]{#color} for colored text and [text]{size:14pt} for font sizing. These map to Tailwind-style inline styles in the rendered output and to Typst markup in the generated source.
Auto-Detection on Input
Typing #, ##, ###, or - at the start of a block converts its type automatically — no toolbar clicks needed.
Typst Code Generation
The editor state (a tree of blocks) is converted to Typst source code via template-specific generators:
// frontend/src/features/template-renderer/generators/westernResume.ts
export function generateWesternResume(state: EditorState): string {
const lines: string[] = [];
// Header with personal info
lines.push(`#set page(margin: (top: 1.5cm, bottom: 1.5cm))`);
lines.push(`#align(center)[`);
lines.push(` = ${escape(state.personalInfo.name)}`);
lines.push(` ${escape(state.personalInfo.email)} | ${escape(state.personalInfo.phone)}`);
lines.push(`]`);
// Sections (h1) → entries (h2) → roles (h3)
for (const section of state.sections) {
lines.push(`== ${escape(section.title)}`);
for (const entry of section.entries) {
lines.push(`#resume-entry(`);
lines.push(` title: [${escape(entry.title)}],`);
lines.push(` right: [${escape(entry.right)}],`);
lines.push(`)`);
// Rich text items as Typst markup
for (const item of entry.items) {
lines.push(` - ${renderRichText(item.content)}`);
}
}
}
return lines.join('\n');
}
The generator translates rich text formatting into native Typst markup:
| Editor Format | Typst Output |
|---|---|
**bold text** | #strong[bold text] |
[colored text]{#0075de} | #text(fill: rgb("#0075de"))[colored text] |
[text]{size:14pt} | #text(size: 14pt)[text] |
Persistent State Without a Backend
All state lives in IndexedDB via .
Trade-offs and Limitations
What Works Well
Privacy: No data leaves the browser. You can verify this in DevTools Network tab — zero requests contain resume content.
PDF quality: Typst output is genuinely professional, on par with LaTeX.
Startup speed: No account, no signup. The app loads and you start editing.
What Could Be Better
WASM binary size: The two.wasmfiles total ~13 MB. First load on slow connections is noticeable. HTTP caching and service worker pre-caching mitigate this after the first visit.
Font loading: Typst needs fonts available in the virtual filesystem. The worker fetches them from CDN on init — on a bad network, this can take tens of seconds. There's a 60-second timeout with a user-facing error about VPN/network issues.
contentEditable: Like every contentEditable-based editor, there are edge cases with selection, IME input, and copy-paste. The dual-mode (WYSIWYG + raw markdown) is a pragmatic escape hatch: if the rich text editor misbehaves, drop into markdown mode.
No collaboration: This is deliberate. Real-time collaboration requires a server (or WebRTC signaling), which reintroduces the privacy problem.
Try It Yourself
git clone https://github.com/Bowen7/smart-resume.git
cd smart-resume
npm install
npm run dev
Open http://localhost:5173. The app requires no environment variables for basic use — only the optional /api/feedback endpoint needs a DISCORD_WEBHOOK_URL.
Have you built something with WASM in the browser? What challenges did you hit with Web Workers or contentEditable? I'd appreciate your feedback on the approach.
SOCIAL SHARE CARD GENERATOR