mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
218 lines
8.0 KiB
Python
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()) |