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
12 changes: 12 additions & 0 deletions .github/workflows/docs-integrity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ jobs:
- name: Discoverability surfaces are published
run: uv run python scripts/seo_check.py

- name: Mike-style versioned site_url is slash-safe
# mike rewrites site_url to a version path without a trailing slash. Reproduce
# that exact input so concatenation bugs cannot hide behind the normal config.
run: |
cp mkdocs.yml .mkdocs-versioned.yml
trap 'rm -f .mkdocs-versioned.yml; rm -rf site-versioned' EXIT
sed -i 's|^site_url:.*|site_url: https://mskazemi.com/aobench/latest|' .mkdocs-versioned.yml
uv run mkdocs build --strict -f .mkdocs-versioned.yml --site-dir site-versioned
uv run python scripts/seo_check.py \
--site-dir site-versioned \
--base-url https://mskazemi.com/aobench/latest/

- name: Examples still run
run: uv run python -m pytest tests/test_examples.py -q

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

### Fixed — versioned documentation URLs no longer lose the path separator

- Normalize mike's slashless versioned `site_url` before appending paths in the theme
override. The `llms.txt` alternate, Twitter preview image, changelog banner link, and
breadcrumb ancestor URLs now resolve under `/aobench/latest/` instead of malformed
paths such as `/aobench/latestllms.txt`.
- Exclude `docs/overrides/**` from published documentation so raw Jinja source is not
exposed as a public page.
- Extend the SEO gate with a no-trailing-slash versioned build and site-wide assertions
for these URLs, including breadcrumb resolution and the override-source leak.

### Fixed — structured data now matches Google's breadcrumb and Dataset requirements

- Breadcrumb JSON-LD now emits only real navigable ancestors, gives every emitted
Expand Down
14 changes: 8 additions & 6 deletions docs/overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
{% block extrahead %}
{%- set page_title = (page.meta and page.meta.title) or page.title or config.site_name -%}
{%- set page_desc = (page.meta and page.meta.description) or config.site_description -%}
{%- set site_base = config.site_url.rstrip('/') ~ '/' -%}

<meta name="author" content="Mohsen Seyedkazemi Ardebili">

Expand All @@ -26,7 +27,7 @@
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ page_title }}">
<meta name="twitter:description" content="{{ page_desc }}">
<meta name="twitter:image" content="{{ config.site_url }}assets/social-preview.png">
<meta name="twitter:image" content="{{ site_base }}assets/social-preview.png">

{%- if page.meta and page.meta.keywords %}
<meta name="keywords" content="{{ page.meta.keywords | join(', ') }}">
Expand All @@ -44,7 +45,7 @@
<meta name="citation_public_url" content="https://mskazemi.com/aobench/">
<meta name="citation_abstract_html_url" content="https://mskazemi.com/aobench/">

<link rel="alternate" type="text/plain" href="{{ config.site_url }}llms.txt" title="llms.txt — factual project map for AI answer engines">
<link rel="alternate" type="text/plain" href="{{ site_base }}llms.txt" title="llms.txt — factual project map for AI answer engines">

<script type="application/ld+json">
{
Expand Down Expand Up @@ -141,7 +142,7 @@
"author": { "@id": "https://mskazemi.com/#person" },
"license": "https://www.apache.org/licenses/LICENSE-2.0"
}
{%- if page and page.canonical_url != config.site_url %}
{%- if page and page.canonical_url != site_base %}
,{
"@type": "BreadcrumbList",
"@id": "{{ page.canonical_url }}#breadcrumb",
Expand All @@ -150,7 +151,7 @@
"@type": "ListItem",
"position": 1,
"name": "AOBench",
"item": "{{ config.site_url }}"
"item": "{{ site_base }}"
}
{%- set crumb = namespace(position=1) %}
{%- if page.ancestors %}
Expand All @@ -161,7 +162,7 @@
"@type": "ListItem",
"position": {{ crumb.position }},
"name": {{ ancestor.title | string | tojson }},
"item": "{{ config.site_url }}{{ ancestor.url }}"
"item": "{{ site_base }}{{ ancestor.url }}"
}
{%- endif %}
{%- endfor %}
Expand All @@ -182,6 +183,7 @@
{% endblock %}

{% block announce %}
{%- set site_base = config.site_url.rstrip('/') ~ '/' -%}
<strong>AOBench v0.4.1</strong> — 90 tasks · 29 environments · 6 grounded in real Marconi100 data.
&nbsp;<a href="{{ config.site_url }}about/changelog/">Read the changelog →</a>
&nbsp;<a href="{{ site_base }}about/changelog/">Read the changelog →</a>
{% endblock %}
5 changes: 5 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ repo_url: https://github.com/MSKazemi/aobench
repo_name: MSKazemi/aobench
edit_uri: edit/main/docs/

# The theme override lives under docs/ so Material can load it, but it is source code,
# not public documentation. Without this exclusion MkDocs copies raw Jinja into the site.
exclude_docs: |
overrides/**

theme:
name: material
custom_dir: docs/overrides
Expand Down
55 changes: 49 additions & 6 deletions scripts/seo_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

python scripts/seo_check.py # check ./site
python scripts/seo_check.py --site-dir some/other/site
python scripts/seo_check.py --site-dir site-versioned \\
--base-url https://mskazemi.com/aobench/latest/

Exits 1 on any failure.
"""
Expand Down Expand Up @@ -63,8 +65,15 @@


class Checker:
def __init__(self, site: Path) -> None:
def __init__(self, site: Path, base_url: str) -> None:
self.site = site
self.base_url = base_url.rstrip("/") + "/"
parsed_base = urlparse(self.base_url)
if parsed_base.scheme != "https" or not parsed_base.netloc:
raise ValueError(f"base URL must be absolute https: {self.base_url}")
self.base_scheme = parsed_base.scheme
self.base_netloc = parsed_base.netloc
self.base_path = parsed_base.path
self.failures: list[str] = []
self.passes = 0

Expand Down Expand Up @@ -107,6 +116,11 @@ def check_sitemap(self) -> None:
all(u.startswith("https://") for u in locs),
"every sitemap URL is absolute https",
)
self.check(
all(u.startswith(self.base_url) for u in locs),
"every sitemap URL uses the expected site base",
self.base_url,
)

def check_llms_txt(self) -> None:
path = self.site / "llms.txt"
Expand Down Expand Up @@ -141,6 +155,14 @@ def check_llms_txt(self) -> None:
f"only {n_bytes:,} bytes — did the concatenation break?",
)

def check_template_leaks(self) -> None:
leaked = self.site / "overrides" / "main.html"
self.check(
not leaked.exists(),
"raw theme override is not published as documentation",
str(leaked),
)

def check_page(self, rel: str) -> None:
path = self.site / rel
if not path.is_file():
Expand Down Expand Up @@ -181,12 +203,11 @@ def check_page(self, rel: str) -> None:

def _breadcrumb_target_exists(self, url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.netloc != "mskazemi.com":
if parsed.scheme != self.base_scheme or parsed.netloc != self.base_netloc:
return False
prefix = "/aobench/"
if not parsed.path.startswith(prefix):
if not parsed.path.startswith(self.base_path):
return False
suffix = parsed.path[len(prefix) :].lstrip("/")
suffix = parsed.path[len(self.base_path) :].lstrip("/")
if not suffix:
return (self.site / "index.html").is_file()
target = self.site / suffix
Expand Down Expand Up @@ -286,6 +307,21 @@ def check_structured_data(self, rel: str, html: str) -> None:
f"{rel}: Dataset distributions declare URL and encodingFormat",
)

def check_sitewide_base_urls(self) -> None:
pages = sorted(self.site.rglob("index.html"))
self.check(bool(pages), "slash-safe URL audit found built pages")
expected_refs = (
("llms.txt", "llms.txt alternate uses slash-safe base"),
("assets/social-preview.png", "Twitter image uses slash-safe base"),
("about/changelog/", "announcement link uses slash-safe base"),
)
for path in pages:
rel = path.relative_to(self.site).as_posix()
html = path.read_text(encoding="utf-8")
for suffix, label in expected_refs:
expected = self.base_url + suffix
self.check(expected in html, f"{rel}: {label}", expected)

def check_sitewide_structured_data(self) -> None:
pages = sorted(self.site.rglob("index.html"))
self.check(bool(pages), "structured-data audit found built pages")
Expand All @@ -297,6 +333,11 @@ def check_sitewide_structured_data(self) -> None:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--site-dir", type=Path, default=ROOT / "site")
parser.add_argument(
"--base-url",
default="https://mskazemi.com/aobench/",
help="Expected public base URL for this build; normalized to one trailing slash.",
)
args = parser.parse_args()

if not args.site_dir.is_dir():
Expand All @@ -306,12 +347,14 @@ def main() -> int:
)
return 1

checker = Checker(args.site_dir)
checker = Checker(args.site_dir, args.base_url)
checker.check_robots()
checker.check_sitemap()
checker.check_llms_txt()
checker.check_template_leaks()
for page in KEY_PAGES:
checker.check_page(page)
checker.check_sitewide_base_urls()
checker.check_sitewide_structured_data()

if checker.failures:
Expand Down
Loading