HTML site¶
CLI Beta
Every key, with its type and default: [build.html].
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.
The builder is Beta: the emitted markup, the theming surface described here and the command’s options may still change between minor releases, so pin the minor version you build with. 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
1when 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--denycommand-line flag overrides the key for one run;"none"never fails the threshold (an explicit--max-warningsbudget still applies). See Building for production for the full story.- extra_css
Type:
arrayofstring(optional, default:[])Stylesheets to copy into the site and link on every page.
Each path is resolved relative to the
ubproject.tomlthat declares it (or may be absolute). Every file is copied into the site’s_static/directory and linked after the built-in stylesheet, after the syntax-highlighting palette, and after any feature bundle (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_failedand 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_cssat all sets the complete list.- extra_static
Type:
arrayofstring(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 anextra_csssheet resolves against a file that is actually there. Nothing listed here is linked from the pages.Each path is resolved relative to the
ubproject.tomlthat 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.csscan writeurl(../img/bg.png)and it resolves. A listed file lands flat at_static/{filename}, the same mappingextra_cssuses.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.nojekyllfile or a symlinked shared brand directory can be published on purpose.A stylesheet that lives inside a listed directory —
assets/css/brand.cssabove — 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 defaultdenybar, 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 underassets/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_collisionA file’s destination was already claimed — by a built-in site asset, by an
extra_css/logo/faviconcopy, 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 WindowsBG.pngandbg.pngare one file.build.extra_static_read_failedAn 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_entryAn entry resolves inside the output directory, which would publish the site into itself.
Under the default
denybar ("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
.mdor.rstfiles otherwise becomes part of the site’s page set.An extending configuration’s list is replaced whole, not merged — a child that sets
extra_staticat 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].namefor 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-logobelow to change either).A file that cannot be read is reported as the build warning
build.logo_read_failedand skipped. Unset — or the empty string — means no logo.- logo_dark
Type:
string(optional, default:"")A dark-theme variant of
logo.Shown instead of
logowhile 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 useslogo. Resolved and copied exactly likelogo(an unreadable file is the samebuild.logo_read_failedwarning), and meant to be set together with it (mirroring furo’slight_logo/dark_logopair); set alone, it behaves likelogoand is used everywhere, including print. Unset — or the empty string — meanslogo(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 warningbuild.favicon_read_failedand 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 asCopyright © 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, unlesshide_attributionbelow 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
copyrightline (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, nofooter_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 = Falsebecomeshide_attribution = true. The name is hide-shaped to matchhide_titlein this same table. furo has no equivalent key of its own. Theshow_sphinxits template reads is Sphinx core’shtml_show_sphinxsurfaced 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:
htmlThe icon markup. It is inserted into the page as raw HTML, not escaped — see the warning below.
urlThe link target, used exactly as written, so an external link needs its scheme.
nameThe link’s accessible label. An icon has no text of its own, so this is what a screen reader announces for the link.
classExtra 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. AnsvgwhosefillorstrokeiscurrentColortakes 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 ansvgorimghere to one line of text. Aclasson its own, with nohtml, works too, for an icon font shipped throughextra_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 neitherhtmlnorclass(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
denybar ("warning", described above), either tier fails the build — so a furo list ported across with nonameon an entry needs anameadding, or the code silencing under [lint].url,nameandclassare 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
htmlsemantics are furo’sfooter_icons, so a furo list ports across unchanged — though, per the bar above, add anameto each entry (or silencebuild.footer_icon_invalid) to clear the defaultdenybar 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-bgand--ubc-announcement-fgcustom 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_branchand 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.htmlresults 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/…andhttps://bitbucket.org/…. A trailing/is optional, and so is a trailing.git— the URLgit cloneprints works as written. For any other forge — a self-hosted GitLab, Gitea, Codeberg, sourcehut, Azure DevOps — usesource_view_link/source_edit_linkbelow, which are never checked against that list.A repository set with no
source_branch, set to anhttp://URL, or set to a host outside those three draws no button and is reported as the build warningbuild.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 anhtml_theme_optionsblock 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
mainormaster.[build.html] source_branch = "main"
Read together with
source_repository. It is not consulted bysource_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 asbuild.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 whoseubproject.tomllives indocs/of its repository setsdocs/here. A leading or trailing/is optional.Important
It applies to the
source_repositoryconstruction only. Asource_view_link/source_edit_linktemplate 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.pykeeps 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_repositoryconstruction 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_directoryis 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_repositoryconstruction.- 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_linktwin, with the same{filename}substitution and the same freedom from host checking. The two keys are independent: setting one leaves the other to thesource_repositoryconstruction, so a project can override just the button its forge spells differently.Unset — or the empty string — falls back to the
source_repositoryconstruction.
- redirects
Type:
tableofstring→string(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.htmlstill resolves — it carries the reader on to the new page, keeping the?querythey arrived with (unless the target names one of its own), and their#fragmenttoo — unless the entry names a section (see below).Keys and targets are written the way a
toctreeentry 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 externalhttp:///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 also name a section, for a heading that moved or was renamed:
[build.html.redirects] "guide/setup#old-name" = "guide/setup#new-name" "guide/setup#installing" = "guide/install"
A section renamed on the same page needs no entry at all: put a label of the old name directly above the renamed heading.
.. _old-name: New name --------
The label is rendered on the page as an anchor of its own, so
guide/setup.html#old-namestill lands on the section, and the label still resolves as a:ref:target. Its anchor is spelled the way [parse] id_style spells every name: a labelOld_Name 2.0anchors at#old_name-20under"github"and at#old-name-2-0under"docutils".The table is for the anchors a label does not keep: a section that moved to another page, an anchor that only ever existed by position, such as
id1, and an anchor whose spelling changed with[parse] id_style— such as the#needs-types-2-0that became#needs_types-20when the default style changed to"github". For that last kind, every page at once, legacy_anchor_redirects writes the entries for you.A URL fragment is never sent to a server, so this cannot work with a forwarding page; it works in the reader’s browser instead. If the key’s page still exists, the site’s script forwards a reader whose fragment resolves to nothing — a section that is still there always wins, so an entry for an anchor that has not actually moved simply never fires (and says so, as
build.redirect_anchor_shadowed). If the key’s page is itself redirected, the entry travels in that page’s forwarding page instead, which is the only thing left at that URL. A key naming a section of a page that neither exists nor is redirected has nowhere to live, and is refused withbuild.redirect_invalid.The reader’s
?queryrides a section hop exactly as it rides a page hop. Their#fragmentis the entry’s key: it is replaced by the destination the entry names — or by nothing, which means the top of the new page.Target sections are checked against the page the build actually renders — whether the key names a section or a whole page, so
"old" = "guide#install"is checked exactly as"old#a" = "guide#install"is. A section the target page does not have is reported asbuild.redirect_anchor_unknown, and the redirect is still written — landing on the right page is better than not being forwarded at all. The check reads the ids the rendered page carries, including those written by hand in arawblock; an id a script injects at runtime is outside it (see Build warnings).Redirects chain: if
apoints atbandbpoints atc, a reader arriving atais sent straight toc, in one hop. Section redirects chain too, and across the two layers: ifoldmoved tonewandnew#amoved toother#b, a reader arriving atold.html#areachesother.html#bin a single navigation.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.
Six build warnings report an entry that cannot be used, or that will not do what it says. Each names the entry, and the rest of the table still builds:
build.redirect_invalidA 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 of a page that is neither rendered nor redirected, or two keys that name the same thing once the spellings are normalised.build.redirect_source_existsThe 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_missingThe 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_cycleThe 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.
build.redirect_anchor_unknownThe target page renders no such section. The redirect is still written, and a reader following it lands on the page and stays at the top.
build.redirect_anchor_shadowedThe key’s page still renders the section the entry says has moved, so the entry can never fire — a section that is still there always wins.
Under the default
denybar ("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 ubCode owns (see The output directory). A directory that already had content ubCode did not put there is never pruned: that is the standing rule for every file the build manages, not something redirects introduce, so a build into a hand-authored tree leaves stale forwarding pages where they are.
An extending configuration’s table is replaced whole, not merged — a child that sets
redirectsat all sets the complete table.
- legacy_anchor_redirects
Type:
arrayofstring(optional, default:[])Keeps deep links written against a previous [parse] id_style working.
[build.html] legacy_anchor_redirects = ["docutils"]
For every id on a page that a listed style would have spelled differently, the page gains a redirect from the old spelling to the new one, so a deep link written against an old anchor that nothing on the page carries any more lands on the element that carried it. List the other style:
"docutils"on a"github"project — the anchors a Sphinx build makes, and the ones ubCode made before"github"became the default — or"github"on a"docutils"project, for links written against GitHub- or MyST-style anchors. So on a"github"project with["docutils"],guide.html#needs-types-2-0forwards toguide.html#needs_types-20, a positional#id1(a title with no ASCII letters, such as日本語, or the second of two identical headings) forwards to the element that carried it, unless the page now carries anid1of its own (see below), and a glossary term, a footnote, a citation, acontentsentry, a math:label:and a directive:name:forward just as a section does.The entries are generated from the page itself, by these rules:
an old anchor the page still carries is never redirected: the page’s own anchor always wins, and neither a generated nor a manual entry can override it. That holds even when the new style gave the string to a different element — a positional
#id1now taken by a footnote, or one title now spelled the way another used to be (under"github",Release 1.0is#release-10, the old anchor ofRelease 10) — so a deep link to such an anchor lands on that other element. That is the usual outcome for an old anchor the page still carries, not a rare one;a new anchor the page does not render (the name of an external or indirect hyperlink target, which writes nothing) is never redirected to;
an entry in [build.html.redirects] for the same anchor wins over the generated one, so any single generated entry can be corrected by hand;
a need’s own anchor is its id under either style, so it never moves.
The generated entries join the page’s anchor-redirect map, the one a
[build.html.redirects]key naming a section writes to, and they forward in the same way: in the reader’s browser, by the site’s own script. A reader without JavaScript, a crawler, and anobjects.invconsumer see the new anchors only; What stays different lists what the map does not cover.A page with nothing to redirect carries nothing, so a site whose titles spell the same under both styles is unchanged byte for byte. Unset or empty — the default — generates no entries at all. A listed style that is the project’s own
id_stylegenerates nothing, and is reported as the warningconfig.legacy_anchor_redirects_own_style. An unknown style is a configuration error, as it is forid_style.An extending configuration’s list is replaced whole, not merged.
- 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-itemwith 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 issphinx-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
falseremoves the sidebar search box, thesearch.htmlresults 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
copyrightline (when configured) and a muted “Built with ubc” attribution (unlesshide_attributionremoves it), with thefooter_iconsrow 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
— sectnum, py:function, 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]
--> api/core.rst:4
--> api/core.rst:8
--> api/core.rst:12
py:function: 6 sites rendered as placeholders — no handler for this directive yet
(3 shown)
warning[build.directive_unhandled]
--> api/index.rst:4
--> guide.rst:8
--> tables.rst:4
sectnum: 3 sites rendered as placeholders — no handler for this directive yet
One finding per directive name, most-used first,
with the total number of placeholder sites and the first few distinct
locations of them, each on its own --> line — 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]
--> guide.rst:4
--> index.rst:14
token: 3 sites rendered as chips — unhandled or unrecognised role (2 shown)
warning[build.role_unhandled]
--> guide.rst:6
option: 1 site rendered as a chip — unhandled or unrecognised role
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]
--> architecture.rst:4
--> architecture.rst:6
--> overview.rst:4
diagrams/flow.puml: 4 sites rendered as placeholders — the diagram file could
not be read (missing, unreadable, not a local file, or empty) (3 shown)
warning[build.diagram_unconvertible]
--> ui/wireframes.rst:4
../diagrams/wire.puml: 1 site rendered as a placeholder — the diagram file was
read but its PlantUML could not be converted to Mermaid
warning[build.plantuml_render_failed]
--> architecture.rst:4
--> architecture.rst:8
--> overview.rst:4
uml: 5 sites rendered as Mermaid conversions — the PlantUML executable did
not draw them, or drew SVGs ubCode will not inline
(e.g. diagrams/seq.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 = "sectnum:"
Search¶
Every site is built with full-text search unless you turn it off
(search.enabled above).
It is generated entirely at build time and needs no server:
open the site from a local folder — a file:// URL — and search still works,
which is what makes a built site usable on an air-gapped machine
or straight out of a ZIP file.
What you get¶
A search box at the top of the sidebar, on every page. Results appear as you type, grouped by page, with the matching words highlighted in a short excerpt.
Section-level results. A hit points at the section it was found in, not just at the page, so following it lands you at the right heading.
Need results, shown with a badge carrying the need’s type and status. A need’s id, type, title, status, tags and body text are all searchable, and typing a need id exactly (
REQ_001) puts that need at the top. An id that does not exist simply returns nothing for that part of the query — it never quietly falls back to some other need.A results page at
search.html, reached by pressing Enter or following the “more results” link. It accepts a?q=query string, so a search can be bookmarked or shared, and it offers filter chips for need type and status (choosing several values of one filter widens the results, choosing values of both narrows them).
How much is shown: the dropdown lists the first few results per page, for the first few pages, and links to the rest; the results page counts every match and renders up to 200 of them, saying so in its count line when there are more. The filter chips apply to the whole result set, not only to the part shown.
Query syntax¶
Query |
Meaning |
|---|---|
|
Both words must appear (in any field, in any order). |
|
Exclude anything containing |
|
Both words must appear; quoting is a convenient way to group them (word ORDER is not yet significant). |
|
The need with that id, first — plus anything else mentioning it. |
Words are matched by their stem, so requirement also finds
requirements and required.
Identifiers are matched whole and in parts,
so REQ_001 is findable as REQ_001, as req and as 001,
and PascalCase is findable as pascal or case.
A word you are still typing matches by prefix.
That includes id prefixes that would otherwise be too short to search:
T_ finds everything continuing T_ (such as the T_* need ids),
even though a bare T is below the two-character minimum.
Some words are not indexed at all:
very common ones (the, how, to …),
anything shorter than two characters,
and runs longer than 64 characters.
A query word like that is ignored rather than making the query fail,
so how to install searches for install,
python 3 for python,
and C++ parser for parser.
A word with nothing indexable in it at all — punctuation only, such as
!!! or a lone em dash — is a different matter:
there is no word there to look for,
so the query returns nothing rather than quietly searching for less
than you typed.
Excluding such a word (-!!!) is harmless:
an exclusion that matches nothing removes nothing.
Keyboard¶
/focuses the search box, unless you are already typing in a field. Where the box is off screen — a collapsed sidebar on a narrow window — the keystroke is left alone rather than swallowed, so it still reaches the browser and the page.Down/Upmove through the results, wrapping around;Upfrom nothing selected goes to the last result.Enteropens the highlighted result, or — with nothing highlighted — opens the full results page.Escapecloses the results, then leaves the box.
Files and size¶
Search adds three files to the site:
File |
Purpose |
|---|---|
|
The index itself. Loaded when a reader first engages with the search
box — or on |
|
The search interface: the engine and the UI, linked from every page. |
|
The results page. |
The index is one file, which is the right shape for most projects
but has a practical ceiling.
Above roughly 30,000 indexed items — or 8 MiB —
the build reports the warning build.search_index_large,
naming the actual counts:
the site still works, but the first search on a page will feel slow,
because the browser loads the whole index at once.
Until index splitting arrives, the way out is search.enabled = false.
Note
If your project already has a document called search,
your page wins: the generated results page is not written,
and the build reports build.search_page_conflict.
The search box still works; only its “more results” link
lands on your page instead.
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 |
|---|---|
|
The typesetter. Linked only on pages that contain math. |
|
The KaTeX stylesheet (fonts and layout). Linked with the script. |
|
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 authored and generated PlantUML, from the one setting:
the needuml and needarch views,
and the authored uml / plantuml directives
(and their MyST {uml} / {plantuml} fences).
Two more families, needflow and needsequence, have keys of their own
(below): those diagrams are ubCode’s own Mermaid rather than PlantUML you
wrote, so enabling the executable does not by itself change the engine they are
drawn with.
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.
saltandganttare 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 defaultflow()style emits nodes carrying links, and the Mermaid conversion has nowhere to put them except in a class diagram — so on the commonflow()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 — orhttp,httpsormailto;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+xmlis 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
needflow = false
needsequence = false
command = ["java", "-jar", "{config_dir}/libs/plantuml.jar"]
timeout = 60
min_version = ""
enabledboolean(defaultfalse). Writing the table is not itself the opt-in — set this to turn the renderer on. One switch covers both theneeduml/needarchviews and the authoreduml/plantumldirectives.needflowandneedsequenceeach need this and their own key below.needflowboolean(defaultfalse).Added in version 0.36.0.
Also draw the
needflowviews with the executable. Needsenabledas well — this key chooses which diagrams the renderer draws, never whether there is one, soneedflow = trueon its own changes nothing (and is not reported: a project that shares itsubproject.tomlwith a Sphinx build would otherwise be warned on every build).It is a fidelity setting. With it off, a
needflowis the Mermaid flowchart ubCode draws itself (inubc build pdf, a placeholder box with its caption under it). With it on, the diagram is laid out by PlantUML, the way a Sphinx-Needs build withneeds_flow_engine = "plantuml"lays it out, and each node becomes a link to its need — which the Mermaid flowchart does not yet carry. Some intents cannot be carried over exactly::direction: upand:direction: leftare drawn on their axis mate with a warning, a need type whosestyleis not one of PlantUML’s own node keywords is drawn as a rectangle, and a link type’sstyle_start/style_endare ignored. Needs flow diagram (needflow) lists them all.[needs] flow_engine is the low-precedence default for this key, so a
ubproject.tomlshared with a Sphinx build need not say the same thing twice, and a diagram’s own:engine:overrides both in either direction. The live preview always uses the Mermaid conversion, whatever this says.needsequenceboolean(defaultfalse).Added in version 0.36.0.
Also draw the
needsequenceviews with the executable. Needsenabledas well, exactly asneedflowdoes.A separate key from
needflowbecause a project may well want its traceability graph laid out by PlantUML and its message diagrams left as they are; two switches cost nothing and one would make that inexpressible.[needs] flow_engine does not default this key, and there is no
:engine:option on aneedsequence: Sphinx-Needs’ ownneedsequenceis PlantUML-only and has no engine switch at all, so the key that chooses between engines has nothing to say about this family.With it off, a
needsequenceis the Mermaid sequence diagram ubCode draws itself (inubc build pdf, a placeholder box with its caption under it). The live preview always uses the Mermaid conversion, whatever this says.commandlist 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 thisubproject.toml. Every entry after it is passed through exactly as written — a container image name, aKEY=VALUEpair 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.jarinstead. JVM options go before-jar, where you would write them anyway.timeoutintegerseconds (default60, maximum86400). 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_versionstring, 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.
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,%nowor%randomis 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 |
|---|---|
|
The executable cannot be run, or the table cannot be used as written
(no |
|
The executable reports a version below the accepted minimum. The renderer is off for that build. |
|
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. |
|
Rendering did not finish in |
|
The executable wrote a file the build did not ask for. It is named and left alone. |
|
One |
|
One |
|
One |
|
A |
Smart quotes ([build] smartquotes)¶
Added in version 0.36.0.
Straight quotes, dashes and ellipses in prose are typeset in the built output,
as Sphinx does by default:
"text" becomes “text”, it's becomes it’s,
-- an en dash, --- an em dash and ... an ellipsis.
The two keys live in the shared [build] table
because every output reads them —
this site, the PDF
and the editor preview.
[build]
smartquotes = false # keep every character as written
smartquotes_action = "qe" # quotes and ellipses only
- smartquotes
Type:
bool(optional, default:true)Whether prose is educated.
falsekeeps every character exactly as written. Equivalent to Sphinx’ssmartquotesconfiguration value.- smartquotes_action
Type:
string(optional, default:"qDe")Which replacements are made — letters, in any order:
qquotes,Ddashes (--an en dash,---an em dash),doridashes the other way round,eellipses,bbacktick-style double quotes (two backticks, then two apostrophes),Bbacktick-style double and single quotes, andw"entities. Or a preset:0nothing,1qbde,2qbDe,3qbie, or-1, which turns typographic characters back into ASCII. Unset or an empty string means"qDe"; an unknown letter is reported asconfig.invalid_smartquotes_actionand ignored, as in Sphinx: the letters around it still apply ("qDx"educates quotes and dashes, not ellipses). Equivalent to Sphinx’ssmartquotes_action(except that an empty string means the default, not nothing).
The quote characters follow
[project] language —
„…“ for "de", «…» for "fr", and so on.
For "ja", "zh_CN" and "zh_TW" nothing is educated, as in Sphinx;
nor for a language with no quote characters of its own,
which is reported as config.smartquotes_unsupported_language
(Sphinx is silent there).
What is educated is prose wherever a reader meets it:
paragraphs, section and document titles
— and so the page <title>, the navigation, the “On this page” list
and the search index —,
link and reference text (an implicit :ref: or :doc: shows the target’s
educated title), captions, table cells, field lists, definition terms,
admonition titles, footnote bodies, substituted text,
option-list arguments and descriptions,
:guilabel: and :menuselection:, and a need’s content.
What is never changed: inline literals and code spans, literal, code,
doctest and math blocks, parsed-literal, raw content, :samp:, :file:,
:kbd:, :command: and the other literal roles, image alt text,
object signatures, option strings;
and every need string sphinx-needs builds from need data —
the need title, its fields, needtable and needlist cells, :need: text,
needs.json — as well as a needextract copy’s own text,
which sphinx-needs renders from the need before any typesetting
(a :ref: or :doc: inside the copy still shows the target’s educated title;
a :term: keeps the copy’s text).
The editor’s hover, outline and completions show the text as written, too.
A backslash keeps a character as written —
\", \', \-\-, \.\.\. —
in reStructuredText and, unlike a Sphinx build, in Markdown.
A Markdown parser that lists the MyST smartquotes extension
educates its documents’ quotes even with smartquotes = false,
and one that lists replacements also turns (c) into ©, +- into ±
and so on; see the MyST extensions.
Note
A smartquotes = False in a Sphinx project’s conf.py is not read:
a project that shares its sources with a Sphinx build sets the key here too.
See Smart quotes.
[build.html.highlight]¶
Added in version 0.36.0.
Syntax-highlighting settings for the site. The same keys as [build.highlight], which they inherit from. See Code blocks are syntax highlighted for what the build does with them.
enabledType:
bool(optional, default:true)Colour the code in code blocks and inline code.
Setting this to
falsedraws every listing in one colour — the theme’s own--ubc-code-fg— as this builder did before it could highlight. It does not affect the line-number gutter, and it does not affect the band an:emphasize-lines:option draws behind a line. With colour off, a language nothing answers to is not reported either: no language is looked at.aliasesType: table of
string→string(optional, default:{})Extra names for languages, on top of the built-in table.
Write the name you use on the left and the language to colour it as on the right:
[build.highlight] aliases = { mylang = "python", ourdsl = "text" }
The value must name a language the build already knows — any name from Languages — or one of
text,none,plain,plaintext,rawandoutput, which draw the block plain and report nothing. A value that names nothing is reported asconfig.highlight_alias_unknown, once per entry, and under the default--deny warningthat stops the build. Withenabled = falseit is not reported: no name is looked up at all.An entry wins over a built-in name of the same spelling, so
default = "text"draws a block whose language isdefault— Sphinx’s fallback name, reached through.. highlight:: defaultor[parse] highlight_language = "default"— plain instead of as Python.Values are looked up once:
a = "b"withb = "python"resolvesatob, which is not a language, and is reported.Names are matched ignoring case and surrounding spaces. An empty name or an empty target is refused where it is written.
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 four stable surfaces:
the
--ubc-*custom properties (the token table below),the
ub-*class names on the emitted markup (the vocabulary below),the
--ubc-hl-*custom properties — the twenty-one syntax-highlighting colours, which are alsoubc build pdf’s[build.pdf.theme.colors]hl-*roles, under the same names and with the same default values, so a scheme carries over between the two surfaces name for name, andthe
hl-*class names a highlighted run carries:hl-keyword,hl-type,hl-function,hl-name,hl-built-in,hl-label,hl-attr,hl-variable,hl-number,hl-literal,hl-operator,hl-string,hl-escape,hl-comment,hl-section,hl-bullet,hl-link,hl-additionandhl-deletion, plushl-strongandhl-emphasisfor a bold or italic run that takes no colour of its own.
Nineteen classes over twenty-one properties, because hl-addition and
hl-deletion each take an ink and a background
(--ubc-hl-addition-fg and --ubc-hl-addition-bg, and the deletion pair).
The VS Code preview paints the same hl-* classes from the same twenty-one
property names, so a construct is coloured alike in the editor —
but it takes its values from the editor’s own stylesheet,
so an extra_css redeclaration does not reach it.
Everything else — the specific rules, their order, their selectors — is an implementation detail and may change.
Changed in version 0.36.0: The last two were previously excluded from this contract, because the code colouring happened in the reader’s browser and the classes were highlight.js’s rather than ubCode’s. It happens during the build now, and they are the only way to recolour code, so they are promised rather than merely present.
The palette grew from thirteen properties to twenty-one at the same time.
--ubc-hl-attr used to paint numbers, language constants, operators and
variables as well as attribute names; each of those now has a property of
its own — --ubc-hl-number, --ubc-hl-literal, --ubc-hl-operator
and --ubc-hl-variable — and they all keep the same default colour, so
nothing changes unless you had redeclared --ubc-hl-attr, in which case
your override no longer reaches them.
--ubc-hl-title is now --ubc-hl-function, and the types it also
covered are --ubc-hl-type.
--ubc-hl-label (a reStructuredText target, footnote, citation or
substitution, and a MyST (name)=) and --ubc-hl-escape (an escape
inside a string) split out of --ubc-hl-built-in and --ubc-hl-string
the same way, keeping their old colours.
--ubc-hl-link is the one genuinely new colour: a URL or hyperlink target
in a highlighted markup listing, which nothing coloured before.
It carries the site’s link blue as a LITERAL rather than as a reference, so
a project that rebrands --ubc-link should redeclare --ubc-hl-link
beside it.
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;
/* Decoupling the underline from the link text: one declaration serves
both themes, because --ubc-brand is restated per theme and custom
properties substitute at use time. */
--ubc-link-underline: var(--ubc-brand);
}
/* 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-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.
Motion¶
In-page jumps — the “On this page” links, the ¶ permalinks,
the back-to-top button, footnote and citation references —
glide to their target rather than jumping,
unless the reader’s system asks for reduced motion.
Following a link from another page lands directly on its target, without the glide.
The glide starts only once the page has loaded and been drawn
(the script then sets the ub-loaded class on <html>),
and it needs JavaScript:
a page viewed without it never glides.
To turn the glide off altogether, add this rule to an extra_css file:
html {
scroll-behavior: auto;
}
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 |
|---|---|---|
|
fixed |
Body font stack. |
|
fixed |
Monospace font stack (code, literals). |
|
theme-aware |
Primary text colour. |
|
theme-aware |
Secondary text: captions, nested navigation entries, metadata. |
|
theme-aware |
Stronger border colour, for elements that need to read as edged (table cells, blockquote rules). |
|
theme-aware |
Page background. |
|
theme-aware |
Recessed background: the sidebar, the mobile header, table headers. |
|
theme-aware |
Hover background for interactive elements. |
|
theme-aware |
Default border/rule colour. |
|
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). |
|
theme-aware |
Link text. |
|
theme-aware |
Link text on hover. |
|
theme-aware |
Visited link text. |
|
follows the link’s text colour |
The colour a link’s underline is drawn in.
Added in version 0.36.0. |
|
follows |
The “On this page” entry for the section currently being read.
Defined as |
|
follows |
Top-level entries of the sidebar navigation tree.
Defined as Added in version 0.37.0. |
|
follows |
Nested entries of the sidebar navigation tree,
so the tree reads as a hierarchy.
Defined as Added in version 0.37.0. |
|
follows |
Background of the sidebar entry row under the pointer.
Defined as Added in version 0.37.0. |
|
theme-aware |
Background of inline code and code blocks. |
|
theme-aware |
Foreground of inline code and code blocks. |
|
theme-aware |
The syntax-highlighting palette: one property per token class a
highlighted code block distinguishes, painting the matching Added in version 0.36.0: Documented, and promised: see the note above the table. Changed in version 0.37.0: New default colours: one hue per kind of token, the same hue in light and dark
(keywords, |
|
theme-aware |
Admonition body background. |
|
theme-aware |
Background tint of the element a fragment link targets
(the |
|
fixed |
Accent for generic |
|
fixed |
Accent for |
|
fixed |
Accent for |
|
fixed |
Accent for |
|
fixed |
Accent for |
|
fixed |
Accent for |
|
fixed |
Accent for |
|
fixed |
Accent for |
|
theme-aware |
Label text and left-bar colours of a |
|
theme-aware |
Label text and left-bar colours of a |
|
theme-aware |
Label text and left-bar colours of a |
|
theme-aware |
Label text and left-bar colours of a |
|
fixed |
The background tint of a need card carrying that Added in version 0.36.0: Documented; the tokens themselves are older. |
|
fixed |
The frame or trailing bar of a need card carrying the matching
Added in version 0.36.0: Documented; the tokens themselves are older. |
|
theme-aware |
The categorical palette a Added in version 0.36.0: Documented; the tokens themselves are older. |
|
theme-aware |
The text colour of a value label drawn on that slot’s fill. A token per slot rather than one fixed colour because the slot values differ per theme, so the readable text over them does too. Added in version 0.36.0: Documented; the tokens themselves are older. |
|
theme-aware |
The halo stroked under that label, so its glyphs keep an edge against the fill. Keyed to the label rather than to the page background, which is what keeps it from vanishing into the label itself. Added in version 0.36.0: Documented; the tokens themselves are older. |
|
fixed |
Maximum width of the content column (default |
|
fixed |
Default corner radius. |
|
fixed |
Clearance height of the narrow-viewport header (default |
|
fixed |
Background and text colour of the |
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 |
|
Brand |
|
Site navigation |
|
On this page |
|
Announcement |
|
Footer |
|
Narrow-viewport drawers |
|
Content icon row |
|
Theme toggle |
|
Back to top |
|
Page state |
|
Search |
|
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, the ub-loaded class,
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.
Without ub-loaded, in-page jumps move straight to their target instead of gliding.
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.