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