Pagewright is a single-page Flask app that serves your book directory as a multi-chapter editor. This page documents the request flow, module boundaries, and the extension points where you can plug in your own behaviour.
src/pagewright/
├── cli.py argparse entry point — parses --port, --theme, book_dir
├── server.py Flask app — routes, atomic saves, ETag, no-cache
├── renderer.py Markdown → standalone HTML — theme + mode aware
├── markdown.py Self-contained Markdown converter — heading IDs, callouts
├── theme.py Theme loader — built-in + custom paths, callout overrides
├── config.py pagewright.yaml schema + loader
├── static/ Editor UI — index.html, app.js, style.css, icons.js
└── themes/ Built-in themes (default)
pagewright.cli.mainparses CLI args.pagewright.server.servecallscreate_app(book_dir, theme_name).create_apploadspagewright.yaml(config.load_book_config) and the active theme (theme.load_theme).- Both are stored on
app.configand the routes are registered. - Flask binds to
127.0.0.1:5566(or whatever--portwas given).
Browser Server
│ │
│ GET / │
│ ────────────────────────────────────▶ │
│ ◀──────────── index.html ─────────── │
│ │
│ GET /api/tree │
│ ────────────────────────────────────▶ │
│ ◀──── [book → parts → chapters] ──── │
│ │
│ click chapter "01_intro.md" │
│ │
│ GET /api/file?path=01_intro.md │
│ ────────────────────────────────────▶ │
│ ◀────── markdown source + ETag ───── │
│ │
│ GET /api/preview?path=01_intro.md │
│ &mode=continuous │
│ ────────────────────────────────────▶ │
│ ◀──── standalone preview HTML ───── │
│ │
│ ── user types in CodeMirror ── │
│ (debounce 1.5 s) │
│ │
│ POST /api/file │
│ { path, content, ifMatch } │
│ ────────────────────────────────────▶ │
│ atomic write + new ETag │
│ ◀──────── { saved, etag } ────────── │
│ │
│ GET /api/preview (refresh) │
│ ────────────────────────────────────▶ │
│ ◀──── new HTML, position-stable ──── │
If the file changed under the editor (another tool, git pull):
│ POST /api/file │
│ { …, ifMatch: "<old etag>" } │
│ ────────────────────────────────────▶ │
│ (file mtime no longer matches) │
│ ◀──── 409 Conflict + currentEtag ─── │
│ │
│ ── editor opens modal: │
│ Reload / Overwrite / Cancel ── │
renderer.render_chapter_html(md_path, book, theme, mode, color_scheme):
- Read the markdown file from disk.
- Convert to HTML via
markdown.md_to_html, passing the theme's callout list so blockquote prefixes (> **Tip:**) become<div class="callout callout-tip">. - Look up the chapter in
book.partsto get its number, part index, and total count. - Format the theme's
chapter_opener.htmltemplate (if present) with placeholders. - Wrap the chapter HTML in
<html>+<body>boilerplate, inline the theme CSS, and inject mode-specific overrides:- Continuous — kill
@pageprint constraints, single floating page. - Paged — preserve
@pagerules, inject paged.js polyfill, inject a smallPaged.Handlerthat posts page count back to the parent iframe.
- Continuous — kill
- Return the standalone HTML string.
The output is a complete HTML document. The editor's preview iframe
loads it directly from /api/preview?… and treats it as an isolated
document.
Create a new directory anywhere with at minimum a theme.css. Pass
its path via --theme ./my-theme/ or set theme: ./my-theme/ in
pagewright.yaml. See docs/themes.md.
Add a theme.yaml to your theme:
callouts:
- { label: "Note:", css_class: "callout-note", display: "Note" }
- { label: "Warning:", css_class: "callout-warning", display: "Warning" }For PDF generation or static HTML export, import the renderer directly:
from pathlib import Path
from pagewright.config import load_book_config
from pagewright.theme import load_theme
from pagewright.renderer import render_chapter_html
book = load_book_config(Path("my-book"))
theme = load_theme(book.theme)
out_dir = Path("dist")
out_dir.mkdir(exist_ok=True)
for part in book.parts:
for chapter in part.chapters:
html = render_chapter_html(
book.directory / chapter.file,
book,
theme,
mode="paged", # or "continuous"
color_scheme="light", # or "dark"
)
(out_dir / f"{chapter.file}.html").write_text(html)Pipe each HTML through your favourite print-CSS engine (WeasyPrint, paged.js-cli, Prince) to produce a PDF.
| Method | Path | Purpose |
|---|---|---|
| GET | / |
Editor SPA shell |
| GET | /api/health |
Liveness probe |
| GET | /api/tree |
Book + chapter index |
| GET | /api/file?path=… |
Load markdown source + ETag |
| POST | /api/file |
Atomic save with ETag conflict detection |
| GET | /api/preview?path=&mode= |
Standalone preview HTML for one chapter |
| GET | /theme-asset/<path> |
Serve fonts/images from active theme dir |
| GET | /book-asset/<path> |
Serve files from book directory |
| GET | /static/<path> |
Editor UI bundle (HTML/CSS/JS/icons) |
All routes set Cache-Control: no-store so editor changes land on
next reload without a hard-refresh.
- Markdown render: O(lines), single-pass converter, ~5 ms for a 50 KB chapter on M-series Apple Silicon.
- Preview generation: ~10 ms (markdown → HTML + theme CSS inline).
- paged.js layout: 1–3 s for a typical 4-page chapter; scales roughly linearly with page count.
- Editor side effects (word count, outline rebuild) debounce at 200 ms so 50 KB chapters stay snappy under burst typing.
Flask is the smallest sensible Python web framework. The whole server is ~250 lines. There is no ORM, no async, no background workers, no WebSocket — Pagewright is a single-tenant local tool, not a server application.
If you want to extend it for multi-tenant or networked use, fork it
and replace server.py. The renderer and theme system are the
durable parts; the Flask layer is glue.