HTML site

The [build.html] section configures the static HTML site produced by ubc build html: which extra stylesheets to load, what the site is called, which logo to show, the footer’s copyright line, and whether to build the site’s search index.

Warning

ubc build html is in alpha. The command works and is under active development, but the emitted markup, the theming surface described here, and the command’s options may still change before it is announced as stable, so it is not yet recommended for production builds.

For a guide to running it, see Building a static HTML site.

Minimal example

[build.html]
title = "My Project Docs"
logo = "_static/logo.svg"
extra_css = ["_static/theme.css"]

Options

deny

Type: string (optional, default: "warning")

The build’s quality bar: exit 1 when any finding at or above this level survives [lint] narrowing — "none", "info", "warning" or "error". One bar covers both counts the command reports (index diagnostics and render-phase build warnings), and the site is still written before the exit code is decided. The --deny command-line flag overrides the key for one run; "none" never fails the threshold (an explicit --max-warnings budget still applies). See Building for production for the full story.

extra_css

Type: array of string (optional, default: [])

Stylesheets to copy into the site and link on every page.

Each path is resolved relative to the ubproject.toml that declares it (or may be absolute). Every file is copied into the site’s _static/ directory and linked after the built-in stylesheet and after any feature bundle (syntax highlighting, copy buttons, math typesetting), in the order listed — so a later file overrides an earlier one, and any of them overrides the built-in defaults.

Two files with the same name are both copied, under distinct names (custom.css, custom1.css, …), and a file listed twice is copied and linked once.

A file that cannot be read is reported as the build warning build.extra_css_read_failed and skipped; the rest of the site still builds, and the pages still link it — so the failure is visible in the browser rather than silently dropped.

Note

If the stylesheet lives inside your source directory, exclude it from source discovery (see source) so ubCode does not try to parse it as a document.

An extending configuration’s list is replaced whole, not merged — a child that sets extra_css at all sets the complete list.

extra_static

Type: array of string (optional, default: [])

Files and directories to copy into the site’s _static/ directory.

This is for the files a stylesheet references — background images, fonts, icons — rather than for the stylesheets themselves. Publishing them here means a url(...) inside an extra_css sheet resolves against a file that is actually there. Nothing listed here is linked from the pages.

Each path is resolved relative to the ubproject.toml that declares it (or may be absolute).

A listed directory has its contents copied, preserving their structure relative to that directory:

[build.html]
extra_static = ["assets"]
extra_css = ["assets/css/brand.css"]
assets/css/brand.css   ->   _static/css/brand.css
assets/img/bg.png      ->   _static/img/bg.png
assets/fonts/a.woff2   ->   _static/fonts/a.woff2

so brand.css can write url(../img/bg.png) and it resolves. A listed file lands flat at _static/{filename}, the same mapping extra_css uses.

Inside a listed directory, dot-prefixed files and directories (.DS_Store, .git, .gitkeep) are skipped, and symlinked directories are not descended into. Listing such a path as an entry in its own right publishes it — naming a path explicitly is consent, which is how a .nojekyll file or a symlinked shared brand directory can be published on purpose.

A stylesheet that lives inside a listed directory — assets/css/brand.css above — is copied once for that directory, at its structure-preserving destination, and that is where the page’s <link> points.

The match is on the path as written, so an equivalent but differently-spelled path (assets/../assets/css/brand.css) is not recognised and the sheet falls back to its flat destination. That fallback has a visible consequence either way:

  • if the sheet sits at the root of the listed directory, the flat destination is the one the tree copy wanted, so the tree copy is refused with build.extra_static_collision — which, under the default deny bar, fails the build;

  • if the sheet sits in a subdirectory, the two destinations differ, so the sheet is published twice with no warning at all and the page links the copy whose url(...) cannot resolve.

Spell the two keys’ paths the same way and neither arises.

Note

Entries that overlap — one listed directory nested inside another — are each walked from their own root, so content they share is published under both entries’ destinations. extra_static = ["assets", "assets/css"] publishes everything under assets/css/ twice, at _static/css/… and at _static/…. This is not reported as a collision (the destinations differ); list the outer directory alone unless you want both copies.

Three build warnings can arise, all non-fatal and all narrowable through [lint]:

build.extra_static_collision

A file’s destination was already claimed — by a built-in site asset, by an extra_css / logo / favicon copy, or by an earlier entry — so that file was skipped. It is never renamed and never overwritten: the destination name is what your own CSS refers to, so a silent rename would defeat the key. Names are compared ignoring case, because on macOS and Windows BG.png and bg.png are one file.

build.extra_static_read_failed

An entry, or one file inside a listed directory, could not be read — or a member’s name is not valid UTF-8, which no published destination could represent, so the member is skipped. Its siblings are still copied.

build.extra_static_invalid_entry

An entry resolves inside the output directory, which would publish the site into itself.

Under the default deny bar ("warning", described above), any of these fails the build.

Note

If a listed directory lives inside your source directory, exclude it from source discovery (see source) so ubCode does not try to parse its files as documents — a tree containing .md or .rst files otherwise becomes part of the site’s page set.

An extending configuration’s list is replaced whole, not merged — a child that sets extra_static at all sets the complete list.

title

Type: string (optional, default: "")

The site title.

It is shown as the sidebar heading and appended to each page’s browser title as page title site title. A page whose own title already equals the site title (typically the root document) is left unsuffixed.

When set, it overrides [project].name as the sidebar heading. Unset — or the empty string, which means the same thing — falls back to [project].name for the heading and leaves page titles unsuffixed.

logo

Type: string (optional, default: "")

An image file to show above the sidebar heading.

The path is resolved relative to the declaring ubproject.toml (or may be absolute), and the file is copied into the site’s _static/ directory. It is centred in the sidebar column and scaled to fit inside it (see .ub-brand-logo below to change either).

A file that cannot be read is reported as the build warning build.logo_read_failed and skipped. Unset — or the empty string — means no logo.

logo_dark

Type: string (optional, default: "")

A dark-theme variant of logo.

Shown instead of logo while the site renders in its dark theme — whether the reader chose it with the theme toggle or their system prefers it. When both images are set, printing uses logo. Resolved and copied exactly like logo (an unreadable file is the same build.logo_read_failed warning), and meant to be set together with it (mirroring furo’s light_logo / dark_logo pair); set alone, it behaves like logo and is used everywhere, including print. Unset — or the empty string — means logo (when set) is used in both themes.

favicon

Type: string (optional, default: "")

The site favicon — the icon browsers show in the tab (.ico, .svg, .png, …).

The path is resolved relative to the declaring ubproject.toml (or may be absolute); the file is copied into the site’s _static/ directory and linked as <link rel="icon"> from every page. A file that cannot be read is reported as the build warning build.favicon_read_failed and skipped. Unset — or the empty string — means no favicon.

hide_title

Type: boolean (optional, default: false)

Declare that the logo artwork already contains the site name, so the title text is not repeated beside it — logo-only branding wherever the shell brands the site (today: the sidebar heading; furo’s sidebar_hide_name).

Browser titles and the narrow-viewport header keep the title, and assistive technology still hears the site name (it becomes the logo image’s alternative text). With no logo configured either, the sidebar shows no heading at all — including its link back to the root page.

copyright

Type: string (optional, default: "")

The site copyright statement.

It is shown in each page’s footer as Copyright © {value} — for example, the value "2026, team useblocks" renders as Copyright © 2026, team useblocks. The footer only exists on pages with the site shell (a sidebar and/or an “On this page” column), so a page without either shows no copyright line. Unset — or the empty string — means no copyright line; the footer’s “Built with ubc” attribution is then the row’s only content, unless hide_attribution below removes that too.

hide_attribution

Type: boolean (optional, default: false)

Hide the footer’s “Built with ubc” attribution line.

[build.html]
hide_attribution = true

It removes that line and nothing else. The footer, its divider, and the copyright line (when you set one) all stay, and so does each page’s <meta name="generator" content="ubc"> — this is not a de-branding switch.

When the footer’s bottom row would then be left with no content at all — no copyright, no footer_icons — the row is not drawn, rather than leaving a bare divider and a band of empty space at the end of every page. If the page also has no previous/next links, the <footer> element itself is omitted.

Migrating from Sphinx, the polarity is inverted: html_show_sphinx = False becomes hide_attribution = true. The name is hide-shaped to match hide_title in this same table. furo has no equivalent key of its own. The show_sphinx its template reads is Sphinx core’s html_show_sphinx surfaced to the theme, and it removes only the Sphinx half of furo’s footer line; furo’s own name cannot be removed at all — so there is nothing to port from a furo configuration.

footer_icons

Type: list of tables (optional, default: [])

Icon links shown at the right of every page’s footer row.

Each entry is a table with four optional string keys:

html

The icon markup. It is inserted into the page as raw HTML, not escaped — see the warning below.

url

The link target, used exactly as written, so an external link needs its scheme.

name

The link’s accessible label. An icon has no text of its own, so this is what a screen reader announces for the link.

class

Extra CSS classes for the link, added to the built-in ub-footer-icon.

[[build.html.footer_icons]]
name = "Website"
url = "https://example.com"
html = """<svg viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>"""

Use an inline SVG drawn with currentColor. An svg whose fill or stroke is currentColor takes the footer’s own text colour, and so follows both the light and the dark theme with no styling of your own — that is the whole dark-mode story for this key. The stylesheet sizes an svg or img here to one line of text. A class on its own, with no html, works too, for an icon font shipped through extra_css.

Entries that cannot render are reported as the build warning build.footer_icon_invalid — one warning per faulty entry, naming its position in the list. There are two tiers:

  • skipped — an entry with no url (the link would lead nowhere), or with neither html nor class (it would draw nothing visible). With every entry skipped, no icons row is emitted at all.

  • rendered, unlabelled — an entry with no name. The link still works; it simply carries no label for assistive technology.

Under the default deny bar ("warning", described above), either tier fails the build — so a furo list ported across with no name on an entry needs a name adding, or the code silencing under [lint].

url, name and class are escaped before they are written into their HTML attributes.

An extending configuration replaces the whole list rather than adding to it. Unset or empty means no icons.

The key, the item shape and the raw html semantics are furo’s footer_icons, so a furo list ports across unchanged — though, per the bar above, add a name to each entry (or silence build.footer_icon_invalid) to clear the default deny bar with it.

announcement

Type: string (optional, default: "")

A site-wide banner shown at the top of every page.

[build.html]
announcement = "This documents a pre-release. <a href='stable/'>Stable docs</a>."

The value is inserted into the page as raw HTML, not escaped — see the warning below — so the banner can carry a link or emphasis.

The banner is a single, non-sticky line: it scrolls away with the page rather than following it. Keep it to one short sentence — it is one line tall, so block markup is clipped, and a value too long for the viewport can be scrolled towards its end but not always back to its start.

It is not hidden when the page is printed, though whether its colours print depends on the reader’s browser printing background graphics (most do not by default, which leaves the pale text on white paper).

Unset — or the empty string — means no banner.

Its two colours are the --ubc-announcement-bg and --ubc-announcement-fg custom properties, which have the same value in both themes (see Custom properties); a banner is a deliberate interruption, and one that followed the theme would stop reading as one.

This is furo’s announcement, verbatim, so a furo value ports across unchanged.

Warning

announcement and the html key of footer_icons are the only two values ubCode writes into a built page without escaping them. Everything else the page wrapper writes — title, copyright, the icons’ url / name / class, every asset link — is escaped.

Two consequences are worth knowing. A malformed fragment (an unclosed tag, say) reaches every page in the site as written, with no warning. And anything you put there runs with the same trust as the rest of the page, exactly as content written with the raw directive already does (see raw_enabled). Both keys are project configuration, so this is your own markup on your own site.

source_repository

Type: string (optional, default: "")

The URL of the repository holding this project’s source files.

Set it together with source_branch and every page written from a source file gains a view this page and an edit this page button above its content, each linking to that page’s own source.

Two kinds of page show neither, both by design and neither reported as a problem: the generated search.html results page has no source file of its own, and a page with no navigation and no sub-sections carries no page chrome at all.

[build.html]
source_repository = "https://github.com/useblocks/sphinx-needs"
source_branch = "master"
source_directory = "docs/"

Three hosts are recognised, and only over https: https://github.com/…, https://gitlab.com/… and https://bitbucket.org/…. A trailing / is optional, and so is a trailing .git — the URL git clone prints works as written. For any other forge — a self-hosted GitLab, Gitea, Codeberg, sourcehut, Azure DevOps — use source_view_link / source_edit_link below, which are never checked against that list.

A repository set with no source_branch, set to an http:// URL, or set to a host outside those three draws no button and is reported as the build warning build.source_link_invalid. A missing button is deliberate: a button that leads nowhere is worse than none, and the warning is the signal to you rather than to your readers.

Unset — or the empty string — means no buttons, which is the default and adds nothing at all to a built page.

These are furo’s five source_* theme options, verbatim, so an html_theme_options block ports across by copy. One behaviour of furo’s is not reproduced: where furo would render a dead button (or, with no branch, a link to a 404) ubCode omits the button and says so.

source_branch

Type: string (optional, default: "")

The branch or tag the source links point at — typically main or master.

[build.html]
source_branch = "main"

Read together with source_repository. It is not consulted by source_view_link / source_edit_link, which carry their own targets, and it does nothing on its own — without a repository there is no URL for it to be part of. Set with neither a repository nor a link template it can reach nothing, and is reported as build.source_link_invalid; a repository set with no branch is reported the same way, for whichever button no template covers.

Unset — or the empty string — means no branch.

source_directory

Type: string (optional, default: "")

The path from the repository root down to this project’s source directory.

[build.html]
source_directory = "docs/"

Prefixed to each page’s source path when the URL is built from source_repository, so a project whose ubproject.toml lives in docs/ of its repository sets docs/ here. A leading or trailing / is optional.

Important

It applies to the source_repository construction only. A source_view_link / source_edit_link template receives the page path without this prefix.

The asymmetry is furo’s, and it is reproduced deliberately so that a template pasted out of a working conf.py keeps producing the URLs it produced there. A template that needs the prefix should simply spell it out — https://example.com/view/docs/{filename}.

Set with neither a repository nor a link template it can reach nothing, and is reported as build.source_link_invalid. Unset — or the empty string — means the sources sit at the repository root.

source_view_link

Type: string (optional, default: "")

A URL template for the view this page button.

[build.html]
source_view_link = "https://git.example.com/docs/-/blob/main/{filename}"

Overrides the source_repository construction for this one button, and works on its own with no repository set — which is how a forge outside the three recognised hosts is reached.

Every {filename} in the value is replaced by the page’s source path relative to the project’s source directory, suffix included (filter.rst, topics/intro.md); source_directory is not prefixed to it (see above). The value is used as written and is not checked, so a template that points somewhere unexpected produces a button that does.

Unset — or the empty string — falls back to the source_repository construction.

source_edit_link

Type: string (optional, default: "")

A URL template for the edit this page button.

[build.html]
source_edit_link = "https://git.example.com/docs/-/edit/main/{filename}"

The source_view_link twin, with the same {filename} substitution and the same freedom from host checking. The two keys are independent: setting one leaves the other to the source_repository construction, so a project can override just the button its forge spells differently.

Unset — or the empty string — falls back to the source_repository construction.

redirects

Type: table of stringstring (optional, default: {})

Where pages went when they moved, so a reader holding an old link still arrives.

[build.html.redirects]
"ubc/introduction" = "basics/what_is_ubcode"
"ubc/installation" = "basics/installation"

Each key is the path of a page that no longer exists, and each value is the page it became. The build writes a small forwarding page where the old one used to be, so ubc/introduction.html still resolves — it carries the reader on to the new page, keeping any ?query and #fragment they arrived with that the target does not set itself.

Keys and targets are written the way a toctree entry is: /-separated and relative to the source directory, with a leading / and a source suffix (.rst, .md, …) both optional and both ignored, so "/guide/setup.md" and "guide/setup" are the same page. A target may name a section of its page ("guide/new#install"), or be an external http:// / https:// URL, which is used exactly as written. Any other URL scheme — javascript:, data:, mailto:, a protocol-relative //host/… — is refused, not escaped.

A key may not name a section: a URL fragment is never sent to a server, so a forwarding page cannot tell one fragment from another. Such an entry is refused with build.redirect_invalid rather than accepted and quietly ignored.

Redirects chain: if a points at b and b points at c, a reader arriving at a is sent straight to c, in one hop.

Note

Without JavaScript, the forwarding page falls back to a meta refresh, which reaches the target page but cannot carry a query string or a fragment — a refresh URL is static, so the reader’s own is not merged into it.

Four build warnings report an entry that cannot be used. Each names the entry, skips it, and leaves the rest of the table building:

build.redirect_invalid

A key or target that cannot be read as a page path (empty, a .. segment, a trailing /, a drive letter), a target whose URL scheme is not allowed, a key naming a section, or two keys that name the same page once the spellings are normalised.

build.redirect_source_exists

The key names a page the build still renders. That page keeps its own content and no forwarding page is written — remove whichever of the two is the leftover.

build.redirect_target_missing

The chain ends at a document the build renders no page for. Nothing is written: a forwarding page can only lead somewhere that works.

build.redirect_cycle

The entries redirect in a loop. Every entry in the loop — and any entry pointing into it — is skipped, because a reader following one would never arrive. The rest of the table still builds and the site is still written.

Under the default deny bar ("warning", described above), any of these fails the build.

Removing an entry removes its forwarding page from the output on the next build, along with any directory it emptied — in an output directory the build owns. (An output directory ubCode did not create removes nothing at all; see Building for production.)

An extending configuration’s table is replaced whole, not merged — a child that sets redirects at all sets the complete table.

sd_tabs_storage_prefix

Type: string (optional, default: "ubc-tab-id-")

The browser-storage key prefix under which a reader’s tab selection is remembered.

A tab-item with a :sync: key records the reader’s choice, so picking (say) a language in one tab set selects it in every other set on the page — and keeps it on the next page and the next visit.

[build.html]
sd_tabs_storage_prefix = "acme-tab-"

Set it to an empty string to switch the remembering off: tabs still switch, and still sync within a page, but nothing is written to browser storage and every page starts on its default tab. An empty string is a real setting here, distinct from leaving the key unset.

The value is delivered to the browser as an attribute on the tab script that a page with tabs links, so it shapes the built site only; the editor preview runs no script and is unaffected either way.

It corresponds to sphinx-design’s sd_tabs_storage_prefix, whose default is sphinx-design-tab-id-. The defaults differ deliberately: two builds of the same documentation served from one domain would otherwise read and overwrite each other’s remembered choices. Set both sides to the same value if you want them shared.

search.enabled

Type: boolean (optional, default: true)

Whether to build the site’s search index and ship its search interface — see Search for what that gives you.

[build.html.search]
enabled = false

Setting it to false removes the sidebar search box, the search.html results page and the index file from the site, and skips the indexing work during the build. Rebuilding an existing site after disabling search also deletes the files an earlier build wrote, so nothing stale is left behind.

Turn it off for a very large project whose index file has grown impractical (see the size warning below), or for a site that is only ever read through a host with its own search.

plantuml.*

The external PlantUML renderer — off by default. See External PlantUML renderer below for the whole surface, the requirements and the trade-offs, and Sharing the renderer between builders for naming the executable once for every builder.

[build.html.plantuml]
enabled = true
command = ["java", "-jar", "{config_dir}/libs/plantuml.jar"]

Built-in page behaviour

Beyond the options above, every page with the site shell also carries:

  • a footer at the end of the content column: the previous/next links, then a divider carrying the copyright line (when configured) and a muted “Built with ubc” attribution (unless hide_attribution removes it), with the footer_icons row opposite them. When that row would hold nothing at all it is not drawn, and a footer left with neither that row nor previous/next links is omitted entirely;

  • a back-to-top button — a small pill that appears at the top centre whenever you scroll back up, anywhere beyond the very top of the page (and drops below the narrow-viewport header where that is shown);

  • a skip-to-content link for keyboard users: the first tab stop on the page, hidden until focused, jumping straight to the main content;

  • print styling that hides the navigation chrome (the drawers, header, previous/next links and floating buttons) while keeping the content and the copyright line.

The responsive breakpoints follow furo’s ladder: the “On this page” column folds into an overlay drawer below 82em, the sidebar (and, for most pages, the narrow-viewport header) below 63em, and on very wide screens (97em and up) the whole page scales up ~10%. A page with no sidebar shows the header from 82em already, where its “On this page” column folds away.

Unrendered directives, roles and diagram files

Added in version 0.31.0.

Directives

ubCode renders a known set of directives with their real semantics and every other one as a readable generic placeholder — a labelled frame that still shows the directive’s name, options and rendered body. A directive ubCode does not recognise at all is reported as block.directive_unknown, but a directive it does recognise and simply has no renderer for — contents, acks, and friends — used to render as a placeholder with no signal at all.

The build now reports those, aggregated per directive name for the whole build:

$ ubc build html -w
Found 2 build warning(s).
warning[build.directive_unhandled]
  acks: 47 sites rendered as placeholders — no handler for this directive yet
  (first api/core:118, api/core:204, guide:31)
warning[build.directive_unhandled]
  contents: 12 sites rendered as placeholders — no handler for this directive yet
  (first api/index:12, guide:8, tables:6)

One finding per directive name, most-used first, with the total number of placeholder sites and the first few distinct locations of them — so the output stays a handful of lines however many times a large project uses such a directive.

The count is what carries the multiplicity; the locations only tell you where to start looking, so a repeated one is listed once, and a (N shown) tail says when fewer are listed than there are sites. A site inside spliced content is named by the file it is written in, when there is one: a site pulled in through an .. include:: names the fragment, at the fragment’s own line, with the including page beside it as an included-from location, and a site inside a card shown by a needextract names the need’s own document. A site whose content has no file of its own — rst_prolog / rst_epilog, an imported need, a generated report — names the page, labelled with the source it came from, because a finding with no location at all could be neither silenced nor navigated to.

The site is still complete (the placeholders are the same honest output as before), and like every other build.* warning its detail is shown by --show-warnings / -w or by a run that fails the quality gate. It is not exempt from that gate: under the default bar an unhandled directive is enough to exit 1, and lint configuration is how you silence it.

Two ways to read it:

  • as a coverage signal — if a directive you rely on appears here, its own rendering is not implemented yet, and the frame is what readers see;

  • as a regression guard — a name appearing (or a count jumping) after an upgrade or a content change is worth a look.

Roles

Roles get the same treatment under build.role_unhandled. A role with no renderer is shown as a chip — a small bordered box carrying the role name and its text, so it is visible rather than silently indistinguishable from plain text — and each name is reported once for the whole build:

warning[build.role_unhandled]
  mailheader: 3 sites rendered as chips — unhandled or unrecognised role
  (first guide:4, index:4, index:6)
warning[build.role_unhandled]
  token: 1 site rendered as a chip — unhandled or unrecognised role
  (at guide:4)

The wording is deliberately vaguer than the directive one. ubCode can prove a directive is recognised but unrendered, because directive names are resolved against a specification while parsing. Role names are not, so this one warning covers two cases — a role ubCode has not implemented yet, and a misspelt role name. Both look the same in the output and both render as a chip, so if a name here is one you did not expect, check the spelling first.

A role your project declared with parse.extend_roles is reported too: declaring a name tells ubCode the role exists, but it does not give ubCode a way to render it — so the chip, and this warning, are both accurate. The same is true of parse.extend_directives names on the directive warning above; if you do not want to see them, silence them by name as shown below.

Cross-references are not reported here. A :ref: or :doc: whose target does not exist also renders as a chip, but those roles are implemented — the chip means the target was missing, not the role. They are excluded so that broken links never appear under a “role not implemented” heading.

Diagram files

A diagram directive can take its source from a file instead of an inline body — .. uml:: flow.puml, .. mermaid:: flow.mmd, or the MyST fence {mermaid} flow.mmd — and the build renders that file exactly as it renders an inline diagram. The path follows the same rule as images: a leading / is relative to your source directory, anything else to the directory of the document using it.

When the file cannot be used, the diagram falls back to the same placeholder and the build says so, aggregated per file path:

warning[build.diagram_file_unresolved]
  diagrams/flow.puml: 4 sites rendered as placeholders — the diagram file could
  not be read (missing, unreadable, not a local file, or empty)
  (first architecture:31, architecture:88, overview:12)
warning[build.diagram_unconvertible]
  diagrams/wire.puml: 1 site rendered as a placeholder — the diagram file was
  read but its PlantUML could not be converted to Mermaid (at ui/wireframes:44)
warning[build.plantuml_render_failed]
  uml: 12 sites rendered as Mermaid conversions — the PlantUML executable did
  not draw them, or drew SVGs ubCode will not inline
  (e.g. diagrams/flow.puml: Error line 2) (3 shown)

The three codes are separate because the fix is different: the first is about the path or the file, the second means the file was read fine but ubCode’s PlantUML-to-Mermaid conversion cannot express that diagram, and the third only appears when you have configured an external PlantUML renderer — the file was read and PlantUML was asked, and it is PlantUML or the diagram that needs attention.

That third code aggregates per directive name rather than per file path, and deliberately: a render failure is nearly always one cause — an unreachable !include convention, a pragma the SVG rules refuse, a broken PlantUML install — so keying it by path would turn one cause into one warning per file. The path is not lost: the message names one example. A page whose uml and plantuml diagrams both fail therefore gets one warning for each of the two names. Editing a diagram file re-renders the pages that use it on the next build, so these counts always describe the site as it now stands. ubCode tracks each diagram file as a dependency of the documents that draw it — the same way it tracks an image or an included file — so a changed diagram also refreshes those documents’ diagnostics.

Silencing

The codes use the ordinary lint configuration, and they are independent — silencing one leaves the others reporting. A per-file-ignores rule is applied to each counted site rather than to the finding, so it removes the sites in the files it names from the count and silences the warning only when it names every one of them — which files those are follows from where each site is written, exactly as above:

[lint]
ignore = ["build.directive_unhandled", "build.role_unhandled"]

Or narrow it to a single name, matched on the message (which always begins with the directive name, the role name, or the diagram file’s path):

[[lint.message-ignores]]
codes = ["build.directive_unhandled"]
contains = "acks:"

Math typesetting

Math (the :math: role, the math directive, and MyST dollar math) is typeset in the browser by a bundled KaTeX — generated entirely at build time, no CDN, works offline.

A page that contains math links two files, and the build writes the fonts beside them:

File

Purpose

_static/ubc-katex.js

The typesetter. Linked only on pages that contain math.

_static/ubc-katex.css

The KaTeX stylesheet (fonts and layout). Linked with the script.

_static/katex-fonts/

The 20 KaTeX web fonts (woff2). Never linked by pages — the stylesheet loads them itself.

A page with no math loads none of this, and a site with no math at all ships none of it.

KaTeX output inherits the page’s text colour, so it follows your theme tokens in both light and dark without a separate palette (the KaTeX stylesheet itself is not part of the theming contract).

A formula KaTeX cannot parse is left as its raw LaTeX source with the parse error in the element’s tooltip — never a broken page. Raw LaTeX is also excluded from the search index: once typeset, the source text appears nowhere on the rendered page.

External PlantUML renderer

Added in version 0.34.0.

ubc build html can draw your PlantUML diagrams by running a PlantUML executable you install and maintain, instead of converting them to Mermaid for the browser to draw. It is opt-in and off by default.

It covers both diagram families, from the one setting: the needuml and needarch views, and the authored uml / plantuml directives (and their MyST {uml} / {plantuml} fences). Every diagram in the project is drawn in a single run of the executable, however many there are and whichever family they come from. Two diagrams are drawn once between them only when the PlantUML they produce is BYTE-IDENTICAL — so two copies of the same authored diagram collapse, wherever in the project they are, while a needuml and an authored diagram rarely do: the needuml engine re-wraps its body, and an RST directive body cannot carry the trailing newline that leaves.

Two things make it worth having, and neither is something the Mermaid conversion can be improved into:

  • The diagrams are what PlantUML draws — exactly, including the diagram kinds the conversion approximates or declines. salt and gantt are the clearest case: the conversion can only render those empty, so today they show the diagram source instead of a picture. With an executable configured, they show the picture.

  • Traceability links survive. needuml’s default flow() style emits nodes carrying links, and the Mermaid conversion has nowhere to put them except in a class diagram — so on the common flow() shape every link is silently dropped today, and the boxes simply are not clickable. A real PlantUML renders them as real links inside the diagram.

Before you turn this on

You choose the version, and the version shows. How a diagram looks depends on the PlantUML release you install and on its Graphviz installation. Upgrading PlantUML can change how your diagrams look — and it re-renders them, which is what keeps the pages honest.

PlantUML 1.2026.2 or newer is required. That is a security floor rather than a compatibility one: PlantUML renamed its command-line options between the 2025 and 2026 series, and an older release accepts the options this feature depends on and then silently ignores them — including the one that stops a diagram writing files outside the build’s output directory.

PlantUML’s own preprocessor runs over your diagram, in a sandbox. ubCode hands the executable your diagram source as it was produced, unexpanded, so !include, !theme and the rest are handled by PlantUML itself. ubCode always sets PlantUML’s SANDBOX profile, so out of the box a diagram cannot read files from disk: an !include of a local file fails that diagram, which then falls back to the Mermaid conversion with a warning.

That is a safe default, not a guarantee. What you put in command can relax it. Measured: a later -DPLANTUML_SECURITY_PROFILE=UNSECURE argument draws and publishes an include that would otherwise be refused, and so does a wrapper script that re-exports the profile before starting PlantUML. ubCode does not try to diagnose which of the two PlantUML prefers. That is how to get an include library if you genuinely need one. It is your explicit choice, and it comes with a real caveat: ubCode cannot contain what PlantUML then reads, and PlantUML’s own allowlist confines neither .. nor symbolic links — a diagram under it can read any file your build can.

Not every SVG can be inlined, and one that cannot is refused rather than edited. PlantUML can be asked to emit interactive SVGs (!pragma svginteractive true), which carry a <script> and page-wide CSS; ubCode does not inline those, nor an SVG carrying an event-handler attribute. Links are checked per element, on the bytes that actually reach the page:

  • a link (<a>) may have no scheme at all — #anchor, ../page.html#anchor, a relative path — or http, https or mailto;

  • an <image> may additionally carry a base64 raster (data:image/png, gif, jpeg, webp), which is what a PlantUML sprite renders as. data:image/svg+xml is refused: an SVG payload is itself a document that can carry script and style, so allowing it would reopen everything these rules close — a raster cannot;

  • a <use> may point only within the same document, because it fetches what it points at and splices it into the drawing;

  • any other element carrying a link with a scheme is refused.

A refused diagram falls back to the Mermaid conversion and is reported, rather than being edited into something the executable did not draw.

A local !include stops working, and that is worth knowing before you turn this on. The Mermaid conversion’s own preprocessor skips an include it cannot resolve and draws the rest of the diagram; PlantUML under SANDBOX fails the diagram instead. So a diagram that includes a local file goes from drawn to drawn the old way, with a warning — and because ubc build html fails on any warning by default, that fails the build. Inline the shared text, or use a stdlib include (!include <C4/C4_Context>, !theme cerulean, sprites), which are compiled into PlantUML and work under the sandbox.

The live preview still uses the Mermaid conversion. It is fast and needs no Java, so a diagram can look different in the editor preview than on the built page. The built page is what your readers see.

Diagrams keep the colours PlantUML gave them. An externally rendered diagram is finished bytes by the time the page loads, so the site’s dark theme cannot re-draw it; it is shown on a light card in both themes so that it reads as a picture rather than as a hole in the page. For dark diagrams, theme them at the source (!theme, skinparams, or a [needs] flow_configs block).

You are running your own tool. ubCode launches the executable you name, with the permissions your build has. Point it only at a PlantUML you trust.

Options

[build.html.plantuml]
enabled = true
command = ["java", "-jar", "{config_dir}/libs/plantuml.jar"]
timeout = 60
min_version = ""
enabled

boolean (default false). Writing the table is not itself the opt-in — set this to turn the renderer on. One switch covers both the needuml / needarch views and the authored uml / plantuml directives.

command

list of strings (required when enabled). The executable and its arguments. Always a list, never a single string.

The FIRST entry is the program: a bare name is looked up on PATH, and one containing a path separator is taken relative to this ubproject.toml. Every entry after it is passed through exactly as written — a container image name, a KEY=VALUE pair or a URL reaches your program unchanged.

To name a file beside this ubproject.toml, write {config_dir} and ubCode replaces it with that directory — anywhere in any entry, as often as you like:

command = ["java", "-jar", "{config_dir}/libs/plantuml.jar"]
command = ["java", "-cp", "{config_dir}/a.jar:{config_dir}/b.jar", "net.Main"]

A bare "libs/plantuml.jar" is not rewritten and will not be found, because the renderer runs in a working directory ubCode chooses. Write {config_dir}/libs/plantuml.jar instead. JVM options go before -jar, where you would write them anyway.

timeout

integer seconds (default 60, maximum 86400). Bounds the single run that renders every diagram needing an update, not each diagram. Diagrams that produced nothing when it expires fall back to the Mermaid conversion and are reported.

min_version

string, unset (""), which means the built-in minimum, 1.2026.2. The oldest PlantUML this project accepts, written as PlantUML writes it. It can only raise the built-in minimum; a lower value is reported and ignored.

Sharing the renderer between builders

Added in version 0.35.0.

The keys are the same for every ubc build output format, so the executable can be named once for the whole project in [build.plantuml]. A builder’s own table inherits every key it does not set from [build.plantuml] and wins on every key it does, enabled = false included.

{config_dir} is resolved against the ubproject.toml that wrote the entry, so a shared table in a parent config points at files beside that file. ubc config prints each builder’s effective table; the shared table does not appear separately.

[build.plantuml]
command = ["java", "-jar", "{config_dir}/libs/plantuml.jar"]

[build.html.plantuml]
enabled = true

Caching, and when a diagram is re-drawn

A rendered diagram is cached by its content, so an unchanged diagram costs nothing on a rebuild and an identical diagram is drawn once — identical meaning the PlantUML text produced for it is the same, so the same directive on pages at different directory depths can produce two renders. The cache key covers the diagram’s source, the version your executable reports, and the rendering options, so upgrading PlantUML re-draws every diagram it affects.

Two consequences worth knowing:

  • Replacing your PlantUML with a different build that reports the same version is the one change the key cannot see. Run the build once with --fresh (or --no-cache) to re-draw everything.

  • A diagram using %date , %now or %random is frozen at its first render. It is drawn once and served from the cache until something moves the key, so pages stay stable across builds — but the value is the one captured the first time.

When something goes wrong

Nothing here stops the site being built. A diagram the executable cannot draw falls back to the Mermaid conversion — drawn, not blank — and the finding says so; a renderer that cannot be used at all leaves every diagram exactly as it is today. All of these are warnings, and ubc build html fails on warnings by default, so a failed diagram still fails the build while producing a complete site you can look at.

Code

When

build.plantuml_exec_failed

The executable cannot be run, or the table cannot be used as written (no command, or timeout = 0 or above the maximum). A min_version below the built-in minimum is not one of these: it is reported and ignored, and the renderer still runs.

build.plantuml_version_unsupported

The executable reports a version below the accepted minimum. The renderer is off for that build.

build.plantuml_version_unparsed

ubCode could not read the version the executable printed, so it cannot check it. The renderer is off for that build. Usually a wrapper script printing its own banner first.

build.plantuml_timeout

Rendering did not finish in timeout seconds. Diagrams already finished are kept.

build.plantuml_unexpected_output

The executable wrote a file the build did not ask for. It is named and left alone.

needs.uml_external_render_failed

One diagram could not be drawn by the executable. That diagram falls back to the Mermaid conversion.

Theming with extra_css

The built-in stylesheet is written in terms of CSS custom properties, so most re-branding is a matter of setting a handful of values rather than overriding rules.

There are two stable surfaces:

  • the --ubc-* custom properties (the token table below), and

  • the ub-* class names on the emitted markup (the vocabulary below).

Everything else — the specific rules, their order, their selectors — is an implementation detail and may change.

Note

The syntax-highlighting bundle owns a separate --ubc-hl-* namespace, which is not part of this contract.

Note

sphinx-design components follow these tokens too. The design stylesheet a page with grid / card / button-* markup loads defines its own --sd-* palette in terms of --ubc-brand, --ubc-bg, --ubc-border, --ubc-fg and --ubc-bg-soft, so branding the shell brands those components as well, in both themes. The --sd-* properties remain sphinx-design’s own public API: set one in your extra_css and it wins, because that sheet loads last.

Worked example

A complete re-brand: one accent colour per theme, plus an underline-style link treatment.

# ubproject.toml
[build.html]
extra_css = ["_static/theme.css"]
/* _static/theme.css */

/* Light (the default) — set the tokens on :root. */
:root {
  --ubc-brand: #583eff;
  --ubc-link: #1a1c1e;
  --ubc-link-hover: #1a1c1e;
  --ubc-link-visited: #1a1c1e;
}

/* Dark. Two selectors, because the site has two ways of being dark:
   the reader picked dark with the theme toggle, or the reader's system
   prefers dark and they have not overridden it. */
@media not print {
  :root[data-theme="dark"] {
    --ubc-brand: #e4ff3e;
    --ubc-link: #d6d9dd;
    --ubc-link-hover: #d6d9dd;
    --ubc-link-visited: #d6d9dd;
  }

  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --ubc-brand: #e4ff3e;
      --ubc-link: #d6d9dd;
      --ubc-link-hover: #d6d9dd;
      --ubc-link-visited: #d6d9dd;
    }
  }
}

/* Rules, for anything the tokens do not cover. */
.ub-content a {
  text-decoration-color: var(--ubc-brand);
  text-decoration-thickness: 0.1em;
}

The theme is applied by setting data-theme="dark" or data-theme="light" on the <html> element; with the attribute absent, the reader’s system preference decides. Match that in your own rules and your theme will follow the toggle.

Important

Set every theme-aware token (see the table below) in all three blocks, exactly as above — even a value that would otherwise be identical in both themes.

The built-in stylesheet declares its dark values with the two selectors :root[data-theme="dark"] and :root:not([data-theme="light"]), both of which are more specific than a plain :root. A token you set only on :root therefore wins in light mode and is silently overridden back to the built-in value in dark mode.

Fixed tokens have no dark declaration to lose to, so for those :root alone is enough.

Custom properties

All tokens are declared on :root. The theme-aware ones are redefined for dark mode by the built-in stylesheet, so a theme that changes them must set both (as in the example above); the fixed ones have a single value in both themes.

Property

Kind

Purpose

--ubc-font

fixed

Body font stack.

--ubc-font-mono

fixed

Monospace font stack (code, literals).

--ubc-fg

theme-aware

Primary text colour.

--ubc-fg-muted

theme-aware

Secondary text: captions, navigation entries, metadata.

--ubc-fg-border

theme-aware

Stronger border colour, for elements that need to read as edged (table cells, blockquote rules).

--ubc-bg

theme-aware

Page background.

--ubc-bg-soft

theme-aware

Recessed background: the sidebar, the mobile header, table headers.

--ubc-bg-hover

theme-aware

Hover background for interactive elements.

--ubc-border

theme-aware

Default border/rule colour.

--ubc-brand

theme-aware

The accent colour: the current page in the sidebar, focus rings, (by default) the active entry in “On this page”, and the sphinx-design accent (see the note below).

--ubc-link

theme-aware

Link text.

--ubc-link-hover

theme-aware

Link text on hover.

--ubc-link-visited

theme-aware

Visited link text.

--ubc-toc-active

follows --ubc-brand

The “On this page” entry for the section currently being read. Defined as var(--ubc-brand), so recolouring the brand recolours this too; set it to decouple the two.

--ubc-code-bg

theme-aware

Background of inline code and code blocks.

--ubc-code-fg

theme-aware

Foreground of inline code and code blocks.

--ubc-admonition-bg

theme-aware

Admonition body background.

--ubc-highlight-on-target

theme-aware

Background tint of the element a fragment link targets (the :target highlight on headings, named tables and figures).

--ubc-generic

fixed

Accent for generic .. admonition:: boxes (those with no kind, such as .. admonition:: Before you start).

--ubc-note

fixed

Accent for note admonitions.

--ubc-tip

fixed

Accent for tip / hint admonitions.

--ubc-important

fixed

Accent for important admonitions.

--ubc-warning

fixed

Accent for warning / caution / attention admonitions.

--ubc-danger

fixed

Accent for danger / error admonitions, and for error notices.

--ubc-seealso

fixed

Accent for seealso admonitions.

--ubc-todo

fixed

Accent for todo admonitions (the sphinx.ext.todo port).

--ubc-version-added, --ubc-version-added-bar

theme-aware

Label text and left-bar colours of a .. versionadded:: box.

--ubc-version-changed, --ubc-version-changed-bar

theme-aware

Label text and left-bar colours of a .. versionchanged:: box.

--ubc-version-deprecated, --ubc-version-deprecated-bar

theme-aware

Label text and left-bar colours of a .. deprecated:: box.

--ubc-version-removed, --ubc-version-removed-bar

theme-aware

Label text and left-bar colours of a .. versionremoved:: box.

--ubc-content-width

fixed

Maximum width of the content column (default 46em).

--ubc-radius

fixed

Default corner radius.

--ubc-header-height

fixed

Clearance height of the narrow-viewport header (default 3rem): how far jumped-to anchors and the back-to-top button stay below it. It does not size the header itself. It is also the height of the announcement strip.

--ubc-announcement-bg, --ubc-announcement-fg

fixed

Background and text colour of the announcement banner. Fixed on purpose, not an oversight: a banner is a deliberate interruption, and one that followed the theme would stop reading as one, so a single pair of values serves both themes. Override them on :root alone — there is no dark declaration for a dark override to lose to.

Class vocabulary

The class names the site shell emits. Directive-level classes (ub-need*, ub-video*, ub-needflow*, …) come from the shared HTML writer and are documented with those directives.

Area

Classes

Layout

ub-page (the column container), ub-sidebar-drawer / ub-sidebar, ub-main-column, ub-toc-drawer / ub-toc, ub-content (the <main>), ub-skip-to-content (the keyboard skip link). The drawers are two-layer: the outer *-drawer aside absorbs the width beyond the centred ensemble (and is the off-canvas panel at narrow widths), the fixed-width inner container is the sticky scroll column.

Brand

ub-brand (the block), ub-brand-link (the link to the root document), ub-brand-logo (the logo image), with ub-logo-light / ub-logo-dark variant classes when a distinct logo_dark pairs with logo (the stylesheet displays exactly one per theme).

Site navigation

ub-nav (the <nav>), ub-nav-caption, ub-nav-list, ub-nav-item, ub-nav-link, ub-nav-external, ub-current and ub-current-ancestor (the active trail), ub-has-children, ub-nav-toggle / ub-nav-toggle-label / ub-nav-children (the collapse control).

On this page

ub-page-toc (the <nav>), ub-page-toc-title, ub-toc-list, and ub-active on the entry for the section being read (also marked aria-current="location"). ub-scrollspy-end is a zero-height, aria-hidden marker the script appends at the end of the document so it can tell when the reader has reached the bottom; it renders nothing.

Announcement

ub-announcement (the strip) and ub-announcement-content (the centred line inside it), emitted at the top of every page when announcement is set — including on a page with no site shell.

Footer

ub-footer (the <footer>, on every page with the shell that has anything to put in it), containing the previous/next ub-relations nav — ub-rel with ub-rel-prev / ub-rel-next, ub-rel-dir, ub-rel-title — and the bottom row ub-footer-bottom / ub-footer-details with ub-copyright (only when configured) and the ub-built-with attribution (unless hide_attribution), plus ub-footer-icons with one ub-footer-icon link per footer_icons entry.

Narrow-viewport drawers

ub-drawer-toggle (the hidden checkboxes ub-drawer-nav / ub-drawer-toc), ub-mobile-header with the ub-mh-nav / ub-mh-toc markers and its three slots ub-mh-left / ub-mh-center / ub-mh-right (ub-scrolled is added by the JavaScript once the page scrolls, for the header’s shadow), ub-mobile-btn / ub-mobile-nav-open / ub-mobile-toc-open / ub-mobile-icon, ub-mobile-title, ub-scrim with ub-scrim-nav / ub-scrim-toc.

Content icon row

ub-content-icons, the row of icon controls at the top right of the content column, carrying ub-ci-actions when it holds page actions and ub-ci-toggle once the script has added the theme toggle to it. Its members, in order: ub-view-page and ub-edit-page (the source-link buttons, emitted when source_repository or a link template is set), the script-injected theme toggle, and ub-toc-content-open (the in-content on-this-page opener, shown where the sidebar column hides the header). The row is written into every page that has a sidebar or an on-this-page drawer, because it is where the theme toggle lives, and it FLOATS over the content rather than taking a strip of its own. It is only made visible where one of its members is, so a page whose row would be empty shows nothing at all.

Theme toggle

ub-theme-toggle on every instance, plus the placement variant: ub-theme-toggle-header (in the mobile header’s right slot, before the drawer opener), ub-theme-toggle-ci (in the content icon row, before the on-this-page opener), or ub-theme-toggle-float (the fixed fallback for a page with no such chrome, marked ub-theme-toggle-ci-paired when a row instance exists). One instance is added for each of those places the page provides, and the stylesheet shows exactly one of them at any given width: on a page with chrome, the row above 63em and the mobile header below it; on a page with none, the float at every width. ub-theme-toggle-btn is on the button itself.

Back to top

ub-back-to-top on the JavaScript-injected pill, revealed by the ub-show-back-to-top class the script toggles on <html>.

Search

ub-search-form (the injected box, with ub-search-sidebar or ub-search-page-form), ub-search-input, ub-search-dropdown (the listbox, hidden when closed) with ub-search-group-label, ub-search-option (ub-search-active on the highlighted one), ub-search-empty and ub-search-all; one result is ub-search-hit / ub-search-hit-head / ub-search-title / ub-search-crumb / ub-search-excerpt with ub-search-mark on the matched words, plus ub-search-badge and ub-search-id on a need result. The results page adds ub-search-count, ub-search-facets / ub-search-facet / ub-search-chip (ub-search-chip-on when active), ub-search-group / ub-search-group-title / ub-search-list / ub-search-item / ub-search-more, ub-search-noscript, and the visually-hidden ub-search-announce live region.

Note

The theme toggle (every instance of it, in the header and the content icon row included), the back-to-top button, the header’s ub-scrolled shadow, the ub-active marking, and the whole search interface are added by the site’s JavaScript, not present in the served HTML — so a page viewed with JavaScript disabled carries none of them, and no empty slot or dead control is left where one would have been.

The view/edit buttons are the exception in that row: they are ordinary links, written into the served HTML, and work with JavaScript disabled like any other link.