HTML Export
This chapter covers the full pipeline from Typst source to a navigable, multi-page HTML website. The key idea: Typst compiles your thesis into a single HTML blob, then a Python script splits it into one page per chapter with sidebar navigation, search, and cross-chapter links.
Dual Entry Points
Your project has two Typst entry points:
main.typ— PDF output. Includes page setup, headers/footers, table of contents, page numbers.main-html.typ— HTML output. Wraps each chapter in achapter-section()call sobuild.pyknows where to split. Applies HTML-specific show rules.
Both files import the same chapter files (chapters/*.typ), so the content is shared.
The difference is in the structural markup around them.
Compiling for HTML:
typst compile --features html --format html \
--input lang=fr --input html=true \
main-html.typ web/dist/full-fr.html
The --features html flag unlocks the target() function, and --input html=true sets
the _is-html flag that chapter files can read without needing --features html
themselves.
html-overrides.typ
This file (in templates/) provides three things:
chapter-section(id, body)
Wraps content in <section class="chapter" id="id"> for HTML; passes through
transparently for PDF. This is the marker that build.py uses to find and split
chapters:
#chapter-section("distribution")[
#include "chapters/distribution.typ"
]
The id must match the section_id in build.py’s CHAPTERS list.
part-marker(id, title)
Optional grouping headers. In HTML, emits a <section class="part"> with an <h1> that
appears in the sidebar as a part heading (e.g., “Part I: Content Distribution”). In PDF,
it does nothing — part pages are handled by the thesis style.
html-show-rules
A set of show rules applied only in HTML mode, using target():
- Headings are converted to semantic
<h1>–<h4>withidattributes derived from their labels. Chapter counters are reset at each<h1>. - Inline math is rendered as SVG via
html.frame()wrapped in abox(so it stays inline). - Block math is rendered as SVG via
html.frame(). - Grids (e.g., side-by-side figures) are rendered as SVG with constrained width
(
42em) to avoid oversized output. - Algorithms (figures of kind
"algorithm") are rendered as SVG at38emwidth to force line wrapping within the HTML content area.
Important caveat about target(): The target() function is only available when
compiling with --features html. Files included by both main.typ and main-html.typ
(i.e., your chapter files and shared templates) cannot call target() directly — it will
error during PDF compilation. Instead, use the _is-html flag from i18n.typ:
#import "i18n.typ": _is-html
#let my-function = if _is-html {
// HTML version
} else {
// PDF version
}
Only html-overrides.typ itself (which is imported exclusively by main-html.typ) may
use target().
Theorem Environments
The file environments.typ provides dual implementations for every theorem type
(theorem, lemma, proposition, corollary, definition, conjecture, remark, etc.):
- PDF mode: uses the
ctheoremspackage (thmbox) with colored fills and borders. - HTML mode: uses
_html-thm()to generate<div class="thm-box thm-theoreme">wrapped in afigure(for labeling and numbering).
Each environment type has a CSS class (e.g., .thm-theoreme, .thm-lemme,
.thm-proposition). The css-class argument in _html-thm() must match a rule in
style.css.
Common gotcha: if you add a new theorem type and forget the CSS rule, the box will render with no background and no border — effectively invisible. Always add both the light and dark theme CSS rules:
.thm-mytype { background: #f0f0f0; border-color: #999; }
[data-theme="dark"] .thm-mytype { background: #1e1e1e; }
build.py — The Build Script
The Python build script (web/build.py) orchestrates the full pipeline. Run it with:
uv run web/build.py # Build both languages
uv run web/build.py --skip-lang en # French only
uv run web/build.py --skip-pdf # Skip PDF compilation
uv run web/build.py --skip-compile # Reuse existing full.html (faster iteration)
The script performs these steps:
- Compile Typst to HTML — runs
typst compile --features html --format htmlfor each language, producing a singlefull-fr.html/full-en.html. - Parse and split — loads the HTML with BeautifulSoup, finds all
<section class="chapter">elements, and maps theiridto the chapter list. - Rewrite cross-chapter links — any
href="#some-id"pointing to an element in a different chapter is rewritten tohref="other-file.html#some-id". - Collect footnotes — Typst emits all footnotes as endnotes in a single section. The script distributes them back to their respective chapter pages.
- Generate pages — each chapter becomes a standalone HTML page with: a sticky topbar, a left sidebar (global navigation), a right sidebar (local table of contents from h2/h3 headings), and prev/next navigation links.
- Generate redirect index —
dist/index.htmldetects the browser language and redirects tofr/oren/. - Copy CSS/JS assets — copies
style.css,nav.js, and any other files fromweb/assets/intodist/assets/. - Run Pagefind — indexes the generated pages for client-side full-text search.
Configuration in build.py
The top of build.py has a configuration section marked with # TODO: adapt comments.
Here is what to customize:
CHAPTERS
A dict keyed by language, each value a list of (section_id, filename, title) tuples:
CHAPTERS = {
"fr": [
("cover", "index.html", "Accueil"),
("distribution", "distribution.html", "Distribution de contenu"),
("bibliography", "bibliography.html", "Bibliographie"),
],
"en": [
("cover", "index.html", "Home"),
("distribution", "distribution.html", "Content Distribution"),
("bibliography", "bibliography.html", "Bibliography"),
],
}
The section_id must match the id in the corresponding chapter-section("id") call in
main-html.typ. If they do not match, the script will print a warning and skip the
chapter.
PARTS
Groups chapters in the sidebar. Each entry is (title_or_none, [list_of_section_ids]):
PARTS = {
"fr": [
(None, ["cover", "introduction"]),
("Distribution", ["distribution", "caching"]),
(None, ["bibliography"]),
],
}
Use None for a flat group (no heading), or a string for a labeled group that renders as
a part header in the sidebar.
SUB_CHAPTERS
A set of section_id values that should appear indented and in a smaller font in the
sidebar:
SUB_CHAPTERS = {"acyclique-origine", "acyclique-bases", "acyclique-convergence"}
This is useful when decomposing a large chapter into multiple pages (see below).
Other settings
THESIS_TITLE— the title shown in the topbar, per language.GITHUB_URL— links to the GitHub icon in the topbar.BASE_URL— overridden by the--base-urlCLI argument; critical for GitHub Pages deployment.
CSS Theming
The file web/assets/style.css uses CSS variables for light and dark modes. The root
:root block defines light theme colors, and [data-theme="dark"] overrides them.
Key conventions:
.thm-*classes define theorem box appearance (background + left border).- SVG frames from
html.frame()get the class.typst-frame. In dark mode, they are inverted withfilter: invert(1) hue-rotate(180deg)to flip black-on-white to white-on-dark while preserving colors. - The layout is a CSS grid with three columns (left sidebar, content, right TOC) that collapses responsively at 1200px and 960px breakpoints.
The file web/assets/nav.js handles theme toggling (light/dark/auto cycle), sidebar
open/close on mobile, Pagefind search modal (lazy-loaded on first open), keyboard
shortcuts (Ctrl+K for search, Escape to close), and scroll-spy highlighting in the local
table of contents.
Decomposing a Large Chapter
If a chapter has natural sub-sections that would each make a long page (like the acyclic preferences chapter in hdr-p2p), you can split it into multiple HTML pages while keeping the single chapter structure in the PDF.
In main-html.typ, use separate chapter-section() calls for each sub-section:
#chapter-section("acyclique")[
#include "chapters/acyclique-html-intro.typ"
]
#chapter-section("acyclique-origine")[
#include "chapters/acyclique-origine.typ"
]
#chapter-section("acyclique-bases")[
#include "chapters/acyclique-bases.typ"
]
In main.typ, include the full chapter as a single unit (the PDF has no splitting).
Then in build.py, list the sub-sections in CHAPTERS and add their IDs to
SUB_CHAPTERS so they render indented in the sidebar. The parent entry
("acyclique") appears as a top-level item, and the sub-entries appear nested below it.
This gives readers a manageable page length in the browser while the PDF retains its natural flow.