diff --git a/src/x2mdx/cli.py b/src/x2mdx/cli.py index b3c5288..25982d5 100644 --- a/src/x2mdx/cli.py +++ b/src/x2mdx/cli.py @@ -226,6 +226,14 @@ def build_parser() -> argparse.ArgumentParser: default="OpenAPI Lifecycle Overview", help="Title to use for the generated overview page.", ) + build_lifecycle.add_argument( + "--link-prefix", + help="Optional root-relative URL prefix to use for overview/spec links.", + ) + build_lifecycle.add_argument( + "--primary-spec-id", + help="Optional spec id to promote to the top-level overview page.", + ) build_lifecycle.add_argument( "--fixture-root", help="Directory to resolve manifest fixture paths from; defaults to the manifest directory", @@ -631,7 +639,11 @@ def main(argv: Sequence[str] | None = None) -> int: report = build_openapi_report_from_manifest_args(args) if args.output_file: - page = build_api_page(report, output_path=Path(args.output_file).name) + page = build_api_page( + report, + output_path=Path(args.output_file).name, + primary_spec_id=args.primary_spec_id, + ) output_file = Path(args.output_file) write_page(page, output_file) if args.docs_json: @@ -652,6 +664,8 @@ def main(argv: Sequence[str] | None = None) -> int: overview_name=args.overview_name, spec_dir_name=args.spec_dir_name, overview_title=args.overview_title, + link_prefix=args.link_prefix, + primary_spec_id=args.primary_spec_id, ), Path(args.output_dir), ) diff --git a/src/x2mdx/openapi/render.py b/src/x2mdx/openapi/render.py index 8ef8f82..30ec547 100644 --- a/src/x2mdx/openapi/render.py +++ b/src/x2mdx/openapi/render.py @@ -28,6 +28,23 @@ def slugify(value: str) -> str: return output +def spec_page_name(spec: OpenApiSpecLifecycle) -> str: + return f"{slugify(spec.spec_id)}.mdx" + + +def spec_page_link(spec: OpenApiSpecLifecycle, *, spec_dir_name: str) -> str: + return f"{spec_dir_name}/{Path(spec_page_name(spec)).with_suffix('').as_posix()}" + + +def normalize_link_prefix(link_prefix: str) -> str: + trimmed = link_prefix.strip() + if not trimmed: + raise ValueError("link_prefix must not be empty") + if trimmed == "/": + return "" + return "/" + trimmed.strip("/") + + def md_text(text: Any) -> str: output = html.escape(str(text), quote=False) output = output.replace("{", "\\{").replace("}", "\\}") @@ -566,19 +583,80 @@ def build_spec_page(spec: OpenApiSpecLifecycle, spec_dir_name: str) -> Page: ) body = body.replace("## Reference\n\n str: + if not specs: + return "" + + normalized_link_prefix = normalize_link_prefix(link_prefix) if link_prefix else None + rows = [] + for spec in sorted(specs, key=lambda item: item.spec_id): + target = spec_page_link(spec, spec_dir_name=spec_dir_name) + href = f"{normalized_link_prefix}/{target}" if normalized_link_prefix is not None else f"./{target}" + rows.append( + [ + f"[Open]({href})", + md_code(spec.spec_id), + lifecycle_value(spec.introduced_version, "introduced"), + md_code(spec.latest_version), + lifecycle_value(spec.removed_version, "removed"), + changed_versions_value(spec.changed_in_versions), + md_code(spec.entity_count), + ] + ) + + return render_template("openapi/additional_specs.md.j2", rows=rows) + + +def build_primary_spec_page( + report: OpenApiLifecycleReport, + primary_spec: OpenApiSpecLifecycle, + *, + overview_name: str, + overview_title: str, + spec_dir_name: str, + link_prefix: str | None = None, +) -> Page: + spec_page = build_spec_page(primary_spec, spec_dir_name) + secondary_specs = [spec for spec in report.specs if spec.spec_id != primary_spec.spec_id] + blocks = list(spec_page.blocks) + additional_specs = render_additional_specs_section( + secondary_specs, + spec_dir_name=spec_dir_name, + link_prefix=link_prefix, + ) + if additional_specs: + blocks.append(RawMarkdown(additional_specs)) + + return Page( + path=overview_name, + title=overview_title, + description=spec_page.description, + blocks=blocks, + ) + + def build_overview_page( report: OpenApiLifecycleReport, spec_pages: list[Page], overview_name: str, overview_title: str, + *, + spec_dir_name: str, + link_prefix: str | None = None, ) -> Page: + normalized_link_prefix = normalize_link_prefix(link_prefix) if link_prefix else None spec_page_map = { spec.spec_id: page for spec, page in zip(sorted(report.specs, key=lambda item: item.spec_id), spec_pages) @@ -586,7 +664,8 @@ def build_overview_page( rows = [] for spec in sorted(report.specs, key=lambda item: item.spec_id): page = spec_page_map[spec.spec_id] - spec_dir_link = f"./{page.path[:-4]}" + target = spec_page_link(spec, spec_dir_name=spec_dir_name) + spec_dir_link = f"{normalized_link_prefix}/{target}" if normalized_link_prefix is not None else f"./{page.path[:-4]}" rows.append( [ f"[Open]({spec_dir_link})", @@ -624,14 +703,56 @@ def build_pages( overview_name: str = "overview.mdx", spec_dir_name: str = "specs", overview_title: str = "OpenAPI Lifecycle Overview", + link_prefix: str | None = None, + primary_spec_id: str | None = None, ) -> list[Page]: - spec_pages = [build_spec_page(spec, spec_dir_name) for spec in sorted(report.specs, key=lambda item: item.spec_id)] - overview_page = build_overview_page(report, spec_pages, overview_name, overview_title) + specs = sorted(report.specs, key=lambda item: item.spec_id) + if primary_spec_id: + primary_spec = next((spec for spec in specs if spec.spec_id == primary_spec_id), None) + if primary_spec is None: + raise ValueError(f"primary_spec_id {primary_spec_id!r} did not match any rendered spec") + secondary_pages = [build_spec_page(spec, spec_dir_name) for spec in specs if spec.spec_id != primary_spec_id] + primary_page = build_primary_spec_page( + report, + primary_spec, + overview_name=overview_name, + overview_title=overview_title, + spec_dir_name=spec_dir_name, + link_prefix=link_prefix, + ) + return [primary_page, *secondary_pages] + + spec_pages = [build_spec_page(spec, spec_dir_name) for spec in specs] + overview_page = build_overview_page( + report, + spec_pages, + overview_name, + overview_title, + spec_dir_name=spec_dir_name, + link_prefix=link_prefix, + ) return [overview_page, *spec_pages] -def build_api_page(report: OpenApiLifecycleReport, output_path: str) -> Page: +def build_api_page( + report: OpenApiLifecycleReport, + output_path: str, + *, + primary_spec_id: str | None = None, +) -> Page: specs = sorted(report.specs, key=lambda item: item.spec_id) + if primary_spec_id: + primary_spec = next((spec for spec in specs if spec.spec_id == primary_spec_id), None) + if primary_spec is None: + raise ValueError(f"primary_spec_id {primary_spec_id!r} did not match any rendered spec") + spec_page = build_spec_page(primary_spec, "specs") + return Page( + path=output_path, + title=spec_page.title, + description="Generated API reference from versioned OpenAPI artifacts", + blocks=spec_page.blocks, + ) + if len(specs) != 1: raise ValueError(f"Expected exactly one spec for single-page output, found {len(specs)}") diff --git a/src/x2mdx/templates/openapi/additional_specs.md.j2 b/src/x2mdx/templates/openapi/additional_specs.md.j2 new file mode 100644 index 0000000..b79fd49 --- /dev/null +++ b/src/x2mdx/templates/openapi/additional_specs.md.j2 @@ -0,0 +1,6 @@ +{% import "shared/macros.md.j2" as shared %} +{{ shared.generic_table_section( + "Additional Specs", + ["Page", "Spec", "Introduced", "Latest", "Removed", "Changed In Versions", "Entities"], + rows +) }} diff --git a/tests/test_openapi.py b/tests/test_openapi.py index ba58969..fee96b3 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -57,7 +57,8 @@ def _write_manifest( "versions": [], } for version, source_path, contents in versions: - relative_path = Path(version) / "openapi.yaml" + filename = Path(source_path).name or "openapi.yaml" + relative_path = Path(version) / filename write_text(fixture_root / relative_path, contents) manifest["versions"].append( { @@ -279,6 +280,147 @@ def test_render_pages_from_report_round_trip(self) -> None: self.assertNotIn("### `GET /ping`", spec_page) self.assertNotIn("| Endpoint | Operation ID | Summary | Tags |", spec_page) + def test_render_pages_link_prefix_emits_root_relative_multi_spec_links(self) -> None: + report = build_openapi_lifecycle_report_from_snapshots( + [ + self._snapshot( + "v0.1.0", + "published/scan.yaml", + """ + openapi: 3.0.3 + info: + title: Scan API + version: 0.1.0 + paths: + /scan: + get: + operationId: getScan + summary: Scan endpoint + responses: + "200": + description: ok + """, + ), + self._snapshot( + "v0.1.0", + "published/scan-stream-server.yaml", + """ + openapi: 3.0.3 + info: + title: Scan Streaming API + version: 0.1.0 + paths: + /stream: + get: + operationId: getStream + summary: Stream endpoint + responses: + "200": + description: ok + """, + ), + ], + OpenApiLifecycleConfig( + roots=["published"], + include_spec_patterns=[r".*\.yaml$"], + canonical_path_map={}, + priority_prefixes=["published/"], + ), + source_name="unit test snapshots", + version_filter="unit test versions", + ) + + pages = build_pages( + report, + overview_name="index.mdx", + overview_title="Splice Scan APIs", + link_prefix="/reference/splice-scan-openapi", + ) + out_dir = self.root / "out-link-prefix" + write_pages(pages, out_dir) + + overview = (out_dir / "index.mdx").read_text(encoding="utf-8") + self.assertIn("[Open](/reference/splice-scan-openapi/specs/scan-yaml)", overview) + self.assertIn("[Open](/reference/splice-scan-openapi/specs/scan-stream-server-yaml)", overview) + self.assertNotIn("[Open](./specs/scan-yaml)", overview) + + def test_render_pages_primary_spec_promotes_spec_to_overview_page(self) -> None: + report = build_openapi_lifecycle_report_from_snapshots( + [ + self._snapshot( + "v0.1.0", + "published/scan.yaml", + """ + openapi: 3.0.3 + info: + title: Scan API + version: 0.1.0 + paths: + /livez: + get: + operationId: getLivez + summary: Liveness + responses: + "200": + description: ok + """, + ), + self._snapshot( + "v0.1.0", + "published/scan-stream-server.yaml", + """ + openapi: 3.0.3 + info: + title: Scan Streaming API + version: 0.1.0 + paths: + /stream: + get: + operationId: getStream + summary: Stream endpoint + responses: + "200": + description: ok + """, + ), + ], + OpenApiLifecycleConfig( + roots=["published"], + include_spec_patterns=[r".*\.yaml$"], + canonical_path_map={}, + priority_prefixes=["published/"], + ), + source_name="unit test snapshots", + version_filter="unit test versions", + ) + + pages = build_pages( + report, + overview_name="index.mdx", + overview_title="Splice Scan APIs", + link_prefix="/reference/splice-scan-openapi", + primary_spec_id="scan.yaml", + ) + + out_dir = self.root / "out-primary-spec" + write_pages(pages, out_dir) + + primary_page = out_dir / "index.mdx" + streaming_page = out_dir / "specs" / "scan-stream-server-yaml.mdx" + duplicate_primary_page = out_dir / "specs" / "scan-yaml.mdx" + + self.assertTrue(primary_page.exists()) + self.assertTrue(streaming_page.exists()) + self.assertFalse(duplicate_primary_page.exists()) + + rendered = primary_page.read_text(encoding="utf-8") + self.assertIn('title: "Splice Scan APIs"', rendered) + self.assertIn("## Table of Contents", rendered) + self.assertIn("## Version Change Summary", rendered) + self.assertIn("## Reference", rendered) + self.assertIn("## Additional Specs", rendered) + self.assertIn("[Open](/reference/splice-scan-openapi/specs/scan-stream-server-yaml)", rendered) + def test_render_pages_group_operations_by_path(self) -> None: report = build_openapi_lifecycle_report_from_snapshots( [ @@ -645,6 +787,89 @@ def test_cli_build_api_pages_from_manifest_writes_single_file_and_updates_docs_j reference_group = next(group for group in version["groups"] if group["group"] == "Reference") self.assertEqual(reference_group["pages"], ["appdev/reference/json-api-reference"]) + def test_cli_build_api_pages_from_manifest_supports_primary_spec_mode(self) -> None: + manifest_path = self._write_manifest( + name="cli-primary-spec", + versions=[ + ( + "v0.1.0", + "published/scan.yaml", + """ + openapi: 3.0.3 + info: + title: Scan API + version: 0.1.0 + paths: + /livez: + get: + operationId: getLivez + summary: Liveness + responses: + "200": + description: ok + """, + ), + ( + "v0.1.0", + "published/scan-stream-server.yaml", + """ + openapi: 3.0.3 + info: + title: Scan Streaming API + version: 0.1.0 + paths: + /stream: + get: + operationId: getStream + summary: Stream endpoint + responses: + "200": + description: ok + """, + ), + ], + ) + + out_dir = self.root / "rendered-primary-spec" + self.assertEqual( + cli_main( + [ + "openapi", + "build-api-pages-from-manifest", + "--manifest", + str(manifest_path), + "--root", + "published", + "--include-spec-pattern", + r"^scan\.yaml$", + "--include-spec-pattern", + r"^scan-stream-server\.yaml$", + "--output-dir", + str(out_dir), + "--overview-name", + "index.mdx", + "--overview-title", + "Splice Scan APIs", + "--spec-dir-name", + "specs", + "--link-prefix", + "/reference/splice-scan-openapi", + "--primary-spec-id", + "scan.yaml", + ] + ), + 0, + ) + + primary_page = out_dir / "index.mdx" + streaming_page = out_dir / "specs" / "scan-stream-server-yaml.mdx" + duplicate_primary_page = out_dir / "specs" / "scan-yaml.mdx" + + self.assertTrue(primary_page.exists()) + self.assertTrue(streaming_page.exists()) + self.assertFalse(duplicate_primary_page.exists()) + self.assertIn("## Table of Contents", primary_page.read_text(encoding="utf-8")) + if __name__ == "__main__": unittest.main()