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
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 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_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 any?queryand#fragmentthey arrived with that the target does not set itself.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 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_invalidrather than accepted and quietly ignored.Redirects chain: if
apoints atbandbpoints atc, a reader arriving atais sent straight toc, 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_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, or two keys that name the same page 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.
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 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
redirectsat 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-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
— 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:"
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 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.
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
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.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 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), andthe
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 |
|---|---|---|
|
fixed |
Body font stack. |
|
fixed |
Monospace font stack (code, literals). |
|
theme-aware |
Primary text colour. |
|
theme-aware |
Secondary text: captions, 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 “On this page” entry for the section currently being read.
Defined as |
|
theme-aware |
Background of inline code and code blocks. |
|
theme-aware |
Foreground of inline code and code blocks. |
|
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 |
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 |
|
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, 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.