diff --git a/README.md b/README.md
index 4add07dc..d0b24851 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,8 @@ SPDX-License-Identifier: Apache-2.0
`continuous-delivery-scripts` provides Git-based CI/CD commands for release
automation, semantic versioning, changelog generation, SPDX SBOMs,
third-party IP (TPIP) and licence reports, source licence headers and secret
-checks.
+checks. It is designed as a CI-agnostic set of composable delivery tools rather
+than a Git-history-driven release system.
## Overview
@@ -34,7 +35,9 @@ a release. The shared project delivery definition lives in one place:
including paths, versioning rules and the selected `PROGRAMMING_LANGUAGE`,
regardless of the project's language or CI system. The selected plugin then
uses appropriate ecosystem tools: for example, GoReleaser for Go releases or
-wheel and Twine for Python packages. Consult the
+wheel and Twine for Python packages. CDS also builds on tools such as
+Towncrier, pdoc, detect-secrets and SPDX data sources where they are the best
+fit for a specific delivery task. Consult the
[plugin guides](./guides/plugin-guides.md) for language-specific
requirements, including the [Go plugin guides](./guides/plugin-golang.md);
native build manifests and credentials may still be needed. See
@@ -46,6 +49,30 @@ the project's Git repository and install the required tools. This avoids
infrastructure lock-in: the team can decide which CI system suits its project,
including after the delivery workflow has been defined.
+## Why Continuous Delivery Scripts?
+
+CDS takes a different approach to release automation by deliberately separating
+development history from consumer-facing release information.
+
+Unlike commit-driven tools such as
+[release-please](https://github.com/googleapis/release-please) or
+[semantic-release](https://semantic-release.gitbook.io/), CDS follows
+Towncrier's philosophy: Git commits document technical changes for maintainers,
+while explicit news fragments describe their significance to consumers.
+
+Beyond changelog generation, CDS provides composable, CI-agnostic tools for
+versioning, coordinated releases, licensing, SBOM generation, documentation and
+other delivery responsibilities. CDS often uses specialised tools rather than
+replacing them outright: for example, Towncrier for release notes, GoReleaser
+for Go packaging, wheel and Twine for Python publication, pdoc for Python API
+documentation, and detect-secrets for secret scanning.
+
+This makes it particularly suitable for projects seeking consistent release
+practices across languages and CI/CD platforms without imposing a particular
+commit convention or release workflow.
+
+Learn more in [Why choose Continuous Delivery Scripts?](./guides/why-continuous-delivery-scripts.md).
+
## Usage and documentation
Start with the [common use cases](#common-use-cases) below, then follow the
@@ -184,11 +211,14 @@ same commands whichever CI system the team chooses.
| [`cd-determine-version`](./guides/previewing-versions.md) | Calculates and prints the **project's prospective release version** without generating a changelog. | Required `--release-type` (`-t`) | Developer preview or CI |
| [`cd-generate-news`](./guides/managing-changelogs.md) | Updates the project version and, for beta or production releases, builds the changelog from news fragments; prints the resulting version. | Required `--release-type` (`-t`) | CI release flow |
| [`cd-get-config`](./guides/reading-project-configuration.md) | Prints a project configuration value. | Either `--key` (`-k`) or `--config-variable` (`-c`) | Developer or CI |
+| [`cd-check-project-config`](./guides/checking-project-configuration.md) | Validates CDS-specific `ProjectConfig` values before other commands run. | Optional `--config-file` (`-f`), `--verbose` (`-v`) | Developer setup or CI gate |
| [`cd-tag-and-release`](./guides/automating-releases.md) | Runs the release flow, including documentation, licensing summaries, Git tagging, packaging and publication through the selected [language plugin](./continuous_delivery_scripts/plugins). | Required `--release-type` (`-t`); optional `--current-branch` (`-b`) | CI release flow |
| [`cd-generate-docs`](./guides/generating-code-documentation.md) | Generates code documentation in the configured output directory (or one specified on the command line). | `--output_directory` | Developer preview or CI |
| [`cd-generate-spdx`](./guides/generating-an-spdx-sbom.md) | Generates SPDX documents and third-party licence summaries, then checks licence compliance when project metadata is available. | Required `--output-dir` (`-o`); optional `--lookup-scancode`, `--skip-dependency-download` | CI audit; developer review also possible |
| [`cd-check-licence-compliance`](./guides/checking-licence-compliance.md) | Checks project and dependency licences without creating SPDX documents; optionally writes third-party IP summaries. | Optional `--output-dir` (`-o`) for reports, `--lookup-scancode`, `--skip-dependency-download` | CI gate or developer review |
| [`cd-license-files`](./guides/licence-header-management.md) | Adds or updates source-file licence and copyright headers when the language plugin supports them. | `--verbose` (`-v`) | Developer or CI |
+| [`cd-sync-project-licence`](./guides/syncing-project-licence.md) | Creates or refreshes the top-level project licence file from the configured SPDX identifier. | `--verbose` (`-v`) | Developer setup or CI compliance prep |
+| [`cd-describe-licence-obligations`](./guides/describing-project-licence-obligations.md) | Prints a human-readable summary of obligations for the configured project licence. | `--verbose` (`-v`) | Developer review or CI reporting |
| [`cd-record-secrets`](./guides/recording-secrets.md) | Records accepted findings in the project's [detect-secrets](https://github.com/Yelp/detect-secrets) registry. | `--registry-file` (`-r`); defaults to the configured registry | Developer (registry maintenance) |
| [`cd-detect-secrets`](./guides/checking-for-secrets.md) | Checks Git-tracked files against that registry and fails if new secrets are found. | `--registry-file` (`-r`); defaults to the configured registry | CI; developer checks also possible |
diff --git a/continuous_delivery_scripts/generate_docs.py b/continuous_delivery_scripts/generate_docs.py
index 2851d136..89a941fb 100644
--- a/continuous_delivery_scripts/generate_docs.py
+++ b/continuous_delivery_scripts/generate_docs.py
@@ -12,6 +12,7 @@
import shutil
import sys
from pathlib import Path
+from urllib.parse import SplitResult, urlsplit
import markdown
@@ -28,9 +29,50 @@
logger = logging.getLogger(__name__)
-def _html_guide_link(match: re.Match[str]) -> str:
- """Link to the rendered HTML page instead of its Markdown source."""
- return f'href="{match.group(1)}.html{match.group(2) or ""}"'
+def _guide_html_path(source: Path, guide_sources: Path) -> Path:
+ """Map a guide source file to its published HTML path beneath the guide output."""
+ return source.relative_to(guide_sources).with_suffix(".html")
+
+
+def _guide_sort_key(source: Path, guide_sources: Path) -> tuple[bool, str]:
+ """Keep the top-level index first and sort the rest by relative path."""
+ relative = source.relative_to(guide_sources)
+ return relative != Path("index.md"), relative.as_posix()
+
+
+def _rewrite_guide_link_target(parts: SplitResult, source: Path, guide_sources: Path) -> str | None:
+ """Rewrite only relative Markdown links that point to published guides."""
+ if (
+ parts.scheme
+ or parts.netloc
+ or not parts.path
+ or parts.path.startswith("/")
+ or not parts.path.lower().endswith(".md")
+ ):
+ return None
+ target = (source.parent / Path(parts.path)).resolve()
+ root = guide_sources.resolve()
+ if not target.is_file() or not target.is_relative_to(root):
+ return None
+ return parts._replace(path=f"{parts.path[:-3]}.html").geturl()
+
+
+def _rewrite_rendered_guide_links(body: str, source: Path, guide_sources: Path) -> str:
+ """Retarget rendered guide links to the published HTML pages."""
+
+ def replace(match: re.Match[str]) -> str:
+ href = match.group(1)
+ rewritten = _rewrite_guide_link_target(urlsplit(href), source, guide_sources)
+ if rewritten is None:
+ return match.group(0)
+ return f'href="{escape(rewritten, quote=True)}"'
+
+ return re.sub(r'href="([^"]+)"', replace, body)
+
+
+def _guide_navigation_prefix(guide_folder: Path, relative_html_path: Path) -> str:
+ """Build the route from a guide page back to the documentation root."""
+ return "/".join(".." for _ in (guide_folder / relative_html_path.parent).parts)
def _html_page(
@@ -127,15 +169,32 @@ def _guide_settings() -> tuple[Path, Path, Path, str] | None:
def _render_guide(
- source: Path, destination: Path, project_name: str, nav_prefix: str, has_api_index: bool, page_url: str | None
+ source: Path,
+ destination: Path,
+ guide_sources: Path,
+ guide_folder: Path,
+ project_name: str,
+ has_api_index: bool,
+ page_url: str | None,
) -> str:
"""Render one Markdown guide to an HTML page and return its title."""
text = source.read_text(encoding="utf8")
title = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), source.stem)
body: str = markdown.markdown(text, extensions=["fenced_code", "tables"])
- body = re.sub(r'href="([a-z0-9-]+)\.md(#[^"]*)?"', _html_guide_link, body)
+ body = _rewrite_rendered_guide_links(body, source, guide_sources)
description = f"{title} — {project_name}."
- destination.write_text(_html_page(title, description, body, nav_prefix, has_api_index, page_url), encoding="utf8")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(
+ _html_page(
+ title,
+ description,
+ body,
+ _guide_navigation_prefix(guide_folder, _guide_html_path(source, guide_sources)),
+ has_api_index,
+ page_url,
+ ),
+ encoding="utf8",
+ )
return title
@@ -144,16 +203,22 @@ def _render_guides(
) -> list[tuple[str, str]]:
"""Render all guides and return links for the documentation landing page."""
guide_output.mkdir(parents=True, exist_ok=True)
- navigation_prefix = "/".join(".." for _ in guide_folder.parts)
site_url = documentation_site_url()
links = []
- for source in sorted(guide_sources.glob("*.md")):
- page_url = f"{site_url}{guide_folder.as_posix()}/{source.stem}.html" if site_url else None
+ for source in sorted(guide_sources.rglob("*.md"), key=lambda path: _guide_sort_key(path, guide_sources)):
+ relative_html_path = _guide_html_path(source, guide_sources)
+ page_url = f"{site_url}{(guide_folder / relative_html_path).as_posix()}" if site_url else None
title = _render_guide(
- source, guide_output / f"{source.stem}.html", project_name, navigation_prefix, has_api_index, page_url
+ source,
+ guide_output / relative_html_path,
+ guide_sources,
+ guide_folder,
+ project_name,
+ has_api_index,
+ page_url,
)
- if source.stem != "index":
- links.append((title, source.stem))
+ if source.relative_to(guide_sources) != Path("index.md"):
+ links.append((title, relative_html_path.as_posix()))
return links
diff --git a/continuous_delivery_scripts/templates/documentation_landing.html.jinja2 b/continuous_delivery_scripts/templates/documentation_landing.html.jinja2
index a9a2240a..a6550389 100644
--- a/continuous_delivery_scripts/templates/documentation_landing.html.jinja2
+++ b/continuous_delivery_scripts/templates/documentation_landing.html.jinja2
@@ -3,5 +3,5 @@
{% if licence %}
Project licence: {{ licence }}
{% endif %}
All task guides{% if has_api_index %} · API reference{% endif %}
diff --git a/continuous_delivery_scripts/templates/llms.txt.jinja2 b/continuous_delivery_scripts/templates/llms.txt.jinja2
index a8f3ba91..c5a4588e 100644
--- a/continuous_delivery_scripts/templates/llms.txt.jinja2
+++ b/continuous_delivery_scripts/templates/llms.txt.jinja2
@@ -5,8 +5,8 @@
## Documentation
- [Overview]({{ site_url }})
-{% for source, title in guides if folder -%}
-- [{{ title }}]({{ site_url }}{{ folder.as_posix() }}/{{ source.stem }}.html)
+{% for source, title, href in guides if folder -%}
+- [{{ title }}]({{ site_url }}{{ folder.as_posix() }}/{{ href }})
{% endfor -%}
{% if has_api -%}
- [API reference]({{ site_url }}api.html)
diff --git a/continuous_delivery_scripts/utils/documentation_discovery.py b/continuous_delivery_scripts/utils/documentation_discovery.py
index 85250352..70141103 100644
--- a/continuous_delivery_scripts/utils/documentation_discovery.py
+++ b/continuous_delivery_scripts/utils/documentation_discovery.py
@@ -48,14 +48,16 @@ def structured_data_script(title: str, description: str, url: str, schema_type:
return f''
-def _annotate_existing_pages(output: Path, site_url: str, guides: list[tuple[Path, str]], folder: Path | None) -> None:
+def _annotate_existing_pages(
+ output: Path, site_url: str, guides: list[tuple[Path, str, str]], folder: Path | None
+) -> None:
"""Annotate already-rendered landing and guide pages without rebuilding the API."""
if not guides or folder is None:
return
pages = [(output / "index.html", site_url, "WebSite")]
pages.extend(
- (output / folder / f"{source.stem}.html", f"{site_url}{folder.as_posix()}/{source.stem}.html", "TechArticle")
- for source, _ in guides
+ (output / folder / relative_html_path, f"{site_url}{folder.as_posix()}/{relative_html_path}", "TechArticle")
+ for _source, _title, relative_html_path in guides
)
for page, url, schema_type in pages:
text = page.read_text(encoding="utf8")
@@ -75,13 +77,26 @@ def _guide_title(source: Path) -> str:
)
-def _published_guides(output: Path, sources: Path | None, folder: Path | None) -> list[tuple[Path, str]]:
+def _guide_html_path(source: Path, sources: Path) -> Path:
+ """Map a guide source file to its published HTML path beneath the guide folder."""
+ return source.relative_to(sources).with_suffix(".html")
+
+
+def _guide_sort_key(source: Path, sources: Path) -> tuple[bool, str]:
+ """Keep the guide overview first and sort all nested guides by path."""
+ relative = source.relative_to(sources)
+ return relative != Path("index.md"), relative.as_posix()
+
+
+def _published_guides(output: Path, sources: Path | None, folder: Path | None) -> list[tuple[Path, str, str]]:
if sources is None or folder is None:
return []
+ guide_sources = sources
return [
- (source, _guide_title(source))
- for source in sorted(sources.glob("*.md"), key=lambda path: (path.name != "index.md", path.name))
- if (output / folder / f"{source.stem}.html").is_file()
+ (source, _guide_title(source), relative_html_path.as_posix())
+ for source in sorted(guide_sources.rglob("*.md"), key=lambda path: _guide_sort_key(path, guide_sources))
+ for relative_html_path in [_guide_html_path(source, guide_sources)]
+ if (output / folder / relative_html_path).is_file()
]
@@ -100,7 +115,7 @@ def _render_sitemap(output: Path, site_url: str) -> str:
def _render_llms_index(
- output: Path, site_url: str, name: str, guides: list[tuple[Path, str]], folder: Path | None
+ output: Path, site_url: str, name: str, guides: list[tuple[Path, str, str]], folder: Path | None
) -> str:
"""Render the concise index from pages that were actually published."""
rendered = str(
@@ -111,19 +126,24 @@ def _render_llms_index(
return rendered.rstrip() + "\n"
-def _render_llms_full(output: Path, site_url: str, name: str, guides: list[tuple[Path, str]], folder: Path) -> str:
+def _render_llms_full(output: Path, site_url: str, name: str, guides: list[tuple[Path, str, str]], folder: Path) -> str:
"""Consolidate only the Markdown guides which have corresponding published pages."""
- published = {source.stem for source, _ in guides}
-
- def published_link(match: re.Match[str]) -> str:
- stem, fragment = match.group(1), match.group(2) or ""
- return f"]({site_url}{folder.as_posix()}/{stem}.html{fragment})" if stem in published else match.group(0)
+ published = {source.resolve(): relative_html_path for source, _title, relative_html_path in guides}
sections: list[tuple[str, str]] = []
- for source, title in guides:
+ for source, title, _relative_html_path in guides:
text = source.read_text(encoding="utf8")
text = re.sub(r"^# .*(?:\n|$)", "", text, count=1, flags=re.MULTILINE)
- text = re.sub(r"\]\(([a-z0-9-]+)\.md(#[^)]*)?\)", published_link, text)
+
+ def published_link(match: re.Match[str]) -> str:
+ destination = match.group(1)
+ fragment = match.group(2) or ""
+ target = (source.parent / Path(destination)).resolve()
+ if target not in published:
+ return match.group(0)
+ return f"]({site_url}{folder.as_posix()}/{published[target]}{fragment})"
+
+ text = re.sub(r"\]\(([^)#]+\.md)(#[^)]*)?\)", published_link, text)
in_code = False
body = []
for line in text.strip().splitlines():
diff --git a/guides/index.md b/guides/index.md
index ed31fb26..21874c3d 100644
--- a/guides/index.md
+++ b/guides/index.md
@@ -10,6 +10,10 @@ the implementation, while these pages focus on inputs, outputs and examples.
For language-specific packaging, credentials and metadata support, start with
the [plugin guide index](plugin-guides.md).
+## Choosing CDS
+
+- [Why choose Continuous Delivery Scripts?](why-continuous-delivery-scripts.md): Understand CDS design goals, the specialised tools it uses, and when another approach may fit better.
+
## Dependency licences and OpenChain workflows
- [Assess dependency licence risks](assessing-dependency-licences.md): Review directional, explainable licence screening and optional project policy overrides.
diff --git a/guides/why-continuous-delivery-scripts.md b/guides/why-continuous-delivery-scripts.md
new file mode 100644
index 00000000..aed14d63
--- /dev/null
+++ b/guides/why-continuous-delivery-scripts.md
@@ -0,0 +1,126 @@
+# Why choose Continuous Delivery Scripts?
+
+## Problem and when to read this
+
+Read this guide when you are deciding whether CDS fits a project's release and
+delivery workflow, or when you need to explain why CDS exists alongside other
+release automation tools.
+
+CDS is not primarily trying to infer releases from Git history or prescribe one
+CI/CD platform. It tries to answer a different question: how can a project keep
+consumer-facing release information, delivery policy and automation consistent
+without coupling them to one commit convention, language ecosystem or CI system?
+
+## What CDS is trying to solve
+
+CDS provides reusable commands for software delivery tasks such as changelog
+generation, version calculation, release orchestration, SPDX SBOM generation,
+licence reporting, documentation publishing and secret checks.
+
+The tools share one project configuration in `[ProjectConfig]` and can be run
+locally or from different CI systems. Language plugins then use the most
+appropriate ecosystem tools for project-specific work.
+
+## Design principles
+
+### Separate development history from consumer-facing changes
+
+Git commits and release notes serve different audiences.
+
+- Git history records implementation details for maintainers.
+- Release notes explain user-visible impact for consumers.
+
+Following [Towncrier's philosophy](https://towncrier.readthedocs.io/), CDS uses
+explicit news fragments rather than deriving release notes from commit messages.
+This avoids forcing one commit message to serve both maintainers and consumers.
+
+### Keep release management independent of CI/CD platforms
+
+CDS provides command-line tools that can be invoked from GitHub Actions,
+Jenkins, GitLab CI or other automation environments. The delivery policy lives
+in project configuration and scripts, not in one CI provider's release model.
+
+### Prefer composable tools over monolithic workflows
+
+CDS follows a small-tools approach. Projects can adopt only the commands they
+need instead of taking an all-or-nothing release framework.
+
+Examples include:
+
+- validating news fragments and generating changelogs
+- determining versions and coordinating releases
+- generating SPDX SBOMs and third-party IP reports
+- managing licence files and source headers
+- generating project documentation
+- checking Git-tracked files for new secrets
+
+### Treat software delivery as more than versioning and tagging
+
+Releases often involve documentation, provenance, licensing, dependency review,
+packaging and publication. CDS brings those concerns into one delivery toolkit
+instead of treating a release as only version calculation and Git tagging.
+
+### Support coordinated project releases
+
+CDS takes a project-oriented approach. A repository may contain several
+packages, modules or deliverables but still share one project version,
+changelog and release flow. This is particularly useful for coordinated
+multi-module Go releases.
+
+## CDS often uses specialised tools rather than replacing them
+
+CDS is often a coordinator around established tools instead of a reimplementation
+of them. In this repository and its plugins, that includes:
+
+- [Towncrier](https://towncrier.readthedocs.io/) for consumer-facing changelog generation from news fragments
+- [GoReleaser](https://goreleaser.com/) in the Go plugin for packaging and publication steps
+- Python build tools such as wheel and [Twine](https://twine.readthedocs.io/) in the Python plugin
+- [pdoc](https://pdoc.dev/) and doc2go through the documentation workflow
+- [detect-secrets](https://github.com/Yelp/detect-secrets) for reviewed secret scanning baselines
+- SPDX data and optional [ScanCode LicenseDB](https://scancode-licensedb.aboutcode.org/) lookups for licensing workflows
+
+This is an important distinction: CDS aims to give projects a consistent command
+surface and shared delivery policy, while still relying on specialised tooling
+where that tooling is the best fit.
+
+## How CDS compares with other approaches
+
+| Approach | Primary focus | Key distinction from CDS |
+| --- | --- | --- |
+| [release-please](https://github.com/googleapis/release-please) | Conventional Commits, release PRs and GitHub Releases | Derives release information mainly from commit history and is closely integrated with GitHub workflows. |
+| [semantic-release](https://semantic-release.gitbook.io/) | Fully automated semantic releases | Uses commit conventions to determine release significance and automate publishing. |
+| [Changesets](https://github.com/changesets/changesets) | Explicit change declarations and package versioning | Shares explicit change tracking, but focuses more on package-level versioning and monorepo publishing flows. |
+| [Towncrier](https://towncrier.readthedocs.io/) | Consumer-focused changelog generation | Shares CDS's news-fragment philosophy but does not provide the broader release, documentation and compliance command set. |
+| [release-it](https://github.com/release-it/release-it) | Configurable release automation | Provides an extensible release engine rather than CDS's wider collection of delivery and compliance utilities. |
+| [GoReleaser](https://goreleaser.com/) | Building, packaging and distributing Go applications | Focuses on release artefact production and distribution rather than defining a project-wide delivery policy. |
+
+These approaches are not mutually exclusive. CDS can complement specialised
+tools when their strengths match part of a project's workflow.
+
+## When CDS is a good fit
+
+CDS is particularly suitable when:
+
+- you want release notes to describe consumer impact independently of commit history
+- you need consistent release practices across multiple projects or languages
+- you want release tooling that is portable across CI/CD platforms
+- you prefer composable commands over one monolithic workflow
+- you need licensing, SBOM, documentation and release tasks in one toolkit
+- you want to coordinate several deliverables as one project release
+
+Other approaches may be more suitable when:
+
+- the project already relies on Conventional Commits and wants releases derived directly from them
+- you want a ready-made GitHub-native release PR workflow with minimal orchestration
+- you need independently versioned packages inside a monorepo
+- you mainly need artefact building and distribution rather than broader delivery policy and compliance workflows
+
+## Summary
+
+CDS treats release management as a distinct concern from development history and
+provides reusable building blocks for implementing consistent, portable software
+delivery processes.
+
+Its purpose is not to replace every specialised tool, but to avoid coupling
+release communication, project policy and delivery automation to one commit
+convention, programming language or CI/CD platform.
diff --git a/news/20261027155800.bugfix b/news/20261027155800.bugfix
new file mode 100644
index 00000000..071f0176
--- /dev/null
+++ b/news/20261027155800.bugfix
@@ -0,0 +1 @@
+Publish nested documentation guides and rewrite relative guide links to their HTML pages.
diff --git a/news/20261027164000.doc b/news/20261027164000.doc
new file mode 100644
index 00000000..a84e8845
--- /dev/null
+++ b/news/20261027164000.doc
@@ -0,0 +1 @@
+Document CDS philosophy, external tool integrations, and the full command list in the README.
diff --git a/tests/generate_docs/test_documentation_discovery.py b/tests/generate_docs/test_documentation_discovery.py
index 502e1062..ffeabd3c 100644
--- a/tests/generate_docs/test_documentation_discovery.py
+++ b/tests/generate_docs/test_documentation_discovery.py
@@ -200,6 +200,62 @@ def generate_api(output, _module):
self.assertIsNotNone(payload)
self.assertEqual(json.loads(payload.group(1))["@type"], expected_type)
+ @mock.patch("continuous_delivery_scripts.generate_docs.get_language_specifics")
+ @mock.patch("continuous_delivery_scripts.generate_docs.configuration.get_value_or_default")
+ def test_nested_published_guides_appear_in_discovery_outputs(self, get_config, plugin):
+ with TemporaryDirectory() as temporary_root:
+ root = Path(temporary_root)
+ guides = root / "guides"
+ nested = guides / "migrations"
+ nested.mkdir(parents=True)
+ (guides / "index.md").write_text("# Guides\n\n[Migrations](migrations/index.md)\n", encoding="utf8")
+ (nested / "index.md").write_text(
+ "# Migrations\n\n[Refactoring](refactoring-go-modules.md#details)\n", encoding="utf8"
+ )
+ (nested / "refactoring-go-modules.md").write_text(
+ "# Refactoring\n\n[Overview](../index.md#intro)\n", encoding="utf8"
+ )
+ settings = {
+ ConfigurationVariable.PROJECT_ROOT: str(root),
+ ConfigurationVariable.DOCUMENTATION_GUIDES_DIR: str(guides),
+ ConfigurationVariable.DOCUMENTATION_GUIDES_OUTPUT_FOLDER: "guides",
+ ConfigurationVariable.PROJECT_NAME: "Example Project",
+ ConfigurationVariable.DOCUMENTATION_SITE_URL: "https://example.org/project/",
+ }
+ get_config.side_effect = lambda key, default: settings.get(key, default)
+
+ def generate_api(output, _module):
+ (output / "index.html").write_text("API", encoding="utf8")
+
+ plugin.return_value.generate_code_documentation.side_effect = generate_api
+ output = root / "published"
+ generate_documentation(output, "module")
+
+ sitemap = ElementTree.parse(output / "sitemap.xml")
+ urls = [element.text for element in sitemap.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc")]
+ self.assertEqual(
+ urls,
+ [
+ "https://example.org/project/api.html",
+ "https://example.org/project/guides/index.html",
+ "https://example.org/project/guides/migrations/index.html",
+ "https://example.org/project/guides/migrations/refactoring-go-modules.html",
+ "https://example.org/project/",
+ ],
+ )
+
+ index = (output / "llms.txt").read_text(encoding="utf8")
+ self.assertIn("[Migrations](https://example.org/project/guides/migrations/index.html)", index)
+ self.assertIn(
+ "[Refactoring](https://example.org/project/guides/migrations/refactoring-go-modules.html)", index
+ )
+
+ full = (output / "llms-full.txt").read_text(encoding="utf8")
+ self.assertIn(
+ "[Refactoring](https://example.org/project/guides/migrations/refactoring-go-modules.html#details)", full
+ )
+ self.assertIn("[Overview](https://example.org/project/guides/index.html#intro)", full)
+
@mock.patch("continuous_delivery_scripts.generate_docs.get_language_specifics")
@mock.patch("continuous_delivery_scripts.generate_docs.configuration.get_value_or_default")
def test_does_not_invent_site_urls_and_respects_existing_files(self, get_config, plugin):
diff --git a/tests/generate_docs/test_generate_docs_python.py b/tests/generate_docs/test_generate_docs_python.py
index 0eae5462..632e2573 100644
--- a/tests/generate_docs/test_generate_docs_python.py
+++ b/tests/generate_docs/test_generate_docs_python.py
@@ -198,6 +198,81 @@ def generate_api(output_directory, _module):
)
self.assertEqual((output / "llms.txt").read_text(encoding="utf8"), "# Tool map\n")
+ @mock.patch("continuous_delivery_scripts.generate_docs.get_language_specifics")
+ @mock.patch("continuous_delivery_scripts.generate_docs.configuration.get_value_or_default")
+ def test_publishes_nested_guides_and_rewrites_relative_markdown_links(
+ self, get_value_or_default, get_language_specifics
+ ):
+ with SystemTemporaryDirectory() as temporary_root:
+ project_root = pathlib.Path(temporary_root, "project")
+ guides = project_root / "guides"
+ migrations = guides / "migrations"
+ migrations.mkdir(parents=True)
+ (guides / "index.md").write_text(
+ "# Guides\n\n"
+ "## Overview\n\n"
+ "[Migration guide](migrations/index.md#section)\n\n"
+ "[External](https://example.com/migrations/index.md#section)\n\n"
+ "[Asset](assets/diagram.svg)\n",
+ encoding="utf8",
+ )
+ (guides / "go-modules.md").write_text(
+ "# Go modules\n\n[Refactoring](migrations/refactoring-go-modules.md)\n", encoding="utf8"
+ )
+ (migrations / "index.md").write_text(
+ "# Migrations\n\n"
+ "[Refactoring](refactoring-go-modules.md#details)\n\n"
+ "[Go modules](../go-modules.md)\n",
+ encoding="utf8",
+ )
+ (migrations / "refactoring-go-modules.md").write_text(
+ "# Refactoring Go modules\n\n[Overview](../index.md#overview)\n", encoding="utf8"
+ )
+ get_value_or_default.side_effect = lambda key, _default: {
+ ConfigurationVariable.PROJECT_ROOT: str(project_root),
+ ConfigurationVariable.DOCUMENTATION_GUIDES_DIR: str(guides),
+ ConfigurationVariable.DOCUMENTATION_GUIDES_OUTPUT_FOLDER: "docs/guides",
+ ConfigurationVariable.PROJECT_NAME: "Nested Project",
+ }.get(key)
+ output = pathlib.Path(temporary_root, "site")
+
+ def generate_api(output_directory, _module):
+ (output_directory / "index.html").write_text(
+ "API reference
", encoding="utf8"
+ )
+
+ get_language_specifics.return_value.generate_code_documentation.side_effect = generate_api
+
+ generate_documentation(output, "continuous_delivery_scripts")
+
+ self.assertTrue((output / "docs" / "guides" / "index.html").is_file())
+ self.assertTrue((output / "docs" / "guides" / "go-modules.html").is_file())
+ self.assertTrue((output / "docs" / "guides" / "migrations" / "index.html").is_file())
+ self.assertTrue((output / "docs" / "guides" / "migrations" / "refactoring-go-modules.html").is_file())
+
+ root_guide = (output / "docs" / "guides" / "index.html").read_text(encoding="utf8")
+ self.assertIn('href="migrations/index.html#section"', root_guide)
+ self.assertIn('href="https://example.com/migrations/index.md#section"', root_guide)
+ self.assertIn('href="assets/diagram.svg"', root_guide)
+
+ modules_guide = (output / "docs" / "guides" / "go-modules.html").read_text(encoding="utf8")
+ self.assertIn('href="migrations/refactoring-go-modules.html"', modules_guide)
+
+ nested_index = (output / "docs" / "guides" / "migrations" / "index.html").read_text(encoding="utf8")
+ self.assertIn('href="refactoring-go-modules.html#details"', nested_index)
+ self.assertIn('href="../go-modules.html"', nested_index)
+ self.assertIn('href="../../../index.html"', nested_index)
+ self.assertIn('href="../../../api.html"', nested_index)
+
+ nested_guide = (output / "docs" / "guides" / "migrations" / "refactoring-go-modules.html").read_text(
+ encoding="utf8"
+ )
+ self.assertIn('href="../index.html#overview"', nested_guide)
+
+ landing = (output / "index.html").read_text(encoding="utf8")
+ self.assertIn('href="docs/guides/go-modules.html"', landing)
+ self.assertIn('href="docs/guides/migrations/index.html"', landing)
+
def test_clear_previous_docs(self):
with Patcher() as patcher:
fake_output_dir = pathlib.Path("local_docs")