Custom Theme
A Shiroa theme is a Typst show rule that receives the current chapter and the book description, then chooses how to render them for native HTML, dyn-paged web output, and PDF or editor preview.
Use Built-in Themes when Starlight or mdBook already provides the layout you need. This chapter describes the contract needed to implement a theme from Typst and HTML primitives.
The generated templates/page.typtemplates/page.typ combines two related layers:
- The project show rule sets fonts, page dimensions, markup, equations, and code style. It is applied for every target.
- The site theme creates HTML chrome such as the head, header, sidebar, and main content container. On paged targets it normally preserves the chapter body.
Keeping these layers separate lets PDF and browser output share typography without requiring the HTML theme to emulate a paged document.
A useful theme signature follows the built-in packages: my-theme(book, body, title: "", description: none, plain-body: auto,extra-assets: ())my-theme(book, body, title: "", description: none, plain-body: auto,extra-assets: ()).
bookbookis normallyinclude "book.typ"include "book.typ". Emitting it makes book metadata available to contextual queries.bodybodyis the chapter content supplied by the enclosing show rule.titletitleanddescriptiondescriptionare page metadata.plain-bodyplain-bodypreserves semantic source content for description generation.extra-assetsextra-assetsis a convenient extension point for CSS and JavaScript.
Shiroa describes a compilation with x-targetx-target:
pdfpdf: Paged output or editor preview.web-lightweb-light,web-ayuweb-ayu, or anotherweb-*web-*value: Paged Typst content rendered for a browser.htmlhtml: Native Typst HTML.html-wrapperhtml-wrapper: HTML chrome that loads a separately renderedweb-*web-*artifact.
The predicates exported by shiroashiroa are safer than repeating prefix checks:
#import "@preview/shiroa:0.4.0": ( is-html-target, is-pdf-target, is-web-target, x-target,)
#import "@preview/shiroa:0.4.0": ( is-html-target, is-pdf-target, is-web-target, x-target,)
is-html-target()is-html-target() includes both htmlhtml and html-wrapperhtml-wrapper. is-html-target(exclude-wrapper: true)is-html-target(exclude-wrapper: true) selects only native HTML. See Rendering Targets and Inputs for the full target and input reference.
The CLI uses the targets as follows:
static-html html -> complete native HTML pagedyn-paged html-wrapper -> HTML chrome and JavaScript loader web-light/web-ayu -> separately compiled paged contentPDF/editor pdf -> normal Typst paged content
static-html html -> complete native HTML pagedyn-paged html-wrapper -> HTML chrome and JavaScript loader web-light/web-ayu -> separately compiled paged contentPDF/editor pdf -> normal Typst paged content
First define page behavior that is useful even without HTML chrome:
#import "@preview/shiroa:0.4.0": ( get-page-width, is-html-target, is-pdf-target, is-web-target,)#let project(title: "", body) = { set page( width: get-page-width(), height: auto, ) if is-pdf-target() or is-web-target() set page( margin: (top: 20pt, bottom: 0.5em, rest: 0pt), ) if is-web-target() set document(title: title) if not is-html-target() body}
#import "@preview/shiroa:0.4.0": ( get-page-width, is-html-target, is-pdf-target, is-web-target,)#let project(title: "", body) = { set page( width: get-page-width(), height: auto, ) if is-pdf-target() or is-web-target() set page( margin: (top: 20pt, bottom: 0.5em, rest: 0pt), ) if is-web-target() set document(title: title) if not is-html-target() body}
Shiroa compiles several responsive widths for dyn-paged output. Respecting get-page-width()get-page-width() prevents the rendered frame from being clipped.
Typst’s html.elemhtml.elem creates native HTML elements. A minimal theme should:
- Return the Typst body unchanged for non-HTML targets.
- Emit
bookbookso its metadata can be queried. - Create
<html><html>,<head><head>, and<body><body>. - Put the current page body in a main content container.
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": ( is-html-target, plain-text, prepare-description,)#let my-theme( book, body, title: "", description: none, plain-body: auto,) = { if not is-html-target() { return body } let plain-body = if plain-body == auto { body } else { plain-body } let description = prepare-description( description, plain-body: plain-body, ) html.elem( "html", { html.elem("head", { html.elem("meta", attrs: (charset: "utf-8")) html.elem( "meta", attrs: ( name: "viewport", content: "width=device-width, initial-scale=1.0", ), ) html.elem("title", plain-text(title)) if description != none { html.elem( "meta", attrs: (name: "description", content: description), ) } }) html.elem("body", { book html.elem("main", { html.elem("h1", title) body }) }) }, attrs: (lang: "en"), )}
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": ( is-html-target, plain-text, prepare-description,)#let my-theme( book, body, title: "", description: none, plain-body: auto,) = { if not is-html-target() { return body } let plain-body = if plain-body == auto { body } else { plain-body } let description = prepare-description( description, plain-body: plain-body, ) html.elem( "html", { html.elem("head", { html.elem("meta", attrs: (charset: "utf-8")) html.elem( "meta", attrs: ( name: "viewport", content: "width=device-width, initial-scale=1.0", ), ) html.elem("title", plain-text(title)) if description != none { html.elem( "meta", attrs: (name: "description", content: description), ) } }) html.elem("body", { book html.elem("main", { html.elem("h1", title) body }) }) }, attrs: (lang: "en"), )}
After saving the theme as my-theme.typmy-theme.typ, apply it from the project show rule:
// Compile this example with Typst's HTML target.#import "my-theme.typ": my-theme#let project( title: "", description: auto, body,) = { show: my-theme.with( include "book.typ", title: title, description: description, plain-body: body, ) body}
// Compile this example with Typst's HTML target.#import "my-theme.typ": my-theme#let project( title: "", description: auto, body,) = { show: my-theme.with( include "book.typ", title: title, description: description, plain-body: body, ) body}
The call is installed with show:show: before bodybody is emitted. This gives the theme both the original semantic body and the body after any inner show rules.
get-book-metaget-book-meta queries <shiroa-book-meta><shiroa-book-meta> and runs a mapper in context. A theme should handle missing metadata so an individual chapter can still be previewed. The mapped result can be placed directly in contextual HTML content:
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": get-book-meta#let book-site-title(meta) = { if type(meta) == array { meta = meta.first() } if meta == none { "" } else if "raw-title" in meta { if meta.raw-title == none { "" } else { meta.raw-title } } else if "title" in meta { if type(meta.title) == str { meta.title } else { meta.title.content } } else { "" }}#let site-title() = get-book-meta(mapper: book-site-title)#html.elem("header", context site-title())
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": get-book-meta#let book-site-title(meta) = { if type(meta) == array { meta = meta.first() } if meta == none { "" } else if "raw-title" in meta { if meta.raw-title == none { "" } else { meta.raw-title } } else if "title" in meta { if type(meta.title) == str { meta.title } else { meta.title.content } } else { "" }}#let site-title() = get-book-meta(mapper: book-site-title)#html.elem("header", context site-title())
Useful book metadata includes:
titletitleandraw-titleraw-titledescriptiondescriptionauthorsauthorslanguagelanguagerepositoryrepositoryandrepository_editrepository_editdiscorddiscordsummarysummary
The converted summarysummary is an array. Each element has a kindkind:
chapterchapter: Containslinklink,titletitle,sectionsection, and optionallysubsub.partpart: Contains a parttitletitle.dividerdivider: Requests a visual separator.partbreakpartbreak: Ends the current named group.
The following renderer covers parts, chapters, draft chapters, dividers, and nested subsub arrays. It uses cross-link-path-labelcross-link-path-label to turn .typ.typ sources into .html.html URLs and prefixes them with x-url-basex-url-base for subpath deployments.
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": ( cross-link-path-label, get-book-meta, x-url-base,)#let summary-title(item) = { if "raw-title" in item { item.raw-title } else { item.title.content }}#let chapter-href(path) = { let path = cross-link-path-label("/" + path) if path.starts-with("/") { path = path.slice(1) } x-url-base + path}#let render-summary(items) = html.elem("ul", { for item in items { if item.kind == "part" { html.elem("li", summary-title(item), attrs: (class: "part-title")) } else if item.kind == "chapter" { html.elem("li", { let title = summary-title(item) if item.link == none { html.elem("span", title, attrs: (aria-disabled: "true")) } else { html.elem("a", title, attrs: (href: chapter-href(item.link))) } if "sub" in item { render-summary(item.sub) } }) } else if item.kind == "divider" { html.elem("li", attrs: (class: "divider")) } }})#let sidebar() = get-book-meta(mapper: meta => { if type(meta) == array { meta = meta.first() } if meta == none { [] } else { html.elem("nav", render-summary(meta.summary)) }})
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": ( cross-link-path-label, get-book-meta, x-url-base,)#let summary-title(item) = { if "raw-title" in item { item.raw-title } else { item.title.content }}#let chapter-href(path) = { let path = cross-link-path-label("/" + path) if path.starts-with("/") { path = path.slice(1) } x-url-base + path}#let render-summary(items) = html.elem("ul", { for item in items { if item.kind == "part" { html.elem("li", summary-title(item), attrs: (class: "part-title")) } else if item.kind == "chapter" { html.elem("li", { let title = summary-title(item) if item.link == none { html.elem("span", title, attrs: (aria-disabled: "true")) } else { html.elem("a", title, attrs: (href: chapter-href(item.link))) } if "sub" in item { render-summary(item.sub) } }) } else if item.kind == "divider" { html.elem("li", attrs: (class: "divider")) } }})#let sidebar() = get-book-meta(mapper: meta => { if type(meta) == array { meta = meta.first() } if meta == none { [] } else { html.elem("nav", render-summary(meta.summary)) }})
Add x-currentx-current when the theme needs an active-page class or aria-currentaria-current. Official sidebar implementations also group chapters around partbreakpartbreak and apply collapsible behavior, but those features are not required by the theme contract.
html-support.inline-assetshtml-support.inline-assets transforms rawraw blocks with language csscss or jsjs into HTML assets. This is the simplest extension point for one theme:
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: inline-assets#let theme-css = ```css:root { color-scheme: light dark;}body { margin: 0; font-family: system-ui, sans-serif;}```#let my-head(extra-assets: ()) = html.elem("head", { inline-assets((theme-css, ..extra-assets).join())})
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: inline-assets#let theme-css = ```css:root { color-scheme: light dark;}body { margin: 0; font-family: system-ui, sans-serif;}```#let my-head(extra-assets: ()) = html.elem("head", { inline-assets((theme-css, ..extra-assets).join())})
Pass a one-element tuple with a trailing comma:
// Compile this example with Typst's HTML target.#import "my-theme.typ": my-theme#show: my-theme.with( include "book.typ", extra-assets: ( ```css main { max-width: 72rem; } ```, ),)
// Compile this example with Typst's HTML target.#import "my-theme.typ": my-theme#show: my-theme.with( include "book.typ", extra-assets: ( ```css main { max-width: 72rem; } ```, ),)
Only include scripts controlled by the book or theme. inline-assetsinline-assets embeds their source as a data URL; it does not sanitize JavaScript.
For a theme split across multiple component files, use the shared asset state:
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: add-styles, inline-assets, shiroa-assets// A component registers its styles.#add-styles(```css.callout { border-inline-start: 0.25rem solid royalblue; }```)// The theme head emits all registered styles once.#let collected-assets() = inline-assets(context ( ..shiroa-assets.final().values(),).join())
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: add-styles, inline-assets, shiroa-assets// A component registers its styles.#add-styles(```css.callout { border-inline-start: 0.25rem solid royalblue; }```)// The theme head emits all registered styles once.#let collected-assets() = inline-assets(context ( ..shiroa-assets.final().values(),).join())
add-scriptsadd-scripts and add-assetsadd-assets use the same state. The raw text is the state key, so registering the same block more than once does not duplicate it.
virt-slotvirt-slot inserts a named placeholder and set-slotset-slot returns a show rule that replaces it. Slots are helpful when a theme shell is composed from independent header, sidebar, and content files.
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: set-slot, virt-slot#let shell(body) = { show: set-slot("main-content", body) html.elem("body", { html.elem("header", virt-slot("site-header")) html.elem("main", virt-slot("main-content")) })}#show: set-slot("site-header", [My Book])#shell[Chapter content]
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: set-slot, virt-slot#let shell(body) = { show: set-slot("main-content", body) html.elem("body", { html.elem("header", virt-slot("site-header")) html.elem("main", virt-slot("main-content")) })}#show: set-slot("site-header", [My Book])#shell[Chapter content]
Slot names are an agreement inside a theme. Prefix them, for example my-theme:headermy-theme:header, if third-party components can register slots too.
A dyn-paged page needs two things in its html-wrapperhtml-wrapper phase:
- The browser renderer runtime.
paged-load-trampoline()paged-load-trampoline(), which asks that runtime to load the paged artifact forx-currentx-currentbelowx-url-basex-url-base.
The public trampoline is imported directly:
#import "@preview/shiroa:0.4.0": paged-load-trampoline, x-target#let main-content(body) = { if x-target.starts-with("html-wrapper") { paged-load-trampoline() } else { body }}
#import "@preview/shiroa:0.4.0": paged-load-trampoline, x-target#let main-content(body) = { if x-target.starts-with("html-wrapper") { paged-load-trampoline() } else { body }}
The current 0.4.0 runtime loader is available through the advanced HTML support module used by the built-in mdBook theme:
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: supports-html-internal#import supports-html-internal: dyn-svg-support// Emit inside the HTML head for an html-wrapper page.#dyn-svg-support()
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": html-support#import html-support: supports-html-internal#import supports-html-internal: dyn-svg-support// Emit inside the HTML head for an html-wrapper page.#dyn-svg-support()
supports-html-internalsupports-html-internal is not a stable theme API. A custom theme that must remain compatible across Shiroa versions can use mdBook for dyn-paged output and apply its own theme only to native HTML. If the custom theme owns the dyn-paged wrapper, pin its Shiroa package version and test the loader when upgrading.
This skeleton combines native HTML, dyn-paged main content, metadata, and custom assets. It intentionally leaves the sidebar renderer separate so a project can choose its own navigation structure.
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": ( html-support, is-html-target, paged-load-trampoline, plain-text, prepare-description, x-target,)#import html-support: inline-assets, supports-html-internal#import supports-html-internal: dyn-svg-support#let base-css = ```cssbody { margin: 0; font-family: system-ui, sans-serif;}.layout { max-width: 72rem; margin-inline: auto; padding: 2rem;}```#let my-theme( book, body, title: "", description: auto, plain-body: auto, extra-assets: (),) = { if not is-html-target() { return body } let plain-body = if plain-body == auto { body } else { plain-body } let description = prepare-description( description, plain-body: plain-body, ) let main = if x-target.starts-with("html-wrapper") { paged-load-trampoline() } else { body } html.elem("html", { html.elem("head", { html.elem("meta", attrs: (charset: "utf-8")) html.elem( "meta", attrs: ( name: "viewport", content: "width=device-width, initial-scale=1.0", ), ) html.elem("title", plain-text(title)) if description != none { html.elem( "meta", attrs: (name: "description", content: description), ) } if x-target.starts-with("html-wrapper") { dyn-svg-support() } inline-assets((base-css, ..extra-assets).join()) }) html.elem("body", { book html.elem("div", { html.elem("header", html.elem("h1", title)) html.elem("main", main) }, attrs: (class: "layout")) }) }, attrs: (lang: "en"))}#let project(title: "", description: auto, body) = { show: my-theme.with( include "book.typ", title: title, description: description, plain-body: body, ) body}
// Compile this example with Typst's HTML target.#import "@preview/shiroa:0.4.0": ( html-support, is-html-target, paged-load-trampoline, plain-text, prepare-description, x-target,)#import html-support: inline-assets, supports-html-internal#import supports-html-internal: dyn-svg-support#let base-css = ```cssbody { margin: 0; font-family: system-ui, sans-serif;}.layout { max-width: 72rem; margin-inline: auto; padding: 2rem;}```#let my-theme( book, body, title: "", description: auto, plain-body: auto, extra-assets: (),) = { if not is-html-target() { return body } let plain-body = if plain-body == auto { body } else { plain-body } let description = prepare-description( description, plain-body: plain-body, ) let main = if x-target.starts-with("html-wrapper") { paged-load-trampoline() } else { body } html.elem("html", { html.elem("head", { html.elem("meta", attrs: (charset: "utf-8")) html.elem( "meta", attrs: ( name: "viewport", content: "width=device-width, initial-scale=1.0", ), ) html.elem("title", plain-text(title)) if description != none { html.elem( "meta", attrs: (name: "description", content: description), ) } if x-target.starts-with("html-wrapper") { dyn-svg-support() } inline-assets((base-css, ..extra-assets).join()) }) html.elem("body", { book html.elem("div", { html.elem("header", html.elem("h1", title)) html.elem("main", main) }, attrs: (class: "layout")) }) }, attrs: (lang: "en"))}#let project(title: "", description: auto, body) = { show: my-theme.with( include "book.typ", title: title, description: description, plain-body: body, ) body}
This version emits the current 0.4.0 renderer runtime only for html-wrapperhtml-wrapper. Because that import is advanced, pin the Shiroa package version and test it when upgrading. For a native-HTML-only custom theme, remove the internal import, select the theme with is-html-target(exclude-wrapper: true)is-html-target(exclude-wrapper: true), and use mdBook for all other phases.
Before publishing a custom theme, verify that it:
- Returns readable Typst content for PDF and
web-*web-*targets. - Uses
get-page-width()get-page-width()for responsive paged layouts. - Distinguishes
htmlhtmlfromhtml-wrapperhtml-wrapperwhere necessary. - Emits the
bookbookcontent so metadata queries and the sidebar work. - Handles missing book and site titles.
- Produces a non-empty HTML
<title><title>when a page title is available. - Escapes content through
html.elemhtml.eleminstead of assembling HTML strings. - Prefixes site-local links and assets with
x-url-basex-url-base. - Uses
x-currentx-currentfor current-page state and repository edit links. - Includes alternative content for media that cannot render in PDF.
- Emits registered assets exactly once.
- Builds with both
shiroa buildshiroa buildandshiroa build --mode static-htmlshiroa build --mode static-htmlif it claims to support both output families.