From b6bc5731690f8ea3615518d829f6f655fd974b4b Mon Sep 17 00:00:00 2001 From: James Garner Date: Tue, 18 Aug 2026 15:25:21 +1200 Subject: [PATCH] docs: switch to pre-processor pattern for interface docs --- .docs/extensions/interface_docs.py | 101 +++-------- .docs/scripts/interface_preprocessor.py | 119 +++++++++++++ .docs/tests/test_interface_docs.py | 78 +++++++++ .docs/tests/test_interface_preprocessor.py | 184 +++++++++++++++++++++ CONTRIBUTING.md | 6 +- docs.just | 10 +- 6 files changed, 415 insertions(+), 83 deletions(-) create mode 100755 .docs/scripts/interface_preprocessor.py create mode 100644 .docs/tests/test_interface_docs.py create mode 100644 .docs/tests/test_interface_preprocessor.py diff --git a/.docs/extensions/interface_docs.py b/.docs/extensions/interface_docs.py index e8dc77233..f538c5b86 100644 --- a/.docs/extensions/interface_docs.py +++ b/.docs/extensions/interface_docs.py @@ -12,94 +12,41 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Include interface reference docs in docs site.""" +"""Sphinx fallback extension for interface reference docs. + +The ``interface_preprocessor.py`` script (run by ``just docs``) generates the +interface reference pages under ``reference/interfaces/``. + +This extension provides a fallback: if no interface reference pages exist — +because the preprocessor hasn't run — it writes a placeholder page so that the +glob toctree in ``reference/interfaces.md`` matches at least one document. If +the preprocessor has generated pages, it removes any stale placeholder instead. +""" from __future__ import annotations -import json import pathlib -import re -import subprocess import typing -#################### -# Sphinx extension # -#################### - if typing.TYPE_CHECKING: import sphinx.application - -def setup(app: sphinx.application.Sphinx) -> dict[str, str | bool]: - """Entrypoint for Sphinx extensions, connects generation code to Sphinx event.""" - app.connect('builder-inited', _interface_docs) - return {'version': '1.0.0', 'parallel_read_safe': False, 'parallel_write_safe': False} - - -def _interface_docs(app: sphinx.application.Sphinx) -> None: - if app.config.package is not None: - # For efficiency, don't write interface docs during the package reference docs passes. - # But we need to make sure something is there so that the TOC glob doesn't fail. - ref_dir = pathlib.Path(app.confdir, 'reference', 'interfaces') - ref_dir.mkdir(parents=True, exist_ok=True) - _write_if_needed(path=ref_dir / 'placeholder.md', content='# Temporary TOC placeholder') - return - _main(docs_dir=pathlib.Path(app.confdir)) +_PLACEHOLDER_NAME = 'placeholder.md' -#################### -# generation logic # -#################### - -INDEX_TEMPLATE = """ -({label})= -# {interface_name} - -```{{toctree}} -:glob: -:reversed: -:maxdepth: 1 - -{interface_name}/* -``` -""".strip() -REPO_MAIN_URL = 'https://github.com/canonical/charmlibs/blob/main' +def setup(app: sphinx.application.Sphinx) -> dict[str, str | bool]: + """Sphinx extension entrypoint — registers the fallback hook.""" + app.connect('builder-inited', _fallback) + return {'version': '2.0.0', 'parallel_read_safe': False, 'parallel_write_safe': False} -def _main(docs_dir: pathlib.Path) -> None: - """Write automodule file for package and placeholders rst files for all other packages.""" - root = docs_dir.parent - ref_dir = docs_dir / 'reference' / 'interfaces' +def _fallback(app: sphinx.application.Sphinx) -> None: + ref_dir = pathlib.Path(app.confdir, 'reference', 'interfaces') ref_dir.mkdir(parents=True, exist_ok=True) - (ref_dir / 'placeholder.md').unlink(missing_ok=True) - cmd = [root / '.scripts/ls.py', 'interfaces', '--exclude-examples', '--exclude-placeholders'] - interfaces = json.loads(subprocess.check_output(cmd, text=True)) - for path_str in interfaces: - interface_dir = root / path_str - interface_name = interface_dir.name - interface_ref_dir = ref_dir / interface_name - interface_ref_dir.mkdir(exist_ok=True) - label = f'interfaces-{interface_name.replace("_", "-")}' - index = INDEX_TEMPLATE.format(label=label, interface_name=interface_name) - _write_if_needed(path=ref_dir / f'{interface_name}.md', content=index) - for v in (interface_dir / 'interface').glob('v[0-9]*'): - readme_raw = (v / 'README.md').read_text() - base_url = f'{REPO_MAIN_URL}/interfaces/{interface_name}/interface/{v.name}' - # match all non-http(s) markdown links and prepend base_url to matching links - readme = re.sub( - r'\[(.+)\]\((?!https?://)([^)]+)\)', - lambda m: f'[{m.group(1)}]({base_url}/{m.group(2)})', # noqa: B023 - readme_raw, - ) - content = f'({label}-{v.name})=\n' + readme - _write_if_needed(path=interface_ref_dir / f'{v.name}.md', content=content) - - -def _write_if_needed(path: pathlib.Path, content: str) -> None: - """Write to path only if contents are different. - - This allows sphinx-build to skip rebuilding pages that depend on the output of this extension - if the output hasn't actually changed. - """ - if not path.exists() or path.read_text() != content: - path.write_text(content) + placeholder = ref_dir / _PLACEHOLDER_NAME + if any(p.name != _PLACEHOLDER_NAME for p in ref_dir.glob('*.md')): + # The preprocessor has generated the interface reference pages. + # Remove any stale placeholder so it isn't picked up by the glob toctree. + placeholder.unlink(missing_ok=True) + elif not placeholder.exists(): + placeholder.write_text('# Temporary TOC placeholder') diff --git a/.docs/scripts/interface_preprocessor.py b/.docs/scripts/interface_preprocessor.py new file mode 100755 index 000000000..b6b71c7e8 --- /dev/null +++ b/.docs/scripts/interface_preprocessor.py @@ -0,0 +1,119 @@ +#!/usr/bin/env -S uv run --script --no-project + +# /// script +# requires-python = ">=3.12" +# /// + +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generate interface reference docs in the Sphinx source tree. + +Walks every interface returned by ``.scripts/ls.py interfaces``, and for each +one writes an index stub with a glob toctree under ``reference/interfaces/``, +plus one page per ``interface/v[0-9]*/README.md`` with relative links +rewritten to absolute GitHub URLs. + +Run from ``just docs``; see ``docs.just`` for the invocation. + +This is a standalone preprocessor script rather than a Sphinx extension, +following the same pattern as ``diataxis_preprocessor.py``. Unlike the +package reference docs, interface docs don't use autodoc, so nothing here +needs to run inside a Sphinx build. Generating the pages once up front — +instead of in a ``builder-inited`` hook on every Sphinx pass — keeps the +per-package intermediate passes cheaper and the extension machinery simpler. +The companion ``interface_docs`` extension is now only a fallback: it writes +a placeholder page when this script hasn't run, so the glob toctree in +``reference/interfaces.md`` still matches at least one document. +""" + +from __future__ import annotations + +import json +import pathlib +import re +import subprocess + +_DOCS_DIR = pathlib.Path(__file__).parent.parent.resolve() +_REPO_ROOT = _DOCS_DIR.parent +_REPO_MAIN_URL = 'https://github.com/canonical/charmlibs/blob/main' + +_INDEX_TEMPLATE = """ +({label})= +# {interface_name} + +```{{toctree}} +:glob: +:reversed: +:maxdepth: 1 + +{interface_name}/* +``` +""".strip() + + +def _main() -> None: + """Write an index stub and rewritten version READMEs for every interface.""" + ref_dir = _DOCS_DIR / 'reference' / 'interfaces' + ref_dir.mkdir(parents=True, exist_ok=True) + (ref_dir / 'placeholder.md').unlink(missing_ok=True) + cmd = [ + _REPO_ROOT / '.scripts' / 'ls.py', + 'interfaces', + '--exclude-examples', + '--exclude-placeholders', + ] + interfaces: list[str] = json.loads(subprocess.check_output(cmd, text=True)) + for path_str in interfaces: + _generate_interface_docs(interface_dir=_REPO_ROOT / path_str, ref_dir=ref_dir) + + +def _generate_interface_docs(interface_dir: pathlib.Path, ref_dir: pathlib.Path) -> None: + """Write the index stub and rewritten version READMEs for one interface.""" + interface_name = interface_dir.name + interface_ref_dir = ref_dir / interface_name + interface_ref_dir.mkdir(exist_ok=True) + label = f'interfaces-{interface_name.replace("_", "-")}' + index = _INDEX_TEMPLATE.format(label=label, interface_name=interface_name) + _write_if_needed(path=ref_dir / f'{interface_name}.md', content=index) + for v in (interface_dir / 'interface').glob('v[0-9]*'): + readme_raw = (v / 'README.md').read_text() + base_url = f'{_REPO_MAIN_URL}/interfaces/{interface_name}/interface/{v.name}' + readme = _rewrite_links(readme_raw, base_url) + content = f'({label}-{v.name})=\n' + readme + _write_if_needed(path=interface_ref_dir / f'{v.name}.md', content=content) + + +def _rewrite_links(content: str, base_url: str) -> str: + """Rewrite relative markdown links to absolute GitHub URLs under ``base_url``.""" + return re.sub( + # match all non-http(s) markdown links and prepend base_url to matching links + r'\[(.+)\]\((?!https?://)([^)]+)\)', + lambda m: f'[{m.group(1)}]({base_url}/{m.group(2)})', + content, + ) + + +def _write_if_needed(path: pathlib.Path, content: str) -> None: + """Write to path only if contents are different. + + This allows sphinx-build to skip rebuilding pages that depend on the output of this script + if the output hasn't actually changed. + """ + if not path.exists() or path.read_text() != content: + path.write_text(content) + + +if __name__ == '__main__': + _main() diff --git a/.docs/tests/test_interface_docs.py b/.docs/tests/test_interface_docs.py new file mode 100644 index 000000000..425f20ef0 --- /dev/null +++ b/.docs/tests/test_interface_docs.py @@ -0,0 +1,78 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ruff: noqa: D103 (function docstrings) + +"""Unit tests for the interface_docs Sphinx fallback extension.""" + +from __future__ import annotations + +import types +import typing + +import interface_docs + +if typing.TYPE_CHECKING: + import pathlib + + import sphinx.application + + +def _app(confdir: pathlib.Path) -> sphinx.application.Sphinx: + """A minimal stand-in for the Sphinx app: ``_fallback`` only reads ``confdir``.""" + return typing.cast('sphinx.application.Sphinx', types.SimpleNamespace(confdir=str(confdir))) + + +def test_fallback_writes_placeholder_when_dir_missing(tmp_path: pathlib.Path): + """The reference/interfaces directory and placeholder are created if missing.""" + interface_docs._fallback(_app(tmp_path)) + + placeholder = tmp_path / 'reference' / 'interfaces' / 'placeholder.md' + assert placeholder.exists() + assert placeholder.read_text() == '# Temporary TOC placeholder' + + +def test_fallback_keeps_existing_placeholder(tmp_path: pathlib.Path): + """An existing placeholder is not overwritten.""" + ref_dir = tmp_path / 'reference' / 'interfaces' + ref_dir.mkdir(parents=True) + (ref_dir / 'placeholder.md').write_text('existing') + + interface_docs._fallback(_app(tmp_path)) + + assert (ref_dir / 'placeholder.md').read_text() == 'existing' + + +def test_fallback_skips_placeholder_when_content_exists(tmp_path: pathlib.Path): + """No placeholder is written when the preprocessor has generated pages.""" + ref_dir = tmp_path / 'reference' / 'interfaces' + ref_dir.mkdir(parents=True) + (ref_dir / 'foo.md').write_text('generated') + + interface_docs._fallback(_app(tmp_path)) + + assert not (ref_dir / 'placeholder.md').exists() + + +def test_fallback_removes_stale_placeholder(tmp_path: pathlib.Path): + """A leftover placeholder is removed when generated pages exist.""" + ref_dir = tmp_path / 'reference' / 'interfaces' + ref_dir.mkdir(parents=True) + (ref_dir / 'foo.md').write_text('generated') + (ref_dir / 'placeholder.md').write_text('stale') + + interface_docs._fallback(_app(tmp_path)) + + assert not (ref_dir / 'placeholder.md').exists() + assert (ref_dir / 'foo.md').read_text() == 'generated' diff --git a/.docs/tests/test_interface_preprocessor.py b/.docs/tests/test_interface_preprocessor.py new file mode 100644 index 000000000..437f3368b --- /dev/null +++ b/.docs/tests/test_interface_preprocessor.py @@ -0,0 +1,184 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ruff: noqa: D103 (function docstrings) + +"""Unit tests for the interface preprocessor script.""" + +from __future__ import annotations + +import json +import typing + +import interface_preprocessor as ip + +if typing.TYPE_CHECKING: + import pathlib + + import pytest + +# --- _main --- + + +def _fake_ls(interfaces: list[str]) -> typing.Callable[..., str]: + """A stand-in for ``subprocess.check_output`` returning the given interfaces.""" + + def check_output(*args: object, **kwargs: object) -> str: + return json.dumps(interfaces) + + return check_output + + +def test_main_writes_index_and_version_pages( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """Index stubs and rewritten READMEs are written for each interface.""" + docs_dir = tmp_path / 'docs_site' + ref_dir = docs_dir / 'reference' / 'interfaces' + ref_dir.mkdir(parents=True) + (ref_dir / 'placeholder.md').write_text('# Temporary TOC placeholder') + v1_dir = tmp_path / 'interfaces' / 'foo' / 'interface' / 'v1' + v1_dir.mkdir(parents=True) + (v1_dir / 'README.md').write_text('# Foo v1\n\nSee [schema](schema.md).\n') + monkeypatch.setattr(ip, '_DOCS_DIR', docs_dir) + monkeypatch.setattr(ip, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(ip.subprocess, 'check_output', _fake_ls(['interfaces/foo'])) + + ip._main() + + assert not (ref_dir / 'placeholder.md').exists() + index = (ref_dir / 'foo.md').read_text() + assert index.startswith('(interfaces-foo)=\n# foo\n') + assert 'foo/*' in index + v1 = (ref_dir / 'foo' / 'v1.md').read_text() + assert v1.startswith('(interfaces-foo-v1)=\n# Foo v1\n') + + +def test_main_removes_placeholder(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + """The fallback placeholder is removed when the preprocessor runs.""" + docs_dir = tmp_path / 'docs_site' + ref_dir = docs_dir / 'reference' / 'interfaces' + ref_dir.mkdir(parents=True) + (ref_dir / 'placeholder.md').write_text('# Temporary TOC placeholder') + monkeypatch.setattr(ip, '_DOCS_DIR', docs_dir) + monkeypatch.setattr(ip, '_REPO_ROOT', tmp_path) + monkeypatch.setattr(ip.subprocess, 'check_output', _fake_ls([])) + + ip._main() + + assert not (ref_dir / 'placeholder.md').exists() + + +# --- _generate_interface_docs --- + + +def test_generate_index_stub(tmp_path: pathlib.Path): + """The index stub has a hyphenated label and a glob toctree.""" + interface_dir = tmp_path / 'interfaces' / 'tls_certificates' + interface_dir.mkdir(parents=True) + ref_dir = tmp_path / 'ref' + ref_dir.mkdir() + + ip._generate_interface_docs(interface_dir=interface_dir, ref_dir=ref_dir) + + index = (ref_dir / 'tls_certificates.md').read_text() + assert index.startswith('(interfaces-tls-certificates)=\n# tls_certificates\n') + assert 'tls_certificates/*' in index + + +def test_generate_rewrites_version_readme_links(tmp_path: pathlib.Path): + """Relative links in version READMEs become absolute GitHub URLs.""" + v2_dir = tmp_path / 'interfaces' / 'foo' / 'interface' / 'v2' + v2_dir.mkdir(parents=True) + (v2_dir / 'README.md').write_text( + '# Foo v2\n\nSee [schema](schema.md) and [docs](https://example.com/).\n' + ) + ref_dir = tmp_path / 'ref' + ref_dir.mkdir() + + ip._generate_interface_docs(interface_dir=v2_dir.parent.parent, ref_dir=ref_dir) + + v2 = (ref_dir / 'foo' / 'v2.md').read_text() + assert v2.startswith('(interfaces-foo-v2)=\n# Foo v2\n') + base = f'{ip._REPO_MAIN_URL}/interfaces/foo/interface/v2' + assert f'[schema]({base}/schema.md)' in v2 + assert '[docs](https://example.com/)' in v2 + + +def test_generate_no_interface_dir(tmp_path: pathlib.Path): + """Interfaces without an interface/ directory get an index stub only.""" + interface_dir = tmp_path / 'interfaces' / 'foo' + interface_dir.mkdir(parents=True) + ref_dir = tmp_path / 'ref' + ref_dir.mkdir() + + ip._generate_interface_docs(interface_dir=interface_dir, ref_dir=ref_dir) + + assert (ref_dir / 'foo.md').exists() + # The per-interface directory is created, but contains no version pages. + assert list((ref_dir / 'foo').glob('*.md')) == [] + + +def test_generate_skips_unchanged_files(tmp_path: pathlib.Path): + """Files whose content hasn't changed are not rewritten.""" + interface_dir = tmp_path / 'interfaces' / 'foo' + interface_dir.mkdir(parents=True) + ref_dir = tmp_path / 'ref' + ref_dir.mkdir() + index = ref_dir / 'foo.md' + + ip._generate_interface_docs(interface_dir=interface_dir, ref_dir=ref_dir) + mtime = index.stat().st_mtime_ns + + ip._generate_interface_docs(interface_dir=interface_dir, ref_dir=ref_dir) + + assert index.stat().st_mtime_ns == mtime + + +# --- _rewrite_links --- + + +def test_rewrite_links_relative(): + content = 'See [the schema](schema.md) for details.' + result = ip._rewrite_links(content, 'https://example.com/base') + assert result == 'See [the schema](https://example.com/base/schema.md) for details.' + + +def test_rewrite_links_preserves_http(): + content = 'See [docs](https://example.com/page) and [other](http://example.com/).' + assert ip._rewrite_links(content, 'https://example.com/base') == content + + +# --- _write_if_needed --- + + +def test_write_if_needed_writes_missing(tmp_path: pathlib.Path): + path = tmp_path / 'out.md' + ip._write_if_needed(path=path, content='content') + assert path.read_text() == 'content' + + +def test_write_if_needed_skips_identical(tmp_path: pathlib.Path): + path = tmp_path / 'out.md' + path.write_text('content') + mtime = path.stat().st_mtime_ns + ip._write_if_needed(path=path, content='content') + assert path.stat().st_mtime_ns == mtime + + +def test_write_if_needed_rewrites_different(tmp_path: pathlib.Path): + path = tmp_path / 'out.md' + path.write_text('old') + ip._write_if_needed(path=path, content='new') + assert path.read_text() == 'new' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5420298b0..07271456f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,14 +110,14 @@ just docs html - # build the site with no package reference docs (fast The build is intentionally multi-pass, because different libraries can have conflicting dependencies and we can't install them all into a single Sphinx environment: -1. **Diataxis preprocessing.** The `.docs/scripts/diataxis_preprocessor.py` script runs first. It walks every library, copies any `docs/` pages into the Sphinx source tree, and generates the `_lib-*.md` toctree include files that the category index pages pull in. +1. **Preprocessing.** The `.docs/scripts/diataxis_preprocessor.py` and `.docs/scripts/interface_preprocessor.py` scripts run first. The former walks every library, copies any `docs/` pages into the Sphinx source tree, and generates the `_lib-*.md` toctree include files that the category index pages pull in. The latter generates the interface specification pages: it reads the interface `README.md` files, rewrites relative links to point at the repo on GitHub, and writes the pages under `reference/interfaces/`. 2. **Per-package reference passes.** `sphinx-build` is invoked once per package, each time with that package installed into an isolated `uvx` environment and the `package=` config option set. Each pass runs autodoc against a single library and saves its resolved doctree and index information to disk. Reference warnings are suppressed during these passes because cross-references to other libraries aren't available yet. -3. **Final combined pass.** A final `sphinx-build` runs with no `package` set. It restores the per-package reference docs saved in step 2, generates the interface specification pages, combines everything with the hand-written pages, and produces the complete HTML site (and `llms.txt`). +3. **Final combined pass.** A final `sphinx-build` runs with no `package` set. It restores the per-package reference docs saved in step 2, combines everything with the hand-written and preprocessed pages, and produces the complete HTML site (and `llms.txt`). The logic for steps 2 and 3 lives in the local Sphinx extensions under `.docs/extensions/`, which are registered in `.docs/conf.py`. In particular: - `package_docs.py` drives the per-package autodoc passes and saves/restores each library's reference doctree. -- `interface_docs.py` generates the interface specification pages. During the final pass it reads the interface `README.md` files, rewrites relative links to point at the repo on GitHub, and writes the pages under `reference/interfaces/`. (During the per-package passes it only writes a placeholder so the toctree glob doesn't fail.) +- `interface_docs.py` is a fallback shim for the interface specification pages generated by `.docs/scripts/interface_preprocessor.py`. It only writes a placeholder page when the preprocessor hasn't run, so the toctree glob doesn't fail. ## Working on the docs extensions diff --git a/docs.just b/docs.just index d818c5b61..bb8a45df1 100644 --- a/docs.just +++ b/docs.just @@ -9,7 +9,7 @@ Build the docs, (re)generating reference docs for all packages, or just those sp `just docs` is an alias for `just docs html`. To specify packages, you must use the full recipe, e.g. `just docs html interfaces/foo`. """)] -html *packages: _diataxis_docs (_packages packages) +html *packages: _diataxis_docs _interface_docs (_packages packages) uvx --with-requirements=_dev/requirements.txt --from=sphinx \ sphinx-build -b dirhtml -T -W --keep-going -d _dev/.doctrees -D language=en . '{{build_dir}}/html' @@ -17,6 +17,10 @@ html *packages: _diataxis_docs (_packages packages) _diataxis_docs: ./scripts/diataxis_preprocessor.py +[doc('Generate interface reference docs in the Sphinx source tree.')] +_interface_docs: + ./scripts/interface_preprocessor.py + [doc(""" Run html builder once per package, with its deps installed, setting the package argument and suppressing reference warnings. @@ -46,8 +50,8 @@ _packages *packages: '-D', 'language=en', '-D', f'package={package}', # llms.txt is only generated in the final combined pass (the `html` recipe). - # Leaving it enabled here triggers a nested base-mode build that regenerates the - # interface reference docs, deleting the placeholder this package pass depends on. + # Leaving it enabled here triggers a nested base-mode build that the + # intermediate package passes don't need. '-D', 'llms_txt_enabled=0', '-D', 'suppress_warnings=ref.ref,ref.doc,myst.xref_missing', '.', '{{build_dir}}/html',