@emirotin/zerp
v0.11.1
Published
Zero-config presentation framework for slide decks authored in HTML and/or Markdown.
Maintainers
Readme
zerp
zerp is a zero-config presentation framework. Slides can be authored in HTML, Markdown, or a mix of both.
Each presentation can be authored as just a slides/ folder:
my-deck/
slides/
00-title.html
10-intro.md
20-content.md
images/
cover.jpgzerp finds slides/**/*.html and slides/**/*.md, sorts them by filename, rewrites relative asset URLs so slide-local assets keep working, injects default styles/runtime, and serves or builds a single-page deck. Fonts are bundled into the output, so a built deck is one self-contained file that presents fully offline.
Maintainer Policy
I use zerp myself and find it useful, which is why I am making it public as free open-source software.
That does not mean I am available for general collaboration. Issues and pull requests are intentionally disabled. I do not have the capacity to debug other people's problems for free, and I do not want to spend time triaging low-signal or AI-generated contributions.
If you want to use the project as-is, please do. If you need a fix, a feature, or help integrating it into your workflow, contact me directly for paid support.
Usage
Install from a local checkout:
pnpm add -D file:../zerp
pnpm exec zerp serve .Or from a registry:
pnpm add -D @emirotin/zerp
pnpm exec zerp build .Commands:
pnpm exec zerp serve # serve the current deck on http://localhost:8000 (live-reloads on save)
pnpm exec zerp serve . 3000 --theme dark # explicit deck dir, port, default theme
pnpm exec zerp build --theme light # write ./index.html (light default)
pnpm exec zerp check # browser-backed contrast, font-size, glyph, frame/layout report (both themes, the deck's design size; --theme dark|light|both, --size WxH, --only category,..., --json for tooling)
pnpm exec zerp print -o deck.pdf # render straight to PDF at the deck's design size
pnpm exec zerp slides # deck position → source file mapping (--json for tooling)Browsers
zerp check opens each theme in a real headless browser and needs a Chromium-class one. It never bundles one — it resolves an external browser in this order:
CHROME_BIN— if set, it wins. Point it at any Chrome/Chromium binary — an absolute path or a command name onPATH; a wrapper script that execs one with extra flags works too, as long as it answers--versionwith the browser's version banner. That banner is the health check: a non-browser at the path is a clear error here rather than a confusing launch failure later.- The playwright-managed Chromium — run
zerp install-browseronce to download it; nothing else needs configuring afterward. - A system Chrome/Chromium — Google Chrome or Chromium found on the usual macOS app paths or on
PATH(google-chrome,chromium,chromium-browser). Names found onPATHare resolved to the actual binary before launching, and every candidate must answer--versionwith a version banner — so a non-browser squatting on one of those names (Ubuntu's snap-transitionchromiumstub, say) is skipped rather than crashing the launch.
If none is found, zerp check says so and points here (and zerp build's post-build check summary prints a one-line notice and continues rather than failing). On a machine with no system Chrome, install one once:
pnpm exec zerp install-browser # download the managed Chromium
# — or —
export CHROME_BIN="/path/to/chrome"The browser stays external and optional: the package itself is browser-free, so installs are light and offline-friendly.
Reusing a running browser
By default each zerp check launches a browser and closes it again. A host that checks decks repeatedly — CI, a service, a watch loop — can keep one browser warm instead and point check at it with --browser-endpoint url (or ZERP_BROWSER_ENDPOINT):
# CDP: any Chrome started with --remote-debugging-port
pnpm exec zerp check --browser-endpoint http://127.0.0.1:9222
# playwright protocol: an endpoint from chromium.launchServer()
pnpm exec zerp check --browser-endpoint ws://127.0.0.1:5000/<guid>Prefer http(s):// (CDP) when the host runs its own playwright build: it is the browser's own protocol, so the two sides need no common version. ws(s):// speaks the playwright protocol, which is version-locked between client and server.
A supplied browser belongs to whoever started it: check creates its own context, closes that context, and disconnects — it never closes the browser. No local browser is needed or looked for in this mode. The host must keep its event loop responsive while check runs; a host that blocks it (a synchronous child-process call, say) can stall the very browser it is lending out.
Tooling
This repo pins Node and pnpm via Volta metadata in package.json:
volta pin [email protected] [email protected]Quality commands:
pnpm lint
pnpm lint:fix
pnpm format
pnpm format:check
pnpm test:browser # opt-in headless-browser regression test (requires Chrome/Chromium)husky runs lint-staged and a build check before each commit. dist/ is not checked into git; it is built on demand and included in the npm package via prepublishOnly.
Authoring
- Put all authored content in
slides/. - Use filename prefixes for ordering, for example
00-,10-,20-. - Store deck assets under
slides/too. Relative links likesrc="./images/foo.jpg"are rewritten automatically. - Each
.htmlfile can contain one or more<div class="slide">blocks. .mdfiles are also supported. Each Markdown file is automatically wrapped in<div class="slide">at build time — no manual wrapper needed. Use---on its own line to separate multiple slides within a single.mdfile.- Raw HTML inside Markdown files passes through unchanged, so you can embed interactive
<script>blocks, custom<div>layouts, or<style>elements alongside Markdown content. - At build time, every real
.slideis placed inside a framework-owned<div data-zerp-slide>. The frame controls visibility; the inner.slideis the full-size layout surface, so custom roots may usedisplay: gridor another layout safely. Do not style the reserved frame attributes. - The framework default CSS and browser runtime are stored as separate source assets and inlined into generated HTML during
serveandbuild. - Colors come from design tokens (
var(--zerp-*)) generated from the Harmony palette; decks render in dark and light themes. Do not hardcode colors. - The page title comes from the first slide's top heading (override via the
titlebuild option; folder name as fallback). - Run
zerp checkafter authoring, and after any layout change: it opens each theme in headless Chrome/Chromium (resolved as described under Browsers — runzerp install-browseronce if you have no system Chrome) and reports, per slide, APCA contrast, font-size floors, surface-blend, glyph/font-fallback coverage, SVG text sealed off from judging, and — the layout side, folded in from the formerzerp verify— that exactly one full-size slide frame is active and visible without page overflow, plus any browser console errors. Overflow is relative to the checked viewport — the deck's declaredzerp.size, or 1920x1080 if it declares none, unless--size WxHoverrides it; the summary and--json'sviewportfield (including itssource:"deck","default", or"flag") record exactly what was checked.zerp checkrequires a browser; without one it names the problem (no browser found, orCHROME_BINpointing somewhere invalid) and points atzerp install-browser. - Every finding carries a
category— one ofcontrast,type-size,surface,glyph,svg-text,frame,overflow,safe-zone,console— printed at the front of its report line.--only category,categorynarrows a run to a comma-separated subset; an unknown category is rejected with the full list. - Fonts are inlined per deck: a build carries the Montserrat and Roboto Mono subsets whose
unicode-rangethe deck's own text actually touches (Latin always), plus a one-glyph face for→. A latin deck therefore ships no Cyrillic, and a deck that types№ships the subset that covers it. zerp check's glyph finding names, per element, that its text was rendered by a font the deck does not bundle, along with the fallback family — for example, an element set in a stack zerp did not subset for the characters it contains. The renderer itself is the source of truth: zerp inlines every font it ships as an@font-face, so Chrome's own answer to "what font actually painted this glyph" (isCustomFont: false) is what gets reported, not a static coverage table. Attribution is per element, not per codepoint or count — where a parent's fallback glyphs are a subset of a descendant's in the same family, the finding lands on the descendant; the slide is still flagged, only the element pointer is coarser. Emoji are exempt — every platform draws those from its own colour emoji font.llms.txtdocuments the full set of edge cases (ZWJ sequences, generated content,<svg>text).zerp check --safe-margin pxadditionally requires every top-level element of each slide to stay at least that many px inside all page edges — a print-safe inset for decks headed to PDF. Mark intentionally full-bleed elements withdata-zerp-bleedto exempt them. Off by default; choose a margin below the slide padding so ordinary content never trips it.zerp check --timeout ms(orZERP_VERIFY_TIMEOUT_MS) sets the budget for the whole browser session — launch, navigation, font activation and the probe. The default is 20000ms, which suits a developer machine; raise it on a small or loaded host, or for a deck carrying heavy imagery. A session that runs out of budget produces no report at all, so if you automatezerp check, give it a budget that matches the host it runs on and treat the timeout as a failed check rather than a passed one.- The package ships a designer-facing style guide as
docs/style-system.pdf(resolvable as@emirotin/zerp/docs/style-system.pdf): the layers, the type pair, the token table in both themes, the utilities, and every component, with all examples rendered by the framework's own stylesheet. It is reprinted from the current stylesheet for every release, so it describes the version you installed. - "Slide N" means the 1-based deck position (what the on-screen counter shows) — file prefixes only order files.
zerp slidesprints the position → file mapping; pressingsin a running deck shows the active slide's source.
Printing and PDF export
pnpm exec zerp print # write ./index.pdf at the deck's design size
pnpm exec zerp print . --theme dark -o out.pdfzerp print is the canonical path: it renders the deck directly to a PDF at
its design size (zerp.size, or 1920×1080 if the deck declares none) with a
headless browser, no manual viewport or page-size setup. It produces one
page per slide in deck order: presentation chrome (nav, counter, progress
bar, theme toggle, source badge) is hidden, and steps are rendered in their
final state — every data-step reveal shown, every data-until-step element
gone. Content that overflows a slide is clipped at the bottom of the page
rather than spilling onto a second page, so keep slides within the frame (the
same as on screen — zerp check catches overflow).
Any built index.html is also print-ready as-is (browser print dialog, or a
headless print backend), for custom pipelines that need more control than
zerp print offers. Print with a page size equal to the presentation
viewport and backgrounds enabled — one slide fills exactly one page at any
page size, in either theme. Example: render a deck to PDF at 1920×1080 with
Playwright directly:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1920, "height": 1080})
page.goto("file:///abs/path/to/index.html")
page.pdf(path="deck.pdf", width="1920px", height="1080px", print_background=True)
browser.close()The explicit width/height already describe the page — do not also pass
landscape=True, because Chromium swaps the two dimensions when landscape is
set and you get a portrait page.
Deck configuration (optional)
A deck needs no configuration: a directory with slides/ is a deck. The two
things it cannot express otherwise are which typefaces it is set in and what
design size it is authored for, so an optional zerp key in the deck's own
package.json can name them:
{
"dependencies": {
"@fontsource/bebas-neue": "^5",
"@fontsource/inter": "^5",
"@fontsource/jetbrains-mono": "^5"
},
"zerp": {
"size": "1920x1080",
"fonts": {
"display": { "family": "Bebas Neue", "weights": ["400"] },
"body": { "family": "Inter" },
"mono": { "family": "JetBrains Mono" }
}
}
}sizeis"WxH"in CSS pixels. Unset, it defaults to1920x1080. It fixes the deck's design canvas — every slide frame is sized to it and the runtime scales that canvas to fit the real browser window — and it doubles as the default viewportzerp checkmeasures against and the page sizezerp printproduces, so all three stay in lockstep without repeating the number on the command line.familyis the name the font declares — the same one you would write infont-family. zerp checks it against the package and says so if they differ.fontsourcePackageis optional; it defaults to@fontsource/<family>slugified ("JetBrains Mono"→@fontsource/jetbrains-mono).- The deck installs the package itself. zerp resolves it from the deck's
node_modules(falling back to its own), sopnpm add @fontsource/interbefore building. A package that cannot be resolved is a build error naming the package and the install command. weightsis optional and defaults to what zerp's own styles ask for: body400 600 700 900 400-italic, mono400 700(fontsource file stems). Weights a family does not ship are simply not emitted — browsers synthesize.displaysets the face forh1. Unset, it followsbody— family, package and weights — so a deck that names onlybodygets that family everywhere.monounset stays Roboto Mono; it never followsbody, because the nav, code, tables and labels need real monospace metrics.- The display face applies to
h1only. Move others onto it from your own stylesheet —h2 { font-family: var(--zerp-font-display) }— or off it withh1 { font-family: var(--zerp-font-body) }; zerp's rule carries zero specificity so a plain element selector wins. - Subsets are still chosen by the deck's text, so a CJK family is carried a few ranges at a time rather than all of it.
- Everything else is unchanged: the
→face is always bundled, and each family keeps its role in the five stacks zerp draws with.
Library API
import { buildPresentationHtml, writePresentation } from "@emirotin/zerp";Example
This repository includes a migrated example deck at examples/casino/. Its authored source is only examples/casino/slides/.
