diff --git a/pipeline/core/builder.py b/pipeline/core/builder.py index 11bd920ac..632da72e8 100644 --- a/pipeline/core/builder.py +++ b/pipeline/core/builder.py @@ -1129,8 +1129,26 @@ class DocumentationBuilder: @staticmethod def _slugify(value: str) -> str: - """Lowercase, hyphenate, and strip a string for use in a URL path.""" - return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", value.lower())).strip("-") + """Lowercase, hyphenate, and strip a string for use in a URL path. + + Apostrophes are dropped rather than turned into separators, matching + Mintlify: "Get the authenticated user's provider user ID" slugs to + ``...-users-provider-user-id``, not ``...-user-s-...``. + """ + cleaned = re.sub(r"['’]", "", value.lower()) + return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", cleaned)).strip("-") + + @staticmethod + def _tag_slug(value: str) -> str: + """Slug an OpenAPI tag the way Mintlify does, preserving underscores. + + Mintlify lowercases the tag and replaces whitespace, but otherwise uses + it verbatim. Underscores must survive: the LangSmith spec carries both + ``annotation-queues`` and ``annotation_queues`` as distinct tags that + render to different directories, so normalising them together would + point at pages that do not exist. + """ + return re.sub(r"[^a-z0-9_-]+", "-", value.lower()).strip("-") def _read_frontmatter(self, path: Path) -> dict: """Return the YAML frontmatter of an MDX file, or an empty dict.""" @@ -1214,17 +1232,25 @@ class DocumentationBuilder: for operation in item.values(): if not isinstance(operation, dict) or "responses" not in operation: continue + # Mintlify renders no page for hidden operations. + if operation.get("x-hidden"): + continue summary = operation.get("summary") or operation.get("operationId") if not summary: continue tags = operation.get("tags") or ["default"] slug = ( - f"{self._slugify(str(tags[0]))}/{self._slugify(str(summary))}" + f"{self._tag_slug(str(tags[0]))}/{self._slugify(str(summary))}" ) - if slug in seen: - continue - seen.add(slug) - entries.append((label, f"{base}/{slug}", str(summary))) + # Two operations can share a summary. Mintlify keeps both + # and disambiguates with a numeric suffix, so mirror that + # rather than dropping the second page. + unique, duplicate_index = slug, 0 + while unique in seen: + duplicate_index += 1 + unique = f"{slug}-{duplicate_index}" + seen.add(unique) + entries.append((label, f"{base}/{unique}", str(summary))) return entries def _generate_llms_txt(self) -> None: diff --git a/src/images/integrations/vertex-ai-image-example.jpg b/src/images/integrations/vertex-ai-image-example.jpg index 5d154780d..32dd237ed 100644 Binary files a/src/images/integrations/vertex-ai-image-example.jpg and b/src/images/integrations/vertex-ai-image-example.jpg differ diff --git a/tests/unit_tests/test_builder.py b/tests/unit_tests/test_builder.py index 35265eeea..794c33301 100644 --- a/tests/unit_tests/test_builder.py +++ b/tests/unit_tests/test_builder.py @@ -5,6 +5,7 @@ covering all methods and edge cases including file extension handling, directory structure preservation, and error conditions. """ +import json from pathlib import Path import pytest @@ -704,3 +705,72 @@ def test_build_all_writes_llms_txt() -> None: # noindex pages and snippets are not real pages, so they stay out. assert "hidden.md" not in llms_txt assert "snippets/shared.md" not in llms_txt + + +def test_tag_slug_preserves_underscores() -> None: + """Test that OpenAPI tag slugs keep underscores but normalize case and spaces. + + The LangSmith spec carries both `annotation-queues` and `annotation_queues` + as distinct tags that Mintlify renders to different directories, so + collapsing them together would generate URLs for pages that do not exist. + """ + slug = DocumentationBuilder._tag_slug + assert slug("annotation_queues") == "annotation_queues" + assert slug("annotation-queues") == "annotation-queues" + assert slug("SCIM Tokens") == "scim-tokens" + assert slug("A2A") == "a2a" + + +def test_slugify_drops_apostrophes() -> None: + """Test that apostrophes are removed rather than turned into separators.""" + slug = DocumentationBuilder._slugify + assert slug("Get the authenticated user's provider user ID") == ( + "get-the-authenticated-users-provider-user-id" + ) + assert slug("Get company info") == "get-company-info" + + +def test_openapi_entries_skip_hidden_and_number_duplicates() -> None: + """Test that hidden operations are omitted and duplicate slugs get suffixes. + + Mintlify renders no page for `x-hidden` operations, and disambiguates two + operations sharing a summary with a numeric suffix instead of dropping one. + """ + spec = { + "paths": { + "/a": {"get": {"tags": ["orgs"], "summary": "Get info", "responses": {}}}, + "/b": {"get": {"tags": ["orgs"], "summary": "Get info", "responses": {}}}, + "/c": { + "get": { + "tags": ["fleet orgs"], + "summary": "Hidden op", + "responses": {}, + "x-hidden": True, + } + }, + } + } + docs_json = { + "navigation": { + "pages": [ + { + "group": "REST API", + "openapi": { + "source": "langsmith/spec.json", + "directory": "langsmith/api", + }, + } + ] + } + } + files: list[File] = [ + {"path": "docs.json", "content": json.dumps(docs_json)}, + {"path": "langsmith/spec.json", "content": json.dumps(spec)}, + {"path": "langsmith/page.mdx", "content": "---\ntitle: Page\n---\n\nBody.\n"}, + ] + with file_system(files) as fs: + builder = DocumentationBuilder(fs.src_dir, fs.build_dir) + builder.build_all() + slugs = [slug for _, slug, _ in builder._openapi_entries()] + + assert slugs == ["langsmith/api/orgs/get-info", "langsmith/api/orgs/get-info-1"]