diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index e24c0dd7..8a0a8738 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -33,6 +33,9 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '20'
- run: python docs_check.py
deploy:
diff --git a/docs/changelog.md b/docs/changelog.md
index 8a861dad..a125a2d0 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -3,9 +3,7 @@ title: "Changelog"
description: "Release history for Semantica. All notable changes by version."
---
-
- The latest stable release is **v0.5.0**. Changes listed under **Unreleased** are merged to `main` but not yet published to PyPI.
-
+The latest stable release is **v0.5.0**. Changes listed under **Unreleased** are merged to `main` but not yet published to PyPI.
---
diff --git a/docs_check.py b/docs_check.py
index eb24daa1..5989cfe8 100644
--- a/docs_check.py
+++ b/docs_check.py
@@ -5,6 +5,7 @@ import glob
import json
import os
import re
+import subprocess
import sys
from typing import Any, Callable, cast
@@ -195,6 +196,72 @@ def _() -> list[str]:
return [m for m in modules if m not in index]
+# ── 9. JSX component tags are balanced in every page ─────────────────────────
+@check("All Mintlify JSX component tags are balanced")
+def _() -> list[str]:
+ # Paired block-level components that must open and close.
+ COMPONENTS = [
+ "AccordionGroup", "Accordion", "Steps", "Step",
+ "CodeGroup", "Tabs", "Tab", "CardGroup", "Card",
+ "Expandable",
+ ]
+ issues: list[str] = []
+ for fpath in ALL_MD:
+ content = read(fpath)
+ lines = content.splitlines()
+ for comp in COMPONENTS:
+ # Count opening and closing tags (ignore self-closing )
+ opens = len(re.findall(rf"<{comp}[\s>]", content))
+ closes = len(re.findall(rf"{comp}>", content))
+ if opens != closes:
+ issues.append(
+ f"{fpath}: <{comp}> opened {opens}x but closed {closes}x"
+ )
+ # Check code fences are balanced (odd fence count = unclosed block)
+ fence_count = sum(
+ 1 for ln in lines if ln.strip().startswith("```")
+ )
+ if fence_count % 2 != 0:
+ issues.append(f"{fpath}: odd number of ``` fences — unclosed code block")
+ return issues
+
+
+# ── 10. Mintlify export succeeds (requires Node.js / npx) ────────────────────
+@check("Mintlify export builds without errors")
+def _() -> list[str]:
+ npx = "npx.cmd" if sys.platform == "win32" else "npx"
+ try:
+ result = subprocess.run(
+ [npx, "--yes", "mintlify@4.2.632", "export", "--output", "export_ci_check.zip"],
+ cwd=DOCS,
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+ # Clean up zip regardless of outcome
+ zip_path = os.path.join(DOCS, "export_ci_check.zip")
+ if os.path.exists(zip_path):
+ os.remove(zip_path)
+
+ combined = (result.stdout or "") + (result.stderr or "")
+
+ if result.returncode != 0:
+ # On Windows, npm cleanup raises EPERM on temp dirs — not a real
+ # export failure. Treat as a skip rather than a hard failure.
+ if sys.platform == "win32" and "EPERM" in combined and \
+ "could not be generated" not in combined:
+ return [] # Windows temp-cleanup noise; real CI runs on Linux
+
+ # Surface the last 20 lines so the failing page error is visible
+ tail = "\n".join(combined.strip().splitlines()[-20:])
+ return [f"mintlify export failed (exit {result.returncode}):\n{tail}"]
+ return []
+ except FileNotFoundError:
+ return ["npx not found — skipping Mintlify export check (Node.js required)"]
+ except subprocess.TimeoutExpired:
+ return ["mintlify export timed out after 300 s"]
+
+
# ── Summary ───────────────────────────────────────────────────────────────────
_print_summary(failures)
if failures: