Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/docs-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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.
# Violations that already existed on main are listed in
# scripts/docs-ci-baseline.json and don't fail; only new ones do.
# 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.DS_Store
.vscode
__pycache__/
6 changes: 3 additions & 3 deletions docs/access-security/audit-log-streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Each line contains one complete audit log entry. See the [Audit Log Reference](/

Files use this path:

```
```text
[<path_prefix>/]YYYY/MM/DD/HH/MM/<uuid>.ndjson.gz
```

Expand All @@ -73,7 +73,7 @@ Mixpanel delivers each batch of audit log entries as a gzipped NDJSON object int

The external ID is unique to your Mixpanel organization and has this format:

```
```text
mixpanel-audit-log-streaming-<organization-id>
```

Expand Down Expand Up @@ -177,7 +177,7 @@ Mixpanel delivers each batch of audit log entries as a gzipped NDJSON object int

Grant this service account:

```
```text
audit-log-streaming@mixpanel-prod-1.iam.gserviceaccount.com
```

Expand Down
1 change: 1 addition & 0 deletions docs/agent-intelligence.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "Agent Intelligence"
description: "Send your AI agent traces to Mixpanel over OpenTelemetry and analyze usage, cost, errors, and conversations alongside your product data"
---

Agent Intelligence brings your AI agent's traces into Mixpanel, next to the product data you already track.
Expand Down
1 change: 1 addition & 0 deletions docs/cohort-sync/integrations/wingify.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: Wingify
description: "Export Mixpanel cohorts to Wingify (formerly VWO) as custom segments for your experiments"
---

## Overview
Expand Down
2 changes: 1 addition & 1 deletion docs/tracking-methods/first-party-domains.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Each organization can have up to **3** first-party domains.

## How it works

```
```text
Your app ──► track.yourcompany.com ──► Mixpanel ingestion API
(CNAME to Mixpanel)
```
Expand Down
2 changes: 1 addition & 1 deletion openapi/gdpr.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ components:
status: SUCCESS
requesting_user: user@mail.com
compliance_type: GDPR
project_id: your project ID
project_id: 12345
date_requested: YYYY-MM-DDTHH:MM:SS
distinct_ids:
- distinct_id_1
Expand Down
83 changes: 83 additions & 0 deletions scripts/check_code_samples.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.


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())
94 changes: 94 additions & 0 deletions scripts/check_frontmatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
CI gate: every MDX page needs a non-empty title and description in its YAML
front-matter, and no two pages may share a title.

Known violations on main are listed in docs-ci-baseline.json (see
ci_baseline.py); only new violations fail the check.

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

from ci_baseline import report

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. One entry per page (not per group) keeps
# baseline entries stable when a group shrinks during cleanup.
for title, pages in sorted(titles.items()):
if len(pages) > 1:
for page in sorted(pages):
all_errors.append(f'{page}: title "{title}" is also used by another page')

return report("frontmatter", "Frontmatter check", all_errors, f"{checked} files checked")


if __name__ == "__main__":
sys.exit(main())
Loading
Loading