No description
  • TypeScript 70.6%
  • Gherkin 12.5%
  • CSS 11.9%
  • Svelte 3.4%
  • JavaScript 0.8%
  • Other 0.8%
Find a file
nocyphr ea9d2f513e
All checks were successful
CI / ci (push) Successful in 3m5s
CI / publish (push) Successful in 54s
Merge pull request 'feature_render_plantuml' (#8) from feature_render_plantuml into master
Reviewed-on: #8
2026-05-23 15:30:02 +03:00
.forgejo add: releasenotes template 2026-05-23 13:26:29 +03:00
config update: ci should fail if cov <100 for any metric 2026-05-17 09:02:29 +03:00
examples update: add screenshots to readme 2026-05-16 18:53:30 +03:00
features fix: kill the mutants - no survivors 2026-05-23 15:21:01 +03:00
src fix: kill the mutants - no survivors 2026-05-23 15:21:01 +03:00
tests green: add wiring, fallback on renderfailure, make async, fix unittests 2026-05-23 12:41:27 +03:00
.dockerignore update: moving config 2026-05-16 16:32:40 +03:00
.fallowrc.json refactor: reduce maxCrap threshold from 21 to <10 2026-05-17 08:34:06 +03:00
.gitignore add: fallow config 2026-05-16 11:29:32 +03:00
docker-compose.dev.yml migrate logic 2026-05-09 10:57:52 +03:00
docker-compose.yml migrate logic 2026-05-09 10:57:52 +03:00
Dockerfile update: moving config 2026-05-16 16:32:40 +03:00
index.html add theme toggle 2026-05-16 09:48:11 +03:00
package-lock.json update: drop unused dep 2026-05-23 15:11:59 +03:00
package.json update: drop unused dep 2026-05-23 15:11:59 +03:00
README.md Merge branch 'master' into feature_render_plantuml 2026-05-23 15:29:00 +03:00
REVIEW.md temp: ai codereview doc 2026-05-17 09:11:56 +03:00
stryker.config.mjs fix: ci stage must fail if mutants survive 2026-05-17 08:44:27 +03:00
tsconfig.json add: unittests 2026-05-16 11:01:17 +03:00
vite.config.ts migrate logic 2026-05-09 10:57:52 +03:00

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?

  1. 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
  1. 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 h1h6 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, one describe per 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) in renderDiagrams — the guard is a performance shortcut; the DOM result is identical with or without it because an empty NodeList produces zero iterations
  • mermaid.initialize({ startOnLoad: false }) in renderMermaidBlocksstartOnLoad: false prevents 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 behaviour
  • throw 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