Tooling for building a Documenter site as independent fragments. Each package keeps its own slice of the documentation in its own repository, builds and checks it on its own CI, and the fragment is later composed into a full site without source changes.
A fragment lives in a package's docs/ directory:
MyPackage/
Project.toml
docs/
fragment.toml declarative metadata (see below)
make.jl one-liner calling build_fragment
references.bib optional bibliography (see "Fragment bibliographies")
src/
introduction.md
docstrings.md an @autodocs page over the package's modules
assets/... images and other page assets (travel with the pages)
docs/make.jl is just:
using DocumenterFragments: build_fragment
build_fragment(@__DIR__)There is no using MyPackage; build_fragment loads the modules named in
fragment.toml and resolves docstring references against them (see "Module scope").
name = "Widgets" # section title in the main site and standalone sitename
modules = ["Widgets", "WidgetsCore"] # drives @autodocs coverage / checkdocs
doctest_setup = "using Widgets, TestData" # applied via DocMeta.setdocmeta!
doctest_teardown = "Widgets.reset!()" # optional, runs after each doctest block
bibliography = "references.bib" # optional, relative to docs/
composedref_modules = ["WidgetsBase"] # optional, see "Linking to a dependency's docstrings"
[[pages]]
title = "Introduction"
file = "introduction.md"
[[pages]]
title = "Functions"
[[pages.children]]
title = "Reading data"
file = "functions/read.md"The metadata is the single source of truth: build_fragment reads it for the
standalone build, and the main site build reads the same file to assemble its
navigation, union the module lists, and replay each fragment's doctest setup.
doctest_teardown becomes Documenter's DocTestTeardown, which runs after every
doctest block of the fragment's modules, even a failing one. Use it to restore
process-global state the setup changes (e.g. ENV["COLUMNS"]). Doctests run
before @example blocks are expanded, so without a teardown such a change leaks
into every @example block of the whole build.
Placement in the larger site is deliberately absent: the mount path (e.g.
/widgets/) and anchor namespace prefix are assigned by the main site at
composition time. Each fragment normally gets its own mount, but several can share
one (see "Sharing a mount").
A fragment owns its docstring page (an @autodocs/@docs page listed in
fragment.toml). build_fragment runs makedocs with the modules and a hard
checkdocs (it does not downgrade missing_docs to a warning), so a docstring
attached to a name in those modules that appears on no page fails the fragment's
own CI. At composition the page and its modules travel into the main site, so
coverage is re-checked there with no separately-maintained per-module page.
The check only bites when docstrings are placed selectively. @autodocs over a
whole module splices everything, so nothing is ever missing and the check passes
trivially; curated @docs blocks (or @autodocs with Pages/Filter) make it
fail when a new docstring is left unplaced. Choose the policy per fragment. The
level is the checkdocs keyword on build_fragment (:all default, or
:exports/:public/:none); match the @autodocs visibility (Private) to it.
A fragment's docstring references ([`make_widget`](@ref)) resolve against the
fragment's own modules, not against whatever else happens to be loaded. For each
fragment, the build loads the modules named in fragment.toml, creates a small
scope module that usings exactly those, and points Documenter's CurrentModule
at it (via an injected @meta block on each page). So:
- The fragment author writes no
using; the metadata module list is the only place packages are named. - Resolution is identical standalone and composed: a ref that only resolves because another package is co-loaded in the main site fails the fragment's own CI instead of silently binding to the wrong docstring.
The main site's make.jl calls integrate_fragments, listing each fragment with the mount
path it assigns:
using DocumenterFragments: integrate_fragments
c = integrate_fragments(main_src, [
(; dir = "…/Widgets.jl/docs", mount = "widgets"),
(; dir = "…/Gadgets.jl/docs", mount = "gadgets"),
])
makedocs(;
modules = c.modules, # union of all fragments' modules
pages = Any["Home"=>"index.md"; [f.pages for f in c.fragments]],
source = main_src,
plugins = c.plugins, # namespacing, and citations if any
# ...usual HTML options...
)integrate_fragments copies each fragment's sources (pages and assets together) into
main_src/<mount>/, runs DocMeta.setdocmeta! for each fragment's modules, and
returns the unioned module list, a fragments vector carrying each fragment's
mount-prefixed page tree, and the plugins to pass to makedocs. The pages are returned per
fragment rather than pre-merged so the main site controls where each section sits
in its navigation. The main site then runs a single makedocs, so the whole site
shares one theme, one search index and one navigation tree.
The returned plugins must be passed to makedocs; without them fragment pages
build un-namespaced and collide (see "Anchor namespacing"). It always contains
the namespacing plugin and, if any fragment has a bibliography, the merged
CitationBibliography (see "Fragment bibliographies").
integrate_fragments assigns each fragment a unique anchor namespace derived from
its mount, so the caller only chooses mounts. The namespace never appears in a
URL or in anything an author writes (@ref links are rewritten automatically), so
it is not a spec option.
Several fragments can share one mount to produce flat, intermixed URLs (e.g.
Widgets and WidgetsPlots both under /widgets/, giving /widgets/introduction
and /widgets/plots side by side):
c = integrate_fragments(main_src, [
(; dir = "…/Widgets.jl/docs", mount = "widgets"),
(; dir = "…/WidgetsPlots.jl/docs", mount = "widgets"),
])Each fragment's sources are merged into the shared main_src/<mount>/, and its
module scope and anchor namespace are applied to its own pages only. So docstring
references resolve against the fragment that authored them, and each fragment keeps
its own namespace even though the pages sit side by side: a ## Examples heading in
one does not collide with a ## Examples in the other. The integrator makes the
per-fragment namespaces unique automatically (fragments sharing a mount cannot each
derive a unique namespace from it), so this needs no coordination.
The one real constraint is that file paths must not collide: the fragments' page
and asset paths (relative to each src/) are merged into one directory, so two
fragments both shipping docstrings.md is an error. integrate_fragments reports
the offending path and the fragment that introduced it; rename the file in one of
them.
The main site can relocate individual fragment pages in its navigation. List a
page's fragment-relative path in the spec's detach, and integrate_fragments omits it from
that fragment's page tree and returns it under f.detached, keyed by that path:
c = integrate_fragments(main_src, [
(; dir = "…/Widgets.jl/docs", mount = "widgets", detach = ["docstrings.md"]),
(; dir = "…/Gadgets.jl/docs", mount = "gadgets", detach = ["docstrings.md"]),
])
pages = Any[
"Home"=>"index.md",
(f.pages for f in c.fragments)..., # fragment sections, minus their detached pages
"Docstring Index"=>Any[ # gathered wherever the main site wants them
f.name => f.detached["docstrings.md"].second for f in c.fragments
],
]f.detached["docstrings.md"] is a title => mounted-path pair (the title the
fragment gave it, e.g. "API" => "widgets/docstrings.md"); reuse the title or
supply your own. Only the navigation position changes: the page still lives under
the fragment's mount, so its namespace, module scope and assets are unaffected.
integrate_fragments builds each fragment's module scope at runtime (see "Module scope"),
which advances the method world age. Call makedocs as a top-level statement in
make.jl, as above, so it runs in the new world and sees those scopes. If you wrap
the build in a function, call it as Base.invokelatest(makedocs; ...).
Two invariants the integrator enforces:
- Each module is owned by exactly one fragment.
integrate_fragmentserrors if two fragments declare the same module, sinceDocMeta.setdocmeta!is last-writer-wins and Documenter rejects a docstring spliced from two places. (Name-level check; overlapping submodule trees viarecursive = trueare backstopped by Documenter's own duplicate-docstring error.) - The main site's docs environment is a superset of every fragment's: a
fragment's
doctest_setupmust load in both its owndocs/Project.tomland the main site's docs env, so a newdoctest_setupdependency is also a main-site env change.
Because everything moves as one subtree and Documenter recomputes its own internal
links (inter-page links, @ref, inserted images) for each build, those survive the
move under /<mount>/ with no rewriting. Manually written relative links (e.g. a
raw <img src="../assets/x.png">) are not recomputed and can break on integration
if the main site's prettyurls differs from the standalone build they were authored
against. build_fragment takes a prettyurls keyword (default true); whichever
value a fragment builds with, the main site's makedocs should use the same.
Documenter resolves section references across the whole doc set, so two fragments
that both define a ## Examples section would collide once composed. The
namespacing plugin from integrate_fragments registers a build stage (a
Documenter.Builder.DocumentPipeline stage, ordered before cross-reference
resolution) that walks each fragment page's parsed MarkdownAST and, for that
fragment's assigned namespace, rewrites headings to carry an explicit @id
prefixed with the namespace (widgets-...) and prefixes matching section
@ref/@id link targets. It operates on the typed MarkdownAST Documenter
already built, not on the Markdown text, so code blocks, spans, and docstring refs
are recognized by node type and left untouched, with no parsing round-trip. This
is the same extension mechanism DocumenterInterLinks uses to resolve @extref
links.
Namespacing is a placement concern, so it happens only at composition. A
standalone fragment build is un-namespaced: it builds like an ordinary small
Documenter site, with clean anchors (#Examples). This loses nothing, because a
single fragment's CI catches exactly the same content errors either way:
- broken intra-fragment links, missing docstrings, failing doctests and duplicate headers within the fragment all fail the standalone build.
- a link to a section owned by another package (a main site often does this freely, e.g. one package linking to a Plots section) fails the standalone build too, because the target id simply does not exist in the fragment.
- a namespace collision between two fragments is invisible to any single fragment by definition, so it is checked only at composition.
So the split is: the fragment CI validates content; the main site assigns placement (mounts, and a unique namespace per fragment) and validates cross-fragment policy. In v1 this is intentional: fragments stay decoupled and do not coordinate anchor names.
Docstring links into another fragment's modules are the one supported exception,
via @composedref (see "Linking to a dependency's docstrings"). Section links across
fragments remain unsupported; if ever wanted, DocumenterInterLinks could resolve
them against a deployed site's objects.inv inventory, and the per-fragment
namespacing already makes every anchor globally unique, so that could be added
without redesign. It is not the mechanism for docstring links between fragments,
though: fragments deploy no docs of their own, so the only inventory available
would be the previously deployed composed site, which cannot validate against the
dependency versions actually being built and cannot cover names added since the
last deploy.
A fragment's @ref links deliberately resolve only against its own modules (see
"Module scope"), so they cannot reach the docstrings of another package. For
packages the fragment depends on anyway (typically the central package whose
objects it builds on), @composedref links provide that. The fragment declares which
modules it may link into:
name = "WidgetsPlots"
modules = ["WidgetsPlots"]
composedref_modules = ["Widgets"]and its pages (and docstrings) link with a qualified target, either as the link's
code text or as an explicit name after @composedref:
Plots the output of [`Widgets.make_widget`](@composedref), see also
[the widget builder](@composedref Widgets.make_widget).The declared modules must be loadable in the fragment's docs environment, which
for a real dependency they already are; nothing is fetched and no inventory is
involved. That makes the declaration the policy surface: linking into a package
means adding it to composedref_modules (and, if it was not one already, to the docs
environment), a reviewable statement of coupling rather than something that works
silently because a module happens to be loaded.
The fragment's own build validates every @composedref against the loaded module: the
target must be qualified, its module declared, and the binding must carry a
docstring, in exactly the dependency version the docs environment resolves, so
developing against an unreleased dependency works like any other dev workflow.
Since no fragment page holds the dependency's docstrings, the standalone build
generates a Docstrings Available at Composition page collecting exactly the referenced ones
(marked as standalone-only, like the generated home page), and the links lead
there, so a preview shows what each link will point at.
At composition, each @composedref is resolved into a real link to the docstring's
anchor on the page that carries it. That page can belong to the fragment owning the
target module, or to the main site itself: while a site migrates to fragments
piecemeal, the central package's docstrings typically still live on main-site
pages, and the integrator declares that with
main_mods = [Widgets]
c = integrate_fragments(main_src, specs; main_modules = main_mods)
makedocs(; modules = [c.modules; main_mods], ...)Two things are enforced at composition: integrate_fragments errors if a declared
composedref module is neither owned by a fragment nor listed in main_modules
(a submodule counts as provided by whoever provides its parent), and
the build errors if the target's docstring is rendered on no composed page (e.g.
filtered out by whoever provides it). So a fragment can be green while the
composition is red, but only through placement changes on the providing side, and
the error names the link, page and target.
Targets are docstrings only; linking a dependency's sections is not supported (a module carries no section anchors to validate against).
A fragment can own a bibliography. It names a BibTeX file in fragment.toml
(bibliography = "references.bib", resolved relative to the fragment's docs/),
and its pages then use @cite links and @bibliography blocks as in any
DocumenterCitations site. build_fragment constructs the CitationBibliography
plugin itself, so make.jl stays a one-liner, save for loading the package that
provides the plugin type:
using DocumenterFragments: build_fragment
import DocumenterCitations
build_fragment(@__DIR__)DocumenterCitations is a weak dependency: it has to be loaded by the make.jl
of the fragment and of any main site composing it, but a fragment that does not
cite anything neither loads it nor pays for it. To choose a style or otherwise
configure the plugin, pass your own in plugins; build_fragment then leaves it
alone.
Documenter keys plugins by type, so a composed site holds exactly one
CitationBibliography and a fragment cannot carry its own into it. Instead
integrate_fragments reads every fragment's bibliography and merges the entries
into a single plugin, returned in plugins. A main site with a bibliography of
its own passes it in and it is merged too, along with its style:
c = integrate_fragments(main_src, specs;
citations = CitationBibliography(joinpath(@__DIR__, "references.bib")),
)Citation keys are not namespaced, unlike heading anchors. Two fragments writing
## Examples is entirely likely; two fragments picking the same BibTeX key for
different works is not, since the usual key is author plus year plus a title
word. What does happen is the same entry being copied into two fragments from a
common source, and then sharing a key is right: the merged bibliography carries
the work once and both fragments cite it.
So a shared key is allowed, but the entries have to match exactly. If they differ in any field the merge errors, naming the key, both contributors and the values that differ, since nothing downstream would notice one fragment silently citing the other's copy of a work whose title, year or authors have drifted:
Citation key "Knuth1984" is supplied by both fragment "Widgets" and fragment "Gadgets", but the entries differ:
date.year
fragment "Widgets": "1984"
fragment "Gadgets": "1986"
title
fragment "Widgets": "The TeXbook"
fragment "Gadgets": "The TeX book"
A key shared between fragments, or with the main site, must carry the same entry everywhere; reconcile the `.bib` files, or rename the key in one of them.
That leaves the coordination between fragment and main site owners at: keep shared entries identical, which for BibTeX is mostly a one-off, and rename a key if two works genuinely collide. In exchange a work cited by several fragments is listed once rather than once per fragment.
A fragment citing a key it does not supply itself is caught by its own build
rather than by the composed site: with only its own bibliography loaded,
DocumenterCitations fails the fragment's build with Key ... not found in entries. So a fragment cannot come to depend on another's entries as long as it
is composed from a version its own CI passed.
A @bibliography block is canonical (the DocumenterCitations default) when it
defines the link anchors its entries are cited by. A key can only be anchored
once, so a canonical block silently skips any entry another canonical block
already claimed. A fragment therefore cannot own one: composed, it would take
those entries from the site's own bibliography, and citations would lead to
whichever page happened to be expanded first.
So a fragment's @bibliography blocks must all say Canonical = false, which
the fragment's own build enforces, and the composed site holds the canonical
bibliography: it needs one unscoped @bibliography block on a main-site page.
Every fragment's citations then lead to that one central page, and a fragment's
own block is a plain re-listing of some works, which is what a "further reading"
section wants anyway:
## Further reading
General texts on widget design include:
```@bibliography
Pages = []
Canonical = false
Knuth1984
Lamport1994
```Composition errors if fragment pages carry citations and no canonical block is found outside the fragments. It can only check that such a block exists, not that it covers everything, so leave the main site's block unscoped.
A fragment still has to resolve its citations in its own build, where there is no
main site to hold the anchors. So a fragment that declares a bibliography gets
a generated References page carrying its whole bibliography, marked as being
for the standalone build only, in the same way it gets a generated index.md.
Nothing about the fragment's sources changes between building it alone and
composing it.
Two rewrites keep a block's meaning the same in both builds:
*means "every entry of my bibliography", which after merging would reach across fragments, so it is replaced by the keys of the fragment's own file.- A block with no
Pagesfield means "everything cited in this site", which composed likewise spans fragments, so it is scoped to the fragment's own pages.
Builds are configured to be quiet and strict, so a warning in CI is a real signal
rather than expected noise. build_fragment keeps warnonly empty (so Documenter
checks like missing_docs and cross_references are hard errors, see "Docstring
coverage"), sets repolink = nothing to drop the navbar repo-link warning under
remotes = nothing, and synthesizes a minimal index.md landing page for the
standalone build when a fragment has none. Run CI with --depwarn=error to also
make deprecations fatal.
A fragment whose only use of its bibliography is a @bibliography block with
explicitly listed keys, and no @cite anywhere, triggers DocumenterCitations'
"There were no citations" warning: the Pages-filtering branch suppresses that
warning when explicit keys are given, the no-citations branch does not. That is
an upstream gap and is not worked around here.
DocMeta.setdocmeta! is left to warn on overwrite (it warns on any re-set, not
just a changed value). The warning is kept on because its meaningful case is two
fragments putting a different doctest setup on the same module, or a shared
submodule via recursive. The cost is that a second build in the same process
(an iterative rebuild) also warns; clear DocTestSetup (and DocTestTeardown)
between builds if that matters.