diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml new file mode 100644 index 000000000..05a6a5767 --- /dev/null +++ b/.github/workflows/docs-ci.yml @@ -0,0 +1,44 @@ +name: Docs CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +concurrency: + group: docs-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Docs checks + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install OpenAPI validator + run: pip install --quiet pyyaml openapi-spec-validator + # continue-on-error is deliberately absent: each check is a hard gate. + # Steps run in order and the job reports the first failure. + - name: Check frontmatter + run: python scripts/check_frontmatter.py + - name: Check code samples + if: '!cancelled()' + run: python scripts/check_code_samples.py + - name: Check internal links + if: '!cancelled()' + run: python scripts/check_links.py + - name: Check redirects + if: '!cancelled()' + run: python scripts/check_redirects.py + - name: Check OpenAPI specs + if: '!cancelled()' + run: python scripts/check_openapi.py diff --git a/docs.json b/docs.json index f7308876a..e086cc793 100644 --- a/docs.json +++ b/docs.json @@ -409,7 +409,7 @@ "docs/data-governance/data-volume-monitoring", "docs/data-governance/warehouse-sync-monitoring", "docs/data-governance/data-clean-up", - "docs/data-governance/ai-powered-data-governance" + "docs/data-governance/ai-powered-data-governance" ] }, { @@ -705,7 +705,7 @@ "guides/strategic-playbooks/product-analytics-strategy/finale" ] }, - "guides/strategic-playbooks/onboarding-playbook", + "guides/strategic-playbooks/onboarding-playbook", "guides/strategic-playbooks/project-migration", "guides/strategic-playbooks/feature-flag-migration-playbook" ] @@ -1879,7 +1879,7 @@ }, { "source": "/docs/formulas", - "destination": "/changelogs/2023-09-19-formulas" + "destination": "/changelogs" }, { "source": "/docs/funnels", @@ -2383,7 +2383,7 @@ }, { "source": "/docs/tracking/data-warehouse/groups", - "destination": "/docs/tracking-methods/data-warehouse/sending-group-profiles" + "destination": "/docs/tracking-methods/warehouse-connectors" }, { "source": "/docs/tracking/data-warehouse/redshift", @@ -2747,27 +2747,27 @@ }, { "source": "/guides/plan/setup", - "destination": "/guides/strategic-playbooks/onboarding-playbook/plan/setup" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/plan/framework", - "destination": "/guides/strategic-playbooks/onboarding-playbook/plan/framework" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/plan/tracking-strategy", - "destination": "/guides/strategic-playbooks/onboarding-playbook/plan/tracking-strategy" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/implement/send-your-data", - "destination": "/guides/strategic-playbooks/onboarding-playbook/implement/send-your-data" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/implement/qa-data-audit", - "destination": "/guides/strategic-playbooks/onboarding-playbook/implement/qa-data-audit" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/implement/establish-governance", - "destination": "/guides/strategic-playbooks/onboarding-playbook/implement/establish-governance" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/launch/create-boards", @@ -2855,7 +2855,7 @@ }, { "source": "/hc", - "destination": "docs/getting-started/what-is-mixpanel" + "destination": "/docs/what-is-mixpanel" }, { "source": "/hc/admin/general_settings", @@ -3227,23 +3227,23 @@ }, { "source": "/hc/en-us/articles/13174988269844*", - "destination": "/changelogs/2022-12-01-improvements" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13175102961428*", - "destination": "/changelogs/2022-12-13-boards" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13175146659732*", - "destination": "/changelogs/2023-01-18-table-boards" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13175184938260*", - "destination": "/changelogs/2023-01-23-users-flows" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13175224500628*", - "destination": "/changelogs/2023-01-31-embed" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13271005313556*", @@ -3251,39 +3251,39 @@ }, { "source": "/hc/en-us/articles/13395179786644*", - "destination": "/changelogs/2022-07-08-reorient" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395198918804*", - "destination": "/changelogs/2022-11-03-session-improvements" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395199893652*", - "destination": "/changelogs/2022-11-07-millisecond" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395205961236*", - "destination": "/changelogs/2022-07-01-view-users" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395233184916*", - "destination": "/changelogs/2022-06-16-faster-workflow" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395235568660*", - "destination": "/changelogs/2022-05-31-improve-conversion-flow" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395324158868*", - "destination": "/changelogs/2022-05-24-lexicon-context" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395325754772*", - "destination": "/changelogs/2022-04-18-relative-comparison" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13395328448404*", - "destination": "/changelogs/2022-03-29-text-boards" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/13530857626132*", @@ -3291,7 +3291,7 @@ }, { "source": "/hc/en-us/articles/13756463065492*", - "destination": "/changelogs/2023-02-28-retention-calendar-interval" + "destination": "/changelogs" }, { "source": "/hc/en-us/articles/14202292561172*", @@ -3635,7 +3635,7 @@ }, { "source": "/hc/en-us/articles/360041995352*", - "destination": "/docs/tracking/how-tos/events-properties" + "destination": "/docs/data-structure/events-and-properties" }, { "source": "/hc/en-us/articles/360042412051*", @@ -4719,43 +4719,43 @@ }, { "source": "/guides/guides-by-topic/core-reports/create-boards", - "destination":"/guides/guides-by-topic/core-reports" + "destination": "/guides/guides-by-topic/core-reports" }, { "source": "/guides/guides-by-topic/core-reports/discover-insights", - "destination":"/guides/guides-by-topic/core-reports" + "destination": "/guides/guides-by-topic/core-reports" }, { "source": "/guides/guides-by-topic/core-reports/analyze-conversions", - "destination":"/guides/guides-by-topic/core-reports" + "destination": "/guides/guides-by-topic/core-reports" }, { "source": "/guides/guides-by-topic/core-reports/build-user-flows", - "destination":"/guides/guides-by-topic/core-reports" + "destination": "/guides/guides-by-topic/core-reports" }, { "source": "/guides/guides-by-topic/core-reports/track-user-retention", - "destination":"/guides/guides-by-topic/core-reports" + "destination": "/guides/guides-by-topic/core-reports" }, { "source": "/guides/guides-by-topic/core-reports/define-cohorts", - "destination":"/guides/guides-by-topic/core-reports" + "destination": "/guides/guides-by-topic/core-reports" }, { "source": "/guides/strategic-playbooks/onboarding-playbook/plan/*", - "destination":"/guides/strategic-playbooks/onboarding-playbook" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/strategic-playbooks/onboarding-playbook/implement/*", - "destination":"/guides/strategic-playbooks/onboarding-playbook" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/strategic-playbooks/onboarding-playbook/launch/*", - "destination":"/guides/strategic-playbooks/onboarding-playbook" + "destination": "/guides/strategic-playbooks/onboarding-playbook" }, { "source": "/guides/strategic-playbooks/onboarding-playbook/beyond-onboarding", - "destination":"/guides/strategic-playbooks/onboarding-playbook" + "destination": "/guides/strategic-playbooks/onboarding-playbook" } ] } diff --git a/docs/mcp.mdx b/docs/mcp.mdx index 29b000df9..60c847393 100644 --- a/docs/mcp.mdx +++ b/docs/mcp.mdx @@ -222,7 +222,7 @@ Any client that supports the MCP JSON config format, including Microsoft Copilot **Beta.** Service account authentication for MCP is in beta. The interface may change. -[Service accounts](/reference/Mixpanel%20APIs/authentication/service-accounts) are non-human Mixpanel users designed for scripts, back-end services, and automated workflows. They authenticate via a static header — no browser-based login is required. +[Service accounts](/reference/service-accounts) are non-human Mixpanel users designed for scripts, back-end services, and automated workflows. They authenticate via a static header — no browser-based login is required. Use service accounts when you need a headless MCP connection, such as CI/CD pipelines, automated agents, or shared team setups. The service account's project permissions apply: it can only access projects it has been added to. diff --git a/reference/event-deduplication.mdx b/reference/event-deduplication.mdx index 8cfd3d3ad..aa6db007b 100644 --- a/reference/event-deduplication.mdx +++ b/reference/event-deduplication.mdx @@ -23,7 +23,7 @@ Only the four key event properties listed above are used for deduplication. Addi Deduplication occurs when a subset of the event data (event name, distinct\_id, timestamp, \$insert\_id) is identical. Other event properties are not considered. -**Required [Event Object](/docs/data-model#anatomy-of-an-event) attributes** +**Required [Event Object](/docs/data-structure/events-and-properties) attributes** diff --git a/scripts/check_code_samples.py b/scripts/check_code_samples.py new file mode 100644 index 000000000..2c948b6fb --- /dev/null +++ b/scripts/check_code_samples.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +CI gate: every fenced code block in MDX files must declare a language. + +A fenced block opening looks like: + ```python + ```javascript + ```bash + +A block with no language identifier: + ``` + +will cause this check to fail. + +Excluded directories (same as other checks): + - snippets/ + - openapi/ +""" + +import sys +import glob +import os +import re + +EXCLUDED_DIRS = {"snippets", "openapi"} + +def check_file(path: str, display: str) -> list[str]: + errors = [] + with open(path, encoding="utf-8") as fh: + content = fh.read() + + fence_len = 0 # 0 = outside a block; otherwise the opening fence's length + for lineno, line in enumerate(content.splitlines(), 1): + stripped = line.strip() + if not stripped.startswith("```"): + continue + ticks = len(stripped) - len(stripped.lstrip("`")) + rest = stripped[ticks:].strip() + if fence_len: + # Only a bare fence at least as long as the opener closes the block, + # so a ```python block nested inside ````mdx does not end it early. + if ticks >= fence_len and not rest: + fence_len = 0 + continue + if not rest: + errors.append( + f"{display}:{lineno}: code block is missing a language identifier" + ) + fence_len = ticks + + return errors + + +def is_excluded(path: str) -> bool: + parts = path.replace(os.sep, "/").split("/") + return any(part in EXCLUDED_DIRS for part in parts) + + +def main() -> int: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + mdx_files = glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True) + + checked = 0 + all_errors: list[str] = [] + for path in sorted(mdx_files): + rel = os.path.relpath(path, root) + if is_excluded(rel): + continue + all_errors.extend(check_file(path, rel)) + checked += 1 + + if all_errors: + print("Code-sample check FAILED:") + for err in all_errors: + print(f" {err}") + return 1 + + print(f"Code-sample check PASSED ({checked} files checked).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_frontmatter.py b/scripts/check_frontmatter.py new file mode 100644 index 000000000..9dbf32139 --- /dev/null +++ b/scripts/check_frontmatter.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +CI gate: every MDX page must have a 'title' field in its YAML front-matter. + +Directories that are intentionally excluded from the check: + - snippets/ (reusable MDX components, not standalone pages) + - links/ (external-link stubs that use 'url' instead of a body) + - openapi/ (OpenAPI spec files, not MDX pages) +""" + +import re +import sys +import glob +import os + +EXCLUDED_DIRS = {"snippets", "links", "openapi"} + +FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) + + +def check_file(path: str, display: str) -> tuple[list[str], str | None]: + """Return (errors, title). Title is None when absent or empty.""" + errors = [] + with open(path, encoding="utf-8") as fh: + content = fh.read() + + m = FRONTMATTER_RE.match(content) + if not m: + errors.append(f"{display}: missing front-matter block") + return errors, None + + fm = m.group(1) + title = None + title_match = re.search(r"^\s*title\s*:\s*(.*)$", fm, re.MULTILINE) + if not title_match: + errors.append(f"{display}: front-matter is missing required 'title' field") + else: + title = title_match.group(1).strip().strip("\"'") + if not title: + errors.append(f"{display}: front-matter 'title' is empty") + title = None + + # A description is what search results and llms.txt entries render, so a + # page without one is invisible to both. + desc_match = re.search(r"^\s*description\s*:\s*(.*)$", fm, re.MULTILINE) + if not desc_match: + errors.append(f"{display}: front-matter is missing required 'description' field") + elif not desc_match.group(1).strip().strip("\"'"): + errors.append(f"{display}: front-matter 'description' is empty") + + return errors, title + + +def is_excluded(path: str) -> bool: + parts = path.replace(os.sep, "/").split("/") + return any(part in EXCLUDED_DIRS for part in parts) + + +def main() -> int: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + mdx_files = glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True) + + checked = 0 + all_errors: list[str] = [] + titles: dict[str, list[str]] = {} + for path in sorted(mdx_files): + rel = os.path.relpath(path, root) + if is_excluded(rel): + continue + errors, title = check_file(path, rel) + all_errors.extend(errors) + if title: + titles.setdefault(title, []).append(rel) + checked += 1 + + # Two pages sharing a rendered title are indistinguishable in search + # results and to answer engines. + for title, pages in sorted(titles.items()): + if len(pages) > 1: + joined = ", ".join(sorted(pages)) + all_errors.append(f'duplicate title "{title}" on {len(pages)} pages: {joined}') + + if all_errors: + print("Frontmatter check FAILED:") + for err in all_errors: + print(f" {err}") + return 1 + + print(f"Frontmatter check PASSED ({checked} files checked).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_links.py b/scripts/check_links.py new file mode 100644 index 000000000..cf7f7b58e --- /dev/null +++ b/scripts/check_links.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +CI gate: internal links in MDX files must resolve to an existing page. + +Rules: + - Only links whose path starts with '/' are checked (relative links are + ignored because they are rare and context-dependent). + - External URLs (http/https), anchor-only links (#…), and mailto links + are skipped. + - Query-string parameters and fragment anchors are stripped before + looking up the target file. + - A link target is considered valid when one of the following is true: + 1. /.mdx exists + 2. //index.mdx exists (index pages) + 3. is the source of a redirect declared in docs.json + (redirect sources are valid inbound paths even without a backing file) + +Source locations checked: + - href="…" attributes (JSX / HTML in MDX) + - [text](…) Markdown links + +Excluded directories: + - snippets/ + - openapi/ +""" + +import json +import os +import re +import sys +import glob +from urllib.parse import urlparse, unquote + +EXCLUDED_DIRS = {"snippets", "openapi"} + +# Path prefixes that point to static assets, not pages — skip these. +NON_PAGE_PREFIXES = ("/images/", "/icons/", "/logo/", "/favicon") + +# href="..." or href='...' +HREF_RE = re.compile(r"""href=["']([^"']+)["']""") +# [label](url) – skip image links starting with ! +MD_LINK_RE = re.compile(r"(? str: + """Blank out fenced code blocks and inline code spans so that example + links inside them are not treated as real links. Line count is preserved + so reported line numbers stay accurate.""" + out: list[str] = [] + fence_len = 0 + for line in content.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + ticks = len(stripped) - len(stripped.lstrip("`")) + if fence_len: + if ticks >= fence_len and not stripped[ticks:].strip(): + fence_len = 0 + else: + fence_len = ticks + out.append("") + continue + out.append("" if fence_len else INLINE_CODE_RE.sub("", line)) + return "\n".join(out) + + +def extract_links(content: str) -> list[str]: + links: list[str] = [] + for m in HREF_RE.finditer(content): + links.append(m.group(1)) + for m in MD_LINK_RE.finditer(content): + links.append(m.group(1)) + return links + + +def is_internal(link: str) -> bool: + if link.startswith(("http://", "https://", "mailto:", "#")): + return False + if not link.startswith("/"): + return False + if link.startswith(NON_PAGE_PREFIXES): + return False + return True + + +def normalise(link: str) -> str: + """Strip fragment and query-string, then decode percent-encoding.""" + parsed = urlparse(link) + path = parsed.path + return unquote(path).rstrip("/") + + +def build_valid_paths(root: str) -> tuple[set[str], list[str]]: + """Return (exact_paths, wildcard_prefixes) for valid root-relative paths. + + exact_paths – full paths that must match exactly (O(1) lookup via set). + wildcard_prefixes – path prefixes derived from wildcard redirect sources + (e.g. '/changelogs' from '/changelogs/*'). A link + target is valid if it starts with one of these prefixes + followed by '/'. + """ + exact: set[str] = set() + prefixes: list[str] = [] + + # Every .mdx file contributes its path (without extension) and with extension + for mdx in glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True): + rel = os.path.relpath(mdx, root).replace(os.sep, "/") + # e.g. "docs/what-is-mixpanel.mdx" + exact.add("/" + rel) # with .mdx + exact.add("/" + rel[:-4]) # without .mdx + # index pages: "docs/foo/index.mdx" → "/docs/foo" + if rel.endswith("/index.mdx"): + exact.add("/" + rel[: -len("/index.mdx")]) + + # Redirect sources are also valid inbound paths + docs_json = os.path.join(root, "docs.json") + if os.path.exists(docs_json): + with open(docs_json, encoding="utf-8") as fh: + data = json.load(fh) + for redir in data.get("redirects", []): + src = redir.get("source", "") + if src.endswith("/*"): + # Wildcard source: store the prefix for prefix matching + prefix = src[:-2] # e.g. "/changelogs" + exact.add(prefix) + prefixes.append(prefix) + else: + exact.add(src) + + return exact, prefixes + + +def is_excluded(path: str) -> bool: + parts = path.replace(os.sep, "/").split("/") + return any(part in EXCLUDED_DIRS for part in parts) + + +def main() -> int: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + valid_paths, wildcard_prefixes = build_valid_paths(root) + + mdx_files = glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True) + + checked = 0 + all_errors: list[str] = [] + + for path in sorted(mdx_files): + rel = os.path.relpath(path, root) + if is_excluded(rel): + continue + checked += 1 + + with open(path, encoding="utf-8") as fh: + content = strip_code(fh.read()) + + for raw_link in extract_links(content): + if not is_internal(raw_link): + continue + target = normalise(raw_link) + if not target: + continue + + # Direct match (O(1)) + if target in valid_paths: + continue + + # Wildcard-redirect prefix match: check only the small list of + # known wildcard prefixes rather than iterating all valid_paths. + if any(target.startswith(p + "/") for p in wildcard_prefixes): + continue + + all_errors.append( + f"{rel}: broken internal link '{raw_link}' → '{target}'" + ) + + if all_errors: + print("Links check FAILED:") + for err in all_errors: + print(f" {err}") + return 1 + + print(f"Links check PASSED ({checked} files, {len(valid_paths)} valid paths indexed).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_openapi.py b/scripts/check_openapi.py new file mode 100644 index 000000000..d493fb5cb --- /dev/null +++ b/scripts/check_openapi.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +CI gate: validate every OpenAPI specification under openapi/. + +Checks performed: + 1. The file parses as YAML or JSON. + 2. Required top-level structure is present (openapi/swagger version, info + with title and version, and paths). + 3. Every local $ref ("#/...") resolves to a node that exists in the document. + 4. If openapi-spec-validator is installed, the full spec is validated against + the OpenAPI schema. Without it, checks 1-3 still run. +""" + +import glob +import json +import os +import sys + +import yaml + + +def load(path: str): + with open(path, encoding="utf-8") as fh: + text = fh.read() + if path.endswith(".json"): + return json.loads(text) + return yaml.safe_load(text) + + +def iter_refs(node, trail="#"): + """Yield every ($ref value, location) pair in the document.""" + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str): + yield value, trail + else: + yield from iter_refs(value, f"{trail}/{key}") + elif isinstance(node, list): + for index, value in enumerate(node): + yield from iter_refs(value, f"{trail}/{index}") + + +def resolves(doc, ref: str) -> bool: + """Walk a local JSON pointer ("#/components/schemas/Foo") through the doc.""" + node = doc + for part in ref.lstrip("#/").split("/"): + # JSON pointer escapes, per RFC 6901. + part = part.replace("~1", "/").replace("~0", "~") + if isinstance(node, list): + if not part.isdigit() or int(part) >= len(node): + return False + node = node[int(part)] + elif isinstance(node, dict): + if part not in node: + return False + node = node[part] + else: + return False + return True + + +def check_file(path: str, display: str) -> list[str]: + errors = [] + try: + doc = load(path) + except Exception as exc: # noqa: BLE001 - report any parse failure verbatim + return [f"{display}: does not parse ({type(exc).__name__}: {exc})"] + + if not isinstance(doc, dict): + return [f"{display}: top level is not a mapping"] + + if not (doc.get("openapi") or doc.get("swagger")): + errors.append(f"{display}: missing 'openapi' (or 'swagger') version field") + + info = doc.get("info") + if not isinstance(info, dict): + errors.append(f"{display}: missing 'info' object") + else: + for field in ("title", "version"): + if not info.get(field): + errors.append(f"{display}: 'info.{field}' is missing or empty") + + if "paths" not in doc and "webhooks" not in doc: + errors.append(f"{display}: missing 'paths'") + + for ref, where in iter_refs(doc): + if ref.startswith("#"): + if not resolves(doc, ref): + errors.append(f"{display}: unresolved local $ref '{ref}' at {where}") + elif not ref.startswith(("http://", "https://")): + target = os.path.join(os.path.dirname(path), ref.split("#")[0]) + if not os.path.exists(target): + errors.append(f"{display}: $ref points at a missing file '{ref}'") + + return errors + + +def main() -> int: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + spec_dir = os.path.join(root, "openapi") + if not os.path.isdir(spec_dir): + print("OpenAPI check SKIPPED (no openapi/ directory).") + return 0 + + specs = sorted( + glob.glob(os.path.join(spec_dir, "**", "*.yaml"), recursive=True) + + glob.glob(os.path.join(spec_dir, "**", "*.yml"), recursive=True) + + glob.glob(os.path.join(spec_dir, "**", "*.json"), recursive=True) + ) + + try: + from openapi_spec_validator import validate as spec_validate + + deep = True + except ImportError: + spec_validate = None + deep = False + + all_errors: list[str] = [] + for path in specs: + rel = os.path.relpath(path, root) + errors = check_file(path, rel) + if not errors and spec_validate is not None: + try: + spec_validate(load(path)) + except Exception as exc: # noqa: BLE001 - surface the validator's message + first = str(exc).split("\n")[0] + errors.append(f"{rel}: failed OpenAPI schema validation: {first}") + all_errors.extend(errors) + + if all_errors: + print("OpenAPI check FAILED:") + for err in all_errors: + print(f" {err}") + return 1 + + depth = "structure + schema" if deep else "structure only (validator not installed)" + print(f"OpenAPI check PASSED ({len(specs)} specs validated, {depth}).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py new file mode 100644 index 000000000..b5a0b461f --- /dev/null +++ b/scripts/check_redirects.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +CI gate: validate the 'redirects' section of docs.json. + +Checks performed: + 1. No duplicate redirect source paths. + 2. No redirect loops (a self-redirect or a cycle). + 3. Every redirect destination resolves to an existing page OR is itself + the source of another redirect, including a wildcard one (chained + redirects are allowed). + Wildcard destinations (containing '*') are skipped because their + validity is structural rather than path-based. +""" + +import fnmatch +import json +import os +import sys +import glob + + +def build_file_paths(root: str) -> set[str]: + """Return all root-relative page paths derived from .mdx files.""" + paths: set[str] = set() + for mdx in glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True): + rel = os.path.relpath(mdx, root).replace(os.sep, "/") + paths.add("/" + rel) # with extension + paths.add("/" + rel[:-4]) # without extension + if rel.endswith("/index.mdx"): + paths.add("/" + rel[: -len("/index.mdx")]) + return paths + + +def normalise(path: str) -> str: + """Strip trailing slash and query string.""" + return path.split("?")[0].rstrip("/") or "/" + + +def main() -> int: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + docs_json_path = os.path.join(root, "docs.json") + + if not os.path.exists(docs_json_path): + print("Redirects check SKIPPED (docs.json not found).") + return 0 + + with open(docs_json_path, encoding="utf-8") as fh: + data = json.load(fh) + + redirects = data.get("redirects", []) + all_errors: list[str] = [] + + # ── 1. Duplicate sources ──────────────────────────────────────────────── + sources: list[str] = [r.get("source", "") for r in redirects] + seen: set[str] = set() + duplicates: set[str] = set() + for src in sources: + if src in seen: + duplicates.add(src) + seen.add(src) + + for dup in sorted(duplicates): + all_errors.append(f"docs.json: duplicate redirect source '{dup}'") + + # ── 2. Redirect loops (self-redirects and cycles) ─────────────────────── + dest_by_source = {r.get("source", ""): r.get("destination", "") for r in redirects} + reported_loops: set[str] = set() + for start in dest_by_source: + hops = {start} + node = dest_by_source[start] + while node in dest_by_source: + if node in hops: + if node not in reported_loops: + reported_loops.add(node) + all_errors.append( + f"docs.json: redirect loop starting at '{start}' revisits '{node}'" + ) + break + hops.add(node) + node = dest_by_source[node] + + # ── 3. Destinations resolve to a known page or another redirect source ── + file_paths = build_file_paths(root) + exact_sources = {s for s in sources if "*" not in s} + wildcard_sources = [s for s in sources if "*" in s] + + def resolves(path: str) -> bool: + """A destination is valid if it is a real page, an exact redirect + source, or matched by a wildcard redirect source. The last case is a + chained redirect, which the CDN follows to a final landing page.""" + if path in file_paths or path in exact_sources: + return True + return any(fnmatch.fnmatch(path, pat) for pat in wildcard_sources) + + for redir in redirects: + dest = redir.get("destination", "") + if not dest: + all_errors.append( + f"docs.json: redirect from '{redir.get('source')}' has an empty destination" + ) + continue + + # Skip wildcards / external URLs – structural validity only + if "*" in dest or dest.startswith("http"): + continue + + # Strip anchors and query strings from destination + dest_path = normalise(dest.split("#")[0]) + + if not resolves(dest_path): + all_errors.append( + f"docs.json: redirect destination '{dest}' does not resolve " + f"to a known page (source: '{redir.get('source')}')" + ) + + if all_errors: + print("Redirects check FAILED:") + for err in all_errors: + print(f" {err}") + return 1 + + print( + f"Redirects check PASSED ({len(redirects)} redirects validated, " + f"{len(file_paths)} pages indexed)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())