- TypeScript 70.6%
- Gherkin 12.5%
- CSS 11.9%
- Svelte 3.4%
- JavaScript 0.8%
- Other 0.8%
|
|
||
|---|---|---|
| .forgejo | ||
| config | ||
| examples | ||
| features | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .fallowrc.json | ||
| .gitignore | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| REVIEW.md | ||
| stryker.config.mjs | ||
| tsconfig.json | ||
| vite.config.ts | ||
md-pdf
Markdown to PDF converter with live preview and auto-paginated table of contents.

Features
- Two-pane editor: markdown input on the left, live HTML preview on the right
- Smart TOC annotation: detects TOC link lists and fills in the correct PDF page number for each entry
- Page breaks via
----(four dashes on their own line) - Diagram rendering — fenced diagram blocks are replaced with live SVG diagrams in the preview
- Mermaid
- PlantUML (rendered via plantuml.com SVG endpoint)
- Syntax highlighting with VS Code colour themes (dark default, light toggle)
- Theme toggle (toolbar button) — preference persisted across sessions
- Draft auto-saved to
localStorage— survives page refresh - Smooth in-page anchor navigation in the preview pane
- Zero backend — fully client-side, exported to PDF via the browser print dialog
Just want to run the tool?
- Run the following command (docker must be installed)
docker run -d --name md-to-pdf -p 8080:80 git.nocyphr.com/nocyphr/md-to-pdf:latest
- go to
http://localhost:8080
Project Structure
features/ # Cucumber BDD scenarios + step definitions
├── diagram_rendering.feature
├── export.feature
├── persistence.feature
├── render.feature
├── theme.feature
├── toc.feature
└── step_definitions/
├── world.ts # Shared Cucumber World (jsdom setup)
├── export.steps.ts
├── persistence.steps.ts
├── render.steps.ts
├── theme.steps.ts
└── toc.steps.ts
src/ # Application source
├── App.svelte # Root component: editor, preview, toolbar
├── app.css # Global styles
├── main.ts # Vite entry point
└── lib/ # Pure TypeScript modules (unit-tested, covered)
├── diagrams.ts # Diagram rendering (Mermaid, PlantUML → SVG)
├── layout.ts # A4 page layout simulation for TOC page numbers
├── navigation.ts # Anchor-click resolution for in-preview scroll
├── preview.ts # Post-render effects: heading IDs, syntax highlighting, TOC annotation
├── render.ts # Markdown → HTML (marked + page-break handling)
├── storage.ts # localStorage draft persistence
├── theme.ts # Dark/light theme application
└── toc.ts # TOC detection and annotation
config/ # Tool configuration
├── c8.json # Coverage thresholds (100% across all metrics)
├── cucumber.mjs # Cucumber runner config
├── nginx.conf # Production nginx config
└── tsconfig.stryker.json # TypeScript config scoped to Stryker
tests/ # Mocha + Chai unit tests (mirrors src/lib/)
├── helpers.ts # jsdom bootstrap + DOM geometry utilities
├── layout.test.ts
├── navigation.test.ts
├── preview.test.ts
├── render.test.ts
├── storage.test.ts
├── theme.test.ts
└── toc.test.ts
Logic Flow
User types markdown
│
▼
onInput()
├── debounce 150ms
└── saveDraft() ──────────────────────► localStorage
│
▼
$state(markdown)
│
▼
$derived(html) = render(markdown)
├── replace ---- with <div class="page-break">
└── marked.parse() → HTML string
│
▼
DOM update via {@html}
│
▼
$effect() [runs after each render]
├── assignHeadingIds()
│ └── slugify h1–h6 text, deduplicate IDs
├── hljs.highlightElement() per <pre><code>
├── renderDiagrams() — replaces fenced mermaid/plantuml blocks with live SVG
└── annotateToc()
├── detect: <li> elements containing only <a href="#...">
├── validate each anchor target exists in the DOM
├── simulate A4 layout (210mm × 297mm, 14mm padding)
│ └── build headingPages map: heading → page number
└── inject .toc-title · .toc-leader · .toc-page spans
│
▼
User clicks "Download PDF"
│
▼
window.print() → browser print dialog → Save as PDF
Running the App
Development
docker compose -f docker-compose.dev.yml up
Starts a node:22-alpine container with the repo mounted as a volume. Runs npm install then the Vite dev server with hot module replacement.
App available at: http://localhost:5173
Production
docker compose up -d --build
Multi-stage Docker build: node:22-alpine compiles dist/, then nginx:alpine serves it as static files.
App available at: http://localhost:8080
npm Scripts
| Command | What it does |
|---|---|
npm run dev |
Vite dev server with HMR |
npm run build |
Production build to dist/ |
npm run preview |
Serve dist/ locally for inspection |
npm run test:unit |
Mocha + Chai unit tests (tests/) |
npm run test:features |
Cucumber BDD test suite (features/) |
npm run test:cov |
Combined coverage report (unit + BDD) via c8 → coverage/ |
npm run stryker |
Mutation testing — reports surviving mutants |
npm run fallow |
Static analysis: dead code, duplication, complexity |
npm run ci |
Full pipeline: build → unit → features → coverage → mutation → fallow |
Testing
Two test layers run in combination:
- Unit tests (
tests/) — Mocha + Chai, onedescribeper lib module. Each test calls the real source function with a minimal setup and asserts on the exact output. jsdom provides the browser runtime (DOM, localStorage, CSS, getComputedStyle); everything else runs for real. - BDD tests (
features/) — Cucumber scenarios covering end-to-end behaviour from a user perspective.
Coverage
npm run test:cov runs both suites and merges their V8 coverage data into a single report. Both processes write to the same NODE_V8_COVERAGE temp directory; c8 reads all files in one pass and produces lcov + JSON output in coverage/.
Mutation testing
npm run stryker instruments src/lib/ with 269 mutants and runs the full test command for each. Current score: 100.00%.
Timeouts — 6 mutations to page-layout arithmetic in placeElement (gap calculation, cursor advancement, page-break logic) sent the simulation into an infinite or very slow loop. Stryker counts timeouts as killed: the tests detected that something was wrong. They are benign.
Disabled — 3 mutations in diagrams.ts are suppressed with // Stryker disable next-line:
if (mermaidBlocks.length)inrenderDiagrams— the guard is a performance shortcut; the DOM result is identical with or without it because an emptyNodeListproduces zero iterationsmermaid.initialize({ startOnLoad: false })inrenderMermaidBlocks—startOnLoad: falseprevents mermaid from auto-rendering on page load in a real browser; jsdom fires no page-load events so all boolean/object values produce identical test behaviourthrow new Error(\PlantUML: ${response.status}`)error message inrenderPlantUmlBlock— the throw is always caught silently byrenderDiagrams`; message content is not observable through the public API
Code Quality
npm run fallow runs fallow static analysis across the codebase:
- Dead code detection — unused exports and unreachable files
- Duplication scanning — repeated logic across modules
- Complexity — maintainability index per function (threshold: flagged if below acceptable range)
A clean run looks like:
Dead Code ✓ No issues found
Duplication ✓ No duplication found
Complexity ✓ all functions analyzed — 0 above threshold — MI good
Run as part of CI via npm run ci.
CI
Every push and pull request triggers the CI pipeline (npm run ci):
| Step | Command | Fails if |
|---|---|---|
| Build | npm run build |
TypeScript or Vite error |
| Unit tests | npm run test:unit |
Any unit test fails |
| BDD features | npm run test:features |
Any scenario fails |
| Coverage | npm run test:cov |
Any test fails or threshold not met |
| Mutation testing | npm run stryker |
Any mutant survives (break: 100) |
| Static analysis | npm run fallow |
Dead code, duplication, or complexity breach |
Steps are chained with && — a failure stops the pipeline immediately.
On master only: if CI passes, the Publish workflow fires automatically. It builds a Docker image via Kaniko and pushes it to the Forgejo container registry tagged :latest and :<sha>.
Tech Stack
| Framework | Svelte 5 (runes) |
| Build tool | Vite 6 |
| Markdown parser | marked 12 |
| Mermaid | mermaid |
| PlantUML encoding | plantuml-encoder |
| Syntax highlighting | highlight.js 11.9 |
| Unit tests | Mocha 10 + Chai 5 + jsdom |
| BDD tests | Cucumber 11 + jsdom |
| Mutation testing | Stryker 8 |
| Dev runtime | Node 22 Alpine |
| Prod server | nginx Alpine |
| CI | Forgejo Actions |
| Image build | Kaniko |