Files
datastore/ci/jobs/scripts/docs/quickstarts_check.py
Shaun StruwigandClaude Opus 4.8 c2465721ee Docs: guard localStorage and allow multi-product quickstart tags
Two review comments on PR #109586:

1. QuickStartsGrid persisted its filter/search state assuming localStorage
   is always usable. In storage-restricted browsers or enterprise policies
   localStorage.getItem/setItem throws SecurityError, which would abort the
   useState initializers or the persistence effects and take down the whole
   quickstarts page even though persistence is optional. Route every read
   and write through two guarded helpers (readStored / writeStored) that
   swallow the failure and treat storage as "not persisted", the same way
   readStoredSelection already defends against bad JSON. Applied to all
   nine locale copies of the component.

2. The checker's "exactly one product" rule was stronger than the guides
   and misclassified content: tutorial.mdx documents both ClickHouse Cloud
   and self-managed steps but was forced to products: ['Cloud'], so a
   self-managed user filtering on "ClickHouse (Open-Source)" would not find
   it. Allow one or more products (each validated against ALLOWED_PRODUCTS,
   mirroring useCases), tag tutorial.mdx as ['Cloud', 'OSS'], regenerate,
   and update the README / _TEMPLATE / docstring wording. The grid already
   treats products as list membership, so multi-product cards filter
   correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 17:04:30 +02:00

218 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""
Validate the quickstart pages and the generated explorer data.
Run from the docs root (the directory containing docs.json):
python3 ../ci/jobs/scripts/docs/quickstarts_check.py .
Two checks (see docs/get-started/quickstarts/README.md for the authoring
guide):
1. Frontmatter tags and badge markers. Every English quickstart page must
declare `products` (one or more of ALLOWED_PRODUCTS) and `useCases` (one
or more of ALLOWED_USE_CASES). The QuickStartsGrid explorer filters match
on slugs derived from these values, so an unknown value produces a card
that no filter can find. Every page must also contain the
`AUTOGENERATED_START`/`AUTOGENERATED_END` marker pair: the generator
rewrites the badge block between them, so a page without markers keeps
stale badges that the freshness check below cannot detect.
2. Generated data freshness. The explorer card data
(snippets/[<locale>/]components/QuickStartsGrid/quickstarts-data.jsx) and
the badge blocks on the English pages are produced by
_site/scripts/update_quickstarts.py from the page frontmatter. This check
runs the generator and fails if it changes anything — meaning the PR
author edited a quickstart without re-running the generator and committing
its output. The working tree is restored afterwards; the failure message
names the exact command to run.
"""
import re
import subprocess
import sys
from pathlib import Path
ALLOWED_USE_CASES = [
"All",
"Real-time analytics",
"Data warehousing",
"Observability",
"AI/ML",
]
ALLOWED_PRODUCTS = [
"Cloud",
"OSS",
"ClickPipes",
"Language clients",
"ClickStack",
"chDB",
]
SKIP_FILES = {"home.mdx", "README.md"}
# The badge block the generator rewrites, same pattern as
# update_quickstart_badges in _site/scripts/update_quickstarts.py.
AUTOGENERATED_BLOCK = re.compile(
r"\{/\* AUTOGENERATED_START \*/\}.*?\{/\* AUTOGENERATED_END \*/\}", re.DOTALL
)
def parse_tag_list(frontmatter: str, key: str):
"""Return the list value of `key: [a, b]` from frontmatter, or None."""
m = re.search(rf"^{key}:\s*\[(.*?)\]\s*$", frontmatter, re.M)
if not m:
return None
return [v.strip().strip("'\"") for v in m.group(1).split(",") if v.strip()]
def quickstart_pages(docs_root: Path):
"""Yield every English page the generator treats as a quickstart.
This must stay in lockstep with `find_quickstart_files` in
_site/scripts/update_quickstarts.py: the generator recurses
(`**/*.mdx`, `**/*.md`) and skips home.mdx, README.md, and anything under an
underscore-prefixed path part (templates like _TEMPLATE.mdx and helper
directories such as _prerequisites/). A top-level-only scan here would let a
nested quickstart bypass the tag validator entirely once its regenerated
output is committed, so the checker validates exactly the file set the
generator rewrites.
"""
qs_dir = docs_root / "get-started" / "quickstarts"
pages = set()
for pattern in ("**/*.mdx", "**/*.md"):
for p in qs_dir.glob(pattern):
if p.name in SKIP_FILES:
continue
if any(part.startswith("_") for part in p.relative_to(qs_dir).parts):
continue
pages.add(p)
yield from sorted(pages)
def check_frontmatter(docs_root: Path) -> list:
errors = []
for page in quickstart_pages(docs_root):
# Use the path relative to the docs root so nested pages that share a
# filename with a top-level page are unambiguous in the report.
name = page.relative_to(docs_root)
src = page.read_text(encoding="utf-8")
m = re.match(r"^---\n(.*?)\n---\n", src, re.DOTALL)
if not m:
errors.append(f"{name}: no frontmatter block")
continue
fm = m.group(1)
# A page may support more than one product (e.g. a guide with separate
# steps for Cloud and self-managed), and the explorer treats `products`
# as filter membership, so every product a guide documents must be
# tagged or a user filtering on it will not find the guide.
products = parse_tag_list(fm, "products")
if not products:
errors.append(f"{name}: missing or empty `products` frontmatter")
else:
for pr in products:
if pr not in ALLOWED_PRODUCTS:
errors.append(
f"{name}: unknown product {pr!r} "
f"(allowed: {', '.join(ALLOWED_PRODUCTS)})"
)
use_cases = parse_tag_list(fm, "useCases")
if not use_cases:
errors.append(f"{name}: missing or empty `useCases` frontmatter")
else:
for uc in use_cases:
if uc not in ALLOWED_USE_CASES:
errors.append(
f"{name}: unknown use case {uc!r} "
f"(allowed: {', '.join(ALLOWED_USE_CASES)})"
)
if not AUTOGENERATED_BLOCK.search(src):
errors.append(
f"{name}: missing the "
"`{/* AUTOGENERATED_START */}`/`{/* AUTOGENERATED_END */}` "
"marker pair; the generator rewrites the badge block between "
"them, so without markers the page keeps stale badges (copy "
"the block from _TEMPLATE.mdx)"
)
return errors
def generated_paths(docs_root: Path) -> list:
"""Every file the generator may rewrite."""
paths = list(quickstart_pages(docs_root)) # badge blocks on English pages
paths += sorted(docs_root.glob("snippets/components/QuickStartsGrid/quickstarts-data.jsx"))
paths += sorted(docs_root.glob("snippets/*/components/QuickStartsGrid/quickstarts-data.jsx"))
return paths
GENERATOR_COMMAND = "python3 _site/scripts/update_quickstarts.py"
def check_freshness(docs_root: Path) -> list:
generator = docs_root / "_site" / "scripts" / "update_quickstarts.py"
# Snapshot the content of everything the generator may rewrite, so the
# working tree can be restored to exactly its pre-check state.
before = {p: p.read_bytes() for p in generated_paths(docs_root)}
proc = subprocess.run(
[sys.executable, str(generator)],
capture_output=True,
text=True,
)
after = generated_paths(docs_root)
stale = [p for p in after if before.get(p) != p.read_bytes()]
# Restore the tree: rewrite changed files, drop files the generator created.
# This must happen even when the generator failed — it may have rewritten
# some files before hitting the error.
for p in stale:
if p in before:
p.write_bytes(before[p])
else:
p.unlink()
if proc.returncode != 0:
return [
f"generator failed with exit code {proc.returncode}:\n"
f"{proc.stdout[-2000:]}\n{proc.stderr[-2000:]}"
]
if stale:
listing = "\n".join(f" {p.relative_to(docs_root)}" for p in stale)
return [
"the quickstarts index is not up to date, please run:\n"
f" {GENERATOR_COMMAND}\n"
"from the docs/ directory and commit the changes, or ask a "
"maintainer to help update it for you "
"(see get-started/quickstarts/README.md). Out-of-date files:\n"
+ listing
]
return []
def main() -> int:
docs_root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
if not (docs_root / "docs.json").is_file():
print(f"Error: no docs.json in {docs_root}; pass the docs root.")
return 2
errors = check_frontmatter(docs_root)
# Only bother running the generator when the tags are valid — invalid tags
# would just produce garbage slugs in the regenerated data.
if not errors:
errors += check_freshness(docs_root)
if errors:
print(f"FAIL: {len(errors)} quickstart problem(s):")
for e in errors:
print(f"- {e}")
return 1
print("OK: quickstart frontmatter valid, generated data up to date")
return 0
if __name__ == "__main__":
sys.exit(main())