Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DocumenterFragments

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.

How a fragment is laid out

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").

fragment.toml

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").

Docstring coverage

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.

Module scope

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.

Composition into the main site

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.

Sharing a mount

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.

Rerouting pages

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_fragments errors if two fragments declare the same module, since DocMeta.setdocmeta! is last-writer-wins and Documenter rejects a docstring spliced from two places. (Name-level check; overlapping submodule trees via recursive = true are 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_setup must load in both its own docs/Project.toml and the main site's docs env, so a new doctest_setup dependency 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.

Anchor namespacing and cross-package links

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.

Linking to a dependency's docstrings

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).

Fragment bibliographies

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.

Who owns the canonical bibliography

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 Pages field means "everything cited in this site", which composed likewise spans fragments, so it is scoped to the fragment's own pages.

Warnings

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.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages