Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .docs/extensions/diataxis_docs_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Sphinx fallback extension for per-library diataxis docs.
"""Sphinx fallback extension for per-library diataxis docs and change logs.

The ``diataxis_preprocessor.py`` script (run by ``just docs``) copies library
docs into the Sphinx source tree and generates ``_lib-*.md`` include files.
Expand Down Expand Up @@ -44,3 +44,9 @@ def _fallback(app: sphinx.application.Sphinx) -> None:
path = docs_dir / category / f'_lib-{category}.md'
if not path.exists():
path.write_text('')
# Change logs live under reference/ rather than in their own top-level
# section, so the include file goes there too.
changelog_include = docs_dir / 'reference' / '_lib-changelog.md'
if not changelog_include.exists():
changelog_include.parent.mkdir(parents=True, exist_ok=True)
changelog_include.write_text('')
9 changes: 9 additions & 0 deletions .docs/reference/changelogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
(reference-changelogs)=
# Change logs

Every library in the [charmlibs monorepo](https://github.com/canonical/charmlibs) keeps its own change log next to its source in the repository. This page collects those change logs so that they are discoverable from the docs site without having to browse the repository.

Each library is versioned independently; follow the link for the library you're using to see its release history.

```{include} _lib-changelog.md
```
1 change: 1 addition & 0 deletions .docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ interface-libs
charmlibs
charmlibs-interfaces
interfaces
changelogs
```
76 changes: 71 additions & 5 deletions .docs/scripts/diataxis_preprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Copy per-library diataxis docs into the Sphinx source tree.
"""Copy per-library diataxis docs and changelogs into the Sphinx source tree.

Walks every package returned by ``.scripts/ls.py``, looks for a ``docs/``
directory, and copies tutorial, how-to, and explanation pages into the
corresponding Sphinx source directories. Generates ``_lib-*.md`` include
files containing toctree entries that the index pages pull in via
``{include}``.
corresponding Sphinx source directories. Also copies each package's
``CHANGELOG.md`` into ``reference/charmlibs/{pkg}/`` so the change logs
are discoverable from the docs site rather than only from the repo tree.
Generates ``_lib-*.md`` include files containing toctree entries that
the index pages pull in via ``{include}``.

Run from ``just docs``; see ``docs.just`` for the invocation.

Expand Down Expand Up @@ -82,16 +84,32 @@ def _main() -> None:
sources = [_REPO_ROOT / lib_path / f for f in doc_files]
entries = _copy_category(sources, lib_path, category, sphinx_map)
all_entries.setdefault(category, []).extend(entries)
# Collect changelog entries alongside the diataxis categories.
changelog_entries = _copy_changelogs(packages, sphinx_map)
if changelog_entries:
all_entries['changelog'] = changelog_entries
# Write include files with toctree entries for each category.
for category, entries in all_entries.items():
if not entries:
continue
path = _DOCS_DIR / category / f'_lib-{category}.md'
path = _include_path_for(category)
content = _TOCTREE_HEADER + '\n'.join(sorted(entries)) + '\n' + _TOCTREE_FOOTER
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)


def _include_path_for(category: str) -> pathlib.Path:
"""Return where the ``_lib-{category}.md`` include file lives.

Changelog includes live under ``reference/`` so they render alongside
the other reference pages. Diataxis categories keep their own top-level
directory.
"""
if category == 'changelog':
return _DOCS_DIR / 'reference' / '_lib-changelog.md'
return _DOCS_DIR / category / f'_lib-{category}.md'


def _copy_category(
sources: list[pathlib.Path],
lib_path: pathlib.PurePath,
Expand All @@ -114,6 +132,49 @@ def _copy_category(
return entries


def _copy_changelogs(
packages: list[dict[str, Any]], sphinx_map: dict[pathlib.PurePath, str]
) -> list[str]:
"""Copy each package's ``CHANGELOG.md`` into the reference tree.

Each package's change log ends up at
``reference/charmlibs/{pkg}/CHANGELOG.md``, with a synthetic
``# {pkg}: Changelog`` H1 prepended so the page has a single,
library-scoped top-level heading. The existing per-version H1s
(``# 1.2.3 - 4 May 2026``) are demoted to H2 so they appear as
subsections. Relative links (e.g. to package sources) are rewritten
with :func:`_rewrite_links` so they resolve on the published site.
Packages without a ``CHANGELOG.md`` are skipped.
"""
entries: list[str] = []
for pkg in packages:
lib_path = pathlib.PurePath(pkg['path'])
source = _REPO_ROOT / lib_path / 'CHANGELOG.md'
if not source.is_file():
continue
raw = source.read_text()
# Three conventions are in use. Most packages go straight to a
# per-version H1 (`# 1.2.3 - 4 May 2026`), the interfaces packages open
# with a prose `# Changelog` and use H2 for versions, and nginx_k8s
# opens with `# Unreleased`. Drop a leading `# Changelog` rather than
# demoting it, so the page doesn't end up with a redundant `Changelog`
# section directly under its own title -- but match that heading by
# name: "an H1 that doesn't start with a digit" also deletes
# `# Unreleased`, orphaning its entries under the page title, and would
# delete a released `# v1.2.3` too.
body = re.sub(r'\A\s*#\s+Changelog\s*\n+', '', raw, flags=re.IGNORECASE)
# Demote any remaining H1s (per-version headings) to H2 so the injected
# library heading is the sole H1 for the page.
demoted = re.sub(r'^# ', '## ', body, flags=re.MULTILINE)
content = f'# {lib_path.name}: Changelog\n\n{demoted}'
content = _rewrite_links(content, source, sphinx_map)
out_dir = _DOCS_DIR / 'reference' / 'charmlibs' / lib_path
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / 'CHANGELOG.md').write_text(content)
entries.append(f'{lib_path.name} <charmlibs/{lib_path}/CHANGELOG>')
return entries


def _extract_h1(content: str, source: pathlib.Path) -> str:
"""Extract the first H1 heading text from content."""
if source.suffix == '.md':
Expand Down Expand Up @@ -173,6 +234,11 @@ def _build_sphinx_map(packages: list[dict[str, Any]]) -> dict[pathlib.PurePath,
m[readme.relative_to(_REPO_ROOT)] = (
f'/reference/interfaces/{lib_path.name}/{readme.parent.name}'
)
# Per-package change log — rewrite links from anywhere in the tree
# to the docs-site copy.
changelog = _REPO_ROOT / lib_path / 'CHANGELOG.md'
if changelog.is_file():
m[changelog.relative_to(_REPO_ROOT)] = f'/reference/charmlibs/{lib_path}/CHANGELOG'

return m

Expand Down
18 changes: 18 additions & 0 deletions .docs/tests/test_diataxis_docs_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,41 @@ def test_fallback_writes_empty_include_when_missing(tmp_path: pathlib.Path):
"""Fallback writes empty include files when preprocessor hasn't run."""
for category in CATEGORIES:
(tmp_path / category).mkdir()
(tmp_path / 'reference').mkdir()

fallback._fallback(_app(tmp_path))

for category in CATEGORIES:
path = tmp_path / category / f'_lib-{category}.md'
assert path.exists()
assert path.read_text() == ''
changelog = tmp_path / 'reference' / '_lib-changelog.md'
assert changelog.exists()
assert changelog.read_text() == ''


def test_fallback_skips_when_include_exists(tmp_path: pathlib.Path):
"""Fallback does nothing when preprocessor has already run."""
for category in CATEGORIES:
(tmp_path / category).mkdir()
(tmp_path / category / f'_lib-{category}.md').write_text('existing')
(tmp_path / 'reference').mkdir()
(tmp_path / 'reference' / '_lib-changelog.md').write_text('existing-changelog')

fallback._fallback(_app(tmp_path))

for category in CATEGORIES:
path = tmp_path / category / f'_lib-{category}.md'
assert path.read_text() == 'existing'
assert (tmp_path / 'reference' / '_lib-changelog.md').read_text() == 'existing-changelog'


def test_fallback_creates_reference_dir(tmp_path: pathlib.Path):
"""Fallback creates the reference directory if it doesn't already exist."""
for category in CATEGORIES:
(tmp_path / category).mkdir()
# No reference/ directory yet.

fallback._fallback(_app(tmp_path))

assert (tmp_path / 'reference' / '_lib-changelog.md').exists()
173 changes: 173 additions & 0 deletions .docs/tests/test_diataxis_preprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,176 @@ def test_copy_category_empty_sources(tmp_path: pathlib.Path, monkeypatch: pytest
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)
entries = pp._copy_category([], pathlib.PurePath('mylib'), 'how-to', {})
assert entries == []


# --- _include_path_for ---


def test_include_path_for_diataxis(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path):
monkeypatch.setattr(pp, '_DOCS_DIR', tmp_path)
assert pp._include_path_for('how-to') == tmp_path / 'how-to' / '_lib-how-to.md'


def test_include_path_for_changelog(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path):
monkeypatch.setattr(pp, '_DOCS_DIR', tmp_path)
assert pp._include_path_for('changelog') == tmp_path / 'reference' / '_lib-changelog.md'


# --- _copy_changelogs ---


def test_copy_changelogs_generates_page(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
docs_dir = tmp_path / 'docs_site'
(tmp_path / 'mylib').mkdir()
(tmp_path / 'mylib' / 'CHANGELOG.md').write_text(
'# 1.0.0 - 1 May 2026\n\nFirst release.\n\n# 0.1.0 - 1 Apr 2026\n\nBeta.\n'
)
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)
entries = pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {})
out = docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md'
assert out.exists()
text = out.read_text()
assert text.startswith('# mylib: Changelog\n\n')
# Existing per-version H1s are demoted to H2 so the injected heading is
# the only H1 on the page.
assert '\n## 1.0.0 - 1 May 2026\n' in text
assert '\n## 0.1.0 - 1 Apr 2026\n' in text
assert '\n# 1.0.0' not in text
assert entries == ['mylib <charmlibs/mylib/CHANGELOG>']


def test_copy_changelogs_drops_leading_prose_heading(
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
):
# The interfaces packages open with a prose '# Changelog' and use H2 for
# versions, rather than going straight to a per-version H1.
docs_dir = tmp_path / 'docs_site'
(tmp_path / 'mylib').mkdir()
(tmp_path / 'mylib' / 'CHANGELOG.md').write_text(
'# Changelog\n\n## 1.0.0\n\nInitial release.\n'
)
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)
pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {})
text = (docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md').read_text()
assert text.startswith('# mylib: Changelog\n\n')
# The prose heading is dropped rather than demoted, so the page doesn't
# carry a redundant 'Changelog' section under its own title.
assert '## Changelog' not in text
assert '\n## 1.0.0\n' in text


def test_copy_changelogs_keeps_version_heading_that_starts_with_a_digit(
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
):
docs_dir = tmp_path / 'docs_site'
(tmp_path / 'mylib').mkdir()
(tmp_path / 'mylib' / 'CHANGELOG.md').write_text('# 1.0.0 - 1 May 2026\n\nFirst.\n')
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)
pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {})
text = (docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md').read_text()
assert '\n## 1.0.0 - 1 May 2026\n' in text
assert 'First.' in text


def test_copy_changelogs_skips_missing(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
docs_dir = tmp_path / 'docs_site'
(tmp_path / 'has_log').mkdir()
(tmp_path / 'has_log' / 'CHANGELOG.md').write_text('# 1.0.0\n\nRelease.\n')
(tmp_path / 'no_log').mkdir()
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)
entries = pp._copy_changelogs(
[{'path': 'has_log', 'docs': {}}, {'path': 'no_log', 'docs': {}}], {}
)
assert entries == ['has_log <charmlibs/has_log/CHANGELOG>']
assert (docs_dir / 'reference' / 'charmlibs' / 'has_log' / 'CHANGELOG.md').exists()
assert not (docs_dir / 'reference' / 'charmlibs' / 'no_log').exists()


def test_copy_changelogs_rewrites_links(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
docs_dir = tmp_path / 'docs_site'
(tmp_path / 'mylib').mkdir()
(tmp_path / 'mylib' / 'CHANGELOG.md').write_text(
'# 1.0.0 - 1 May 2026\n\nSee [README](README.md) for details.\n'
)
(tmp_path / 'mylib' / 'README.md').touch()
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)
pp._copy_changelogs([{'path': 'mylib', 'docs': {}}], {})
out = docs_dir / 'reference' / 'charmlibs' / 'mylib' / 'CHANGELOG.md'
# Unknown-in-map links become absolute GitHub URLs.
assert 'https://github.com/canonical/charmlibs/blob/main/mylib/README.md' in out.read_text()


# --- _build_sphinx_map (changelog) ---


def test_build_sphinx_map_changelog(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
"""A package's ``CHANGELOG.md`` maps to its docs-site copy."""
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', tmp_path / '.docs')
(tmp_path / '.docs').mkdir()
(tmp_path / 'mylib').mkdir()
(tmp_path / 'mylib' / 'CHANGELOG.md').touch()
m = pp._build_sphinx_map([{'path': 'mylib', 'docs': {}}])
assert m[pathlib.PurePath('mylib/CHANGELOG.md')] == '/reference/charmlibs/mylib/CHANGELOG'


# --- _copy_changelogs against the repo's real change logs ---


def _real_changelogs() -> list[pathlib.Path]:
"""Every CHANGELOG.md `ls.py packages` would publish.

`_packages` globs `[a-z]*` from the repo root and from `interfaces/`, so
dot-directories (`.package`, `.template`, ...) are not packages.
"""
root = pathlib.Path(pp._REPO_ROOT)
found = [*root.glob('[a-z]*/CHANGELOG.md'), *root.glob('interfaces/[a-z]*/CHANGELOG.md')]
return sorted(p for p in found if p.is_file())


def test_there_are_real_changelogs_to_check():
"""Guard the guard: a glob that silently matched nothing would pass everything."""
assert len(_real_changelogs()) > 10


@pytest.mark.parametrize('changelog', _real_changelogs(), ids=lambda p: p.parent.name)
def test_copy_changelogs_keeps_every_heading_in_a_real_changelog(
changelog: pathlib.Path, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
):
"""The synthetic fixtures above only cover the conventions we thought of.

`nginx_k8s` opens with `# Unreleased` rather than a version or a prose
`# Changelog`, and a heuristic written as "an H1 that doesn't start with a
digit" deleted that heading, orphaning its entries under the page title.
Running the real files is what catches the convention nobody wrote a
fixture for -- including the next one.
"""
lib = changelog.parent.relative_to(pp._REPO_ROOT)
docs_dir = tmp_path / 'docs_site'
pkg_dir = tmp_path / lib
pkg_dir.mkdir(parents=True)
(pkg_dir / 'CHANGELOG.md').write_text(changelog.read_text(), encoding='utf-8')
monkeypatch.setattr(pp, '_REPO_ROOT', tmp_path)
monkeypatch.setattr(pp, '_DOCS_DIR', docs_dir)

pp._copy_changelogs([{'path': str(lib), 'docs': {}}], {})

text = (docs_dir / 'reference' / 'charmlibs' / lib / 'CHANGELOG.md').read_text()
h1s = [line for line in text.splitlines() if line.startswith('# ')]
assert h1s == [f'# {lib.name}: Changelog'], 'the injected heading must be the only H1'
# Every heading in the source survives, at one level deeper. The prose
# `# Changelog` heading is the one exception: it is dropped deliberately,
# because the injected heading says the same thing.
published = {line.lstrip('#').strip() for line in text.splitlines() if line.startswith('#')}
for line in changelog.read_text().splitlines():
if not line.startswith(('# ', '## ')):
continue
heading = line.lstrip('#').strip()
if heading.lower() == 'changelog':
continue
assert heading in published, f'{heading!r} was dropped from {lib}'
1 change: 1 addition & 0 deletions docs.just
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ clean:
rm -f tutorials/_lib-tutorials.md
rm -f how-to/_lib-how-to.md
rm -f explanation/_lib-explanation.md
rm -f reference/_lib-changelog.md

[doc('Run `pyright` for local sphinx extensions.')]
ext-static *pyrightargs:
Expand Down
Loading