0.33.x

0.33.0

Released:

27.08.2026

The story of this release, in four arcs: sources became a declared, shareable surface[[source.mounts]] folds external trees into the document set, [source] dir supersedes [project] srcdir, and variant rules decide which files are in the build, with sphinx-build reading the same ubproject.toml keys through sphinx-mounts 0.2.0, released alongside; the needs domain moved in step with Sphinx-Needs (8.4.0, also released alongside) — list2need creates its needs in both engines, needflow gained a portable presentation vocabulary, and a need records its parent and its enclosing sections; markup coverage widened — index entries, the :download: role, Markdown links that resolve, code-block line numbers, CSS classes, and page <head> metadata; and the lint surface grew — around sixty-five new diagnostic codes ship, most of them on by default, so an unchanged project can see new warnings (see Breaking Changes).

✨ Sources & mounts

  • [[source.mounts]] — fold an external tree into your document set

    A generated, vendored or independently-built tree of .rst/.md files becomes part of the project with no copying, symlinking or staging. Each entry declares a dir (walked with its own include / exclude / gitignore) or an explicit files list, a mount_at docname prefix, and an optional attach_to host document whose table of contents gains the entry (toctree_index, entry_doc and attach_each tune the wiring):

    [[source.mounts]]
    dir       = "../bazel-bin/docs/api"
    mount_at  = "_generated/api"
    attach_to = "index"
    

    Mounted files are confined to their root — a path escaping it is reported as build.mount_path_escape — and a mounted document that would take a host document’s name loses, with a diagnostic. sphinx-build reads the same table via sphinx-mounts 0.2.0. See Mounts: extra source trees.

  • [source] dir — the project’s source root (supersedes [project] srcdir)

    The directory your documents are discovered from now lives in [source], under the same key name a [[source.mounts]] entry uses for its own root. Setting it also anchors every discovery glob at each source root, so one written pattern means the same thing under your own tree and under every mounted one — and it may point outside the configuration folder (dir = "../sources"), which srcdir could spell but never made work. sphinx-mounts 0.2.0 reads the same key when anchoring variant rules. See dir, and the deprecation under Breaking Changes.

  • Variant-gated source selection: [[source.variant_sources]]

    Decide which files are part of the build, per build variant. Each rule pairs an if condition with a set of files globs; every rule whose condition is false removes its files, and a file no false rule matches is unaffected. Rules only ever narrow, and their order does not matter. A removed file is never read — no page, no document name, no needs, and nothing in it reaches search, objects.inv, cross-references or needs.json. A toctree entry naming a removed document reports the informational toctree.variant_excluded — naming the condition — instead of a missing-document warning, so a 150% index page stays green under ubc check. sphinx-build narrows the same document set via sphinx-mounts 0.2.0 (0.1.x does not read the key), held to the same condition grammar by a shared, machine-checked conformance corpus. See 5. Selecting whole files with variant_sources.

  • if on a [[source.mounts]] entry — gate a whole bundle per variant

    Where a variant_sources rule narrows a file set by glob, if on a mount removes the whole bundle: it contributes no documents, wires nothing into its attach_to host, and its pages leave every index. It needs no globs, so it can gate a tree outside the project, and it is the only way to gate a files mount. Both gating keys share one condition grammar and one validator, so a condition string means the same thing wherever it is written. Reported once per gated mount as config.mount_gated (informational). sphinx-build honours the same key via sphinx-mounts 0.2.0 or newer. Neither tool can see the other’s version while it builds, so these release notes are the whole mechanism: an older sphinx-mounts does not gate the bundle — it reports an unknown key and publishes the mount anyway. Upgrade both together. See Gating a whole bundle with a mount if.

  • std.file_outside_roots — a discovered file that no root claims

    A source file that matches your patterns but lies outside the source root and outside every mount root is not discovered at all — no page, no document name, nothing a :doc: or toctree entry can resolve. This warning is the only thing that will tell you it is there, and it names the ways to act: move it under a root, add it to [source] extend_exclude, or .. include:: it from a document (a fragment some document already pulls in is exempt). It cannot arise in the default layout, where the configuration folder is the root. See Linting.

✨ Needs

  • list2need creates its needs

    The directive was recognised and then ignored: it rendered a placeholder, the needs it described did not exist, and every :need: role pointing at one raised a false needs.link_ref. Each bullet is now a real need — filters, needtable, needs.json, schema validation, needextend, hover, goto-definition and find-references all work on it. :presentation: is applied: nested (the default) draws a child item’s card inside its parent’s, standalone keeps every card a sibling; an item’s content is parsed as markup in the host document’s own coordinates. Sphinx-Needs 8.4.0 rebuilt the directive the same way upstream and gained Markdown support and true line numbers with it; what stays ubCode’s own is the editor integration — goto-definition lands on the (ID) token the author wrote. Auto-generated ids are byte-exact against Sphinx-Needs 8.3.1, which is one release behind 8.4.0’s id change for bracket-carrying items. Twelve inputs that abort a Sphinx build are needs.list2need_* warnings naming the offending line or option; the item is skipped — the block, for a missing :types: or an empty body — and everything else still builds.

  • A need written inside another need records it as its parent

    parent_needs now carries the id of the nearest enclosing need — the way a Sphinx build always has — and parent_need is that list’s first entry. It is derived for an authored nested directive and for a list2need item under :presentation: nested alike. Backlinks, the *_back filter surface, needtable columns, needs.json and the card’s meta rows all pick it up with no new configuration. A :delete:d need never enters the chain, and intermediate containers — an admonition, a list item, an if body, an .. include:: splice — are transparent. See the derivation rule under Breaking Changes.

  • A need records the sections it is written under: sections and section_name

    sections carries the enclosing section titles, innermost first; section_name is the innermost one. Both mirror Sphinx-Needs, and both are filterable and projectable everywhere: the python :filter: dialect, a :cypher: query (and so the MCP query_cypher tool), needtable :columns: and :sort:, ubc query filter, and needs.json. A needtable naming section_name in :columns: used to render an empty column beside a “not a known column” warning; those columns now render and the warnings are gone. See The section-location node properties.

    Warning

    Both fields are core fields, so the first ubc diff across the upgrade reports them as added on every need under a heading — and renaming a heading now reports a change on every need under it, which then enters impact propagation. Exclude them per run with ubc diff --deny-core sections --deny-core section_name.

  • A project-wide default card design: [needs] default_layout

    Every need with no :layout: of its own is drawn with the named design instead of the hardcoded clean. This is Sphinx-Needs’ canonical TOML spelling of needs_default_layout, so one shared ubproject.toml now means the same thing to sphinx-build and to ubCode; previously ubCode did not read the key and the two engines disagreed in silence. Precedence, top wins: an authored :layout:, a [needs.fields.layout] predicates row, that field’s default, then default_layout, then clean. An unknown name is reported once at configuration time. See default_layout.

  • needflow speaks a portable presentation vocabulary

    :direction: (down / up / right / left, plus the TB / TD / BT / LR / RL aliases) means the same thing on both diagram engines; :show_link_names: takes a value (none / outgoing / incoming / type); and :show_legend: can name a [needs.flow_legends] configuration — an ordered legend of the types and link types you choose, placed where you say. Four new [needs] keys carry the project-wide defaults: flow_direction, flow_show_links, flow_show_legend and flow_legends. The same options shipped in Sphinx-Needs 8.4.0, so one source means one diagram under both tools. A legacy :config: naming a direction that disagrees with :direction: is reported as needs.option_conflict (the option wins). See Needs.

  • A needtable :cypher: query can name the table’s columns

    A query returning more than the matched node is a projection: the RETURN clause’s columns become the table’s columns, AS names the headers, and each row still links to its need. collect(o) or collect(o.id) in the final RETURN renders as a cell of linked chips, so one row can carry a whole neighbourhood. A single-node RETURN stays a selection and takes its columns from :columns:, as before. See Projections in a needtable.

  • A Cypher query naming absent vocabulary is diagnosed

    A :cypher: filter that names a field, label or link type the project does not define — n.staus, :SPEC, [:implments] — now reports needs.cypher_vocabulary, with a did-you-mean, instead of silently matching nothing. It is a warning: on the default deny = "warning" gate a project carrying such a typo starts failing where it previously exited 0 — silence it with ignore = ["needs.cypher_vocabulary"] under [lint] while you fix the query. A known field merely unset on some needs is never reported.

✨ Markup & rendering

  • The index directive and role render like Sphinx — and their entries are searchable

    .. index:: (and MyST {index}) no longer prints a placeholder box into the page — it renders only its anchor, exactly as a Sphinx build does, and the :index: role renders its display text as ordinary prose. The editor’s Rendered preview keeps a small chip listing the entries while you author. Every entry is parsed the way Sphinx parses it — single: / pair: / double: / triple: / see: / seealso:, the leading ! main flag and the comma shorthand — and filed against its anchor: a word you put in .. index:: and nowhere else in your prose is findable in the search of a site built with ubc build html (alpha), and answers the search_references MCP tool as an index:entry hit. A malformed entry is reported (index.invalid_entry, index.legacy_prefix) instead of accepted in silence. There is still no genindex.html page.

  • The :download: role

    :download:`assets/report.pdf` publishes that file alongside your site and links it as a download, in the editor preview and in ubc build html (alpha) alike. The published file keeps its own name, one file linked from many pages is copied once, and a target containing :// is linked as an external URL and never copied. A target that names no readable file keeps its text and loses its link, as a Sphinx build does — reported while you type (download.not_readable) rather than only when the site is built. In the preview, clicking a download link opens the file with your operating system’s application for it. A :download: inside imported need content is refused with a diagnostic and never published. See Downloads.

  • Markdown link destinations are cross-references

    A Markdown link destination no longer has to be a web address: [](#target-name) reaches a target or label, [](other.md#section) reaches another document and a place inside it, [](notes.txt) publishes a file for the reader, and [](inv:#target) searches another project’s intersphinx inventory. A link you gave no text takes the target’s title, and a destination that resolves to nothing keeps its text and is reported — rather than being silently deleted from the page as a Sphinx build does. Three new [parse.parsers.md] keys tune it: url_schemes declares custom schemes (optionally rewriting them from a template), links_external_new_tab opens external links in a new tab, and all_links_external turns destination lookup off entirely — the escape hatch if the new resolution reports links you meant literally. See Cross-referencing with links and Markdown link cross-references.

  • Line numbers and emphasised lines in code blocks

    code-block, sourcecode, code and literalinclude now apply :linenos:, :lineno-start:, :number-lines: and :emphasize-lines:, and literalinclude applies :lineno-match: — in the editor preview and the built site. The numbers are drawn beside the code, so they can never be copied with it. .. highlight::’s :linenothreshold: numbers every long-enough literal block below it — including a plain :: block and a Markdown fence — exactly as in a Sphinx build. A malformed option is reported instead of ignored, and the block still renders in every case, unlike a Sphinx build, which drops it.

  • CSS classes: the rst-class and cssclass directives

    Both attach CSS classes to your content rather than rendering a placeholder box. With a body, the body’s blocks carry the classes; without one, the class attaches to the block that follows — and, when the directive is the last thing in a section, to the next section, matching a Sphinx build exactly. An element that cannot carry a class is reported (directive.class_unsupported_target) rather than silently dropping it. Bare .. class:: is no longer an unknown directive: it is the Python domain’s object directive under Sphinx’s default primary_domain — use rst-class to attach a class. See CSS classes.

  • The inline raw role, and fifteen more core constructs

    .. role:: raw-html(raw) with :format: html defines a working pass-through role; raw_enabled = false under [parse] now switches the role off too, with its own code, so the directive’s warning and the role’s can be silenced independently. .. acks:: renders its bullet list; the :manpage:, :mailheader: and :newsgroup: roles render; sectionauthor, moduleauthor, codeauthor, header, footer and meta are accepted and render nothing — which is what a default-configured Sphinx HTML build produces — instead of printing a placeholder box of your authoring metadata into the reader’s prose. Sphinx 9’s hyphenated version-added / version-changed / version-deprecated / version-removed markers work, and describe, object and section-numbering are recognised. See Raw content.

✨ The HTML build (alpha)

  • Page redirects: [build.html.redirects]

    Maps the path of a page that moved to the page it became, and the build writes a small forwarding page where the old one used to be — keeping any query string and fragment the reader came with. A target may name a section of its page or an external URL; chains resolve to their end; removing an entry removes its forwarding page on the next build. A faulty entry is reported and skipped, leaving the rest of the table building. This is the sphinxext-rediraffe port. See redirects.

  • Page <head> meta tags

    .. meta:: (reStructuredText and MyST alike) and MyST html_meta: front matter now write <meta> tags into the built page’s <head>, where a Sphinx build puts them — description and keywords finally reach the built HTML, and hand-written property=og:… tags work today. The directive’s body is validated: a malformed body is reported (meta.invalid_body, meta.invalid_attribute) and the fields that could be read are kept, where docutils drops the whole directive. A page that writes none is byte-identical to before. See Page <head> meta tags.

  • A page can override its browser tab title

    .. title:: Some Other Title (and the MyST {title} fence) now sets what the browser tab and the search results show — without renaming the page anywhere else. The heading stays the heading: the sidebar entry, the previous/next labels, the parent’s table-of-contents caption and the objects.inv export all keep showing your H1, which is Sphinx’s own two-channel split. The directive also stops printing a placeholder box into the page, in the editor preview as well as the built site. See A page can override its browser title.

✨ Querying

  • ubc query search — find what your project can cross-reference

    A new ubc query subcommand that searches everything the project can cross-reference — its own :ref: labels, glossary terms and documents, its filed index entries, and every entry of every loaded intersphinx inventory — and returns ranked hits, each carrying the exact role to paste where one exists. Filter with --kind (label / term / doc / index, or a domain:objtype glob) and --scope (local / external / one intersphinx project), choose --format table or json (the same envelope the search_references MCP tool answers with), and script it on the exit code. Passing no query lists every reference by name, so ubc query search --kind label is “list this project’s labels”. See Finding what you can reference.

👌 Improvements

  • A :cypher: may be paired with the python filter surfaces. A view directive carrying both no longer raises needs.filter_conflict — which, on the default gate, failed the build for exactly the portable idiom the docs recommend: ubCode evaluates the Cypher, a Sphinx build evaluates the python surfaces, and Sphinx-Needs 8.4.0 accepts and ignores :cypher: for exactly this purpose. The code is retired; a stale [lint] ignore entry for it is inert.

  • A need card labels its link rows with the link type’s configured titles. A row now reads the outgoing title of its link type, and an incoming “links back” row reads the incoming title, instead of the raw type name followed by (incoming) — so the builtin parent_needs rows read parent needs and child needs, the same words a Sphinx build writes.

  • A field or link named content is no longer declared in silence. content is a need’s own body and no declaration can shadow it, so filters, tables, variant conditions, report templates and needs.json all answer the body for that name and the declared value is invisible to them. The declaration is kept — nothing that renders today stops rendering. It is now reported as config.field_shadowed_by_identity on all four declaration surfaces; silence it with ignore = ["config.field_shadowed_by_identity"] under [lint] while you migrate.

  • Report templates see the need’s context fields. .html.j2 templates now expose content, docname, lineno, sections, section_name and the origin flags alongside the need’s declared fields, through the same projection every other need surface reads.

  • Variant data is resolved while the configuration is loaded, so it is available to source discovery; the merged value and its diagnostics are unchanged. Sphinx-Needs 8.4.0 makes the same move upstream.

  • [source] extend_exclude and extend_include accumulate across an extend chain instead of a child’s list replacing its parent’s, matching ruff. See Sharing configuration.

  • A missing source root is reported, not fatal. A root that is absent or not a directory is reported as config.source_dir_missing and the project’s existing pages are retained rather than reading as wholesale deletions; ubc build list-documents prints configuration diagnostics to stderr before the list, so an empty list arrives with its reason.

  • An in-project :ref:`search` links the built search page when the build writes one, matching a Sphinx build — and a project defining its own search label wins, for the reference and the objects.inv row alike. A no-title :index: role that looks like a typed entry gets the informational index.role_type_prefix advisory, naming the rewrite.

  • Navigation titles keep their inline markup. A document title carrying code, bold or a :ref: reached the sidebar, the prev/next footer and an in-page .. toctree:: as plain text — a :ref: simply vanished. All four surfaces now render the title’s markup. The sidebar logo renders centred at its intrinsic width, as furo’s does.

  • The search_references MCP tool answers an empty query with a name-ordered listing of everything the project can cross-reference, instead of an empty result.

‼️ Breaking Changes

  • Discovery follows the declared roots

    Discovery walked the whole configuration folder while document names were only derived for the source root and the mount roots. A file in that gap was walked, parsed and indexed — and then had no document name at all: no page, no toctree slot, and its needs sat in the index as unbuildable ghosts. The walk now covers exactly the declared roots, so a file outside every root is no longer discovered: it leaves needs.json and ubc query with the page it never had. std.file_outside_roots (above) is what reports it. A project that sets neither [source] dir nor [project] srcdir is unaffected — the configuration folder is the root.

  • [project] srcdir is deprecated

    It keeps working, unchanged and frozen: with [source] dir unset, every code path behaves exactly as it did. Setting both is the legitimate mixed-version bridge — dir wins, and config.source_dir_superseded says which key is ignored. A project on srcdir alone gets config.srcdir_deprecated, which lists every discovery glob that migrating would require rewriting, with the rewritten spelling where it can be computed. Both codes can be silenced individually via [lint] ignore.

  • New warnings may appear on an unchanged project

    This release adds around sixty-five new diagnostic codes — about fifty-five of them warnings — and on the default gate (deny = "warning") a warning fails ubc check and ubc build html. The likeliest to fire on a project that was clean on 0.32.0: config.srcdir_deprecated (every project still on [project] srcdir), std.file_outside_roots, config.field_shadowed_by_identity, needs.cypher_vocabulary, the newly resolved Markdown link destinations (std.doc / std.ref on links that used to pass through as text — all_links_external = true restores that), and the newly applied directive.code_block_* / directive.class_* / index.* / meta.* / role.raw_* families on previously-silent authoring mistakes. Silence any of them individually or by family under [lint]: ignore = ["directive.class_*"].

  • parent_needs is derived, not authored

    Writing :parent_needs: on a need directive — or ((parent_needs="…")) on a list2need item — is now reported as needs.option_derived and the value discarded. (A Sphinx build accepts the option and discards the value with no diagnostic at all.) A project that declared [needs.links.parent_needs] itself keeps the option but loses authored values: they are derived from nesting instead. Declare [needs.fields.parent_needs] to keep an ordinary, authorable field of that name.

  • Variant conditions and [[copy()]] over need context fields now resolve

    A variant condition or [[copy(...)]] naming section_name, docname, content, lineno, sections or an origin flag previously resolved to nothing and silently took its fallback arm; it now resolves, as it does in Sphinx-Needs — resolved field values in needs.json change for such projects with no source edit. A condition written with the n. prefix likewise starts matching. An unknown name is no longer silent: it is reported under needs.variant with a suggestion. A project that declares its own field named content moves in the opposite direction: the identity field cannot be shadowed, so these surfaces now read the need’s body where they previously read the declared field.

  • needflow‘s :show_link_names: wins over the configuration

    The option and [needs] flow_show_links used to be OR-ed, so configuration could only ever turn labels on; only an unset option consults the configuration now. This is what makes :show_link_names: none able to unlabel one diagram in a labels-on project. The same change shipped in Sphinx-Needs 8.4.0.

  • A needtable :cypher: query’s order is honoured

    The default id_complete sort was applied unconditionally, so a :cypher: carrying an ORDER BY rendered in id order. With a :cypher: present and no :sort:, the query’s returned order is now the row order — which also means a query with no ORDER BY renders in returned order (case-sensitive, or match order for a traversing query) where the old sort was case-insensitive id order. Add :sort: id_complete to keep the previous order.

  • A built site’s objects.inv no longer advertises genindex, modindex or py-modindex

    Those labels named pages ubc build html (alpha) does not build, so an intersphinx consumer that resolved one followed a link to a file that was never written — silently. The inventory now promises only the page set the build produces (the search label stays), and a downstream project referencing them gets an actionable unresolved-reference warning instead of a dead link. Referring to those names from inside your own project is unaffected.

  • One-time full re-index and re-render on upgrade

    The cache format moved (6.116 → 6.145) and the build manifest with it (73 → 84). The first run after upgrading rebuilds the index and re-renders every page once — about the cost of a cold build — then incremental behaviour resumes. Self-healing, nothing to delete by hand. Note that each build variant keeps its own cache directory, so per-variant CI pays a cold index per variant.

🐛 Fixes

  • An unresolvable predicates name no longer deletes the need. One bad name in one [needs.fields.*] predicates expression removed every need the predicate touched from the index, leaving a single warning and exit code 0 — so filters, tables, schema validation and needs.json all saw an empty project. It is now reported (with a did-you-mean), the predicate is treated as non-matching, and the field takes its plain default. A project affected by this will see needs — and every dead link and schema violation those needs generate — that the previous build was hiding. (Sphinx-Needs 8.4.0 ships the same fix upstream.)

  • The shorthand :cypher: form is vocabulary-checked. A :cypher: written as a bare expression (n.staus = 'open') produced no vocabulary notes, because only the full-query form was ever parsed. Both accepted forms are checked now — and the query_cypher MCP tool keeps its did-you-mean notes for a comment-prefixed query, which were previously dropped.

  • A [[source.mounts]] dir mount’s own include is now honoured; it was computed and then never read, so a mount could neither narrow nor widen its own file selection.

  • A mounted file selected by a path-prefixed parser include now routes to the same parser in the editor as in the index, instead of falling back to the editor’s language id.

  • [[source.variant_sources]] rules whose globs name a path now reach mounted trees, not only rules whose globs name a bare file name.

  • objects.inv no longer emits two search rows for a project that defines its own search label, and diagnostics raised about an :index: role now point at the role instead of at line 1 of the file.