chore: import upstream snapshot with attribution
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
This commit is contained in:
Executable
+461
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import html.parser
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SITE = ROOT / "site"
|
||||
|
||||
ROUTES = [
|
||||
"/",
|
||||
"/quickstart/",
|
||||
"/usage/",
|
||||
"/activity/",
|
||||
"/recent-edits/",
|
||||
"/session-intelligence/",
|
||||
"/mcp/",
|
||||
"/token-usage/",
|
||||
"/chat-import/",
|
||||
"/insights/",
|
||||
"/commands/",
|
||||
"/stats/",
|
||||
"/session-api/",
|
||||
"/configuration/",
|
||||
"/remote-access/",
|
||||
"/pg-sync/",
|
||||
"/duckdb/",
|
||||
"/changelog/",
|
||||
]
|
||||
|
||||
REQUIRED_FRAGMENTS = [
|
||||
"/configuration/#session-discovery",
|
||||
"/token-usage/#how-it-compares-to-ccusage",
|
||||
"/session-api/#agentsview-session-usage",
|
||||
]
|
||||
|
||||
REQUIRED_META = {
|
||||
("property", "og:image"): "https://agentsview.io/assets/static/og-image.png",
|
||||
("property", "og:image:width"): "1200",
|
||||
("property", "og:image:height"): "630",
|
||||
("property", "og:type"): "website",
|
||||
("property", "og:site_name"): "AgentsView",
|
||||
("name", "twitter:card"): "summary_large_image",
|
||||
("name", "twitter:image"): "https://agentsview.io/assets/static/og-image.png",
|
||||
}
|
||||
|
||||
REQUIRED_SITEMAP_URLS = [
|
||||
"https://agentsview.io/",
|
||||
]
|
||||
|
||||
COMPACT_SVG_MAX_HEIGHTS: dict[str, float] = {}
|
||||
|
||||
FORBIDDEN_PATTERNS = [
|
||||
"virtual:starlight",
|
||||
"@astrojs/starlight",
|
||||
"discord-top-link",
|
||||
"<aside class=\"md-banner\"",
|
||||
"<Tabs",
|
||||
"<TabItem",
|
||||
"<Card",
|
||||
"<CardGrid",
|
||||
"<Screenshot",
|
||||
"<Aside",
|
||||
"set:html",
|
||||
]
|
||||
|
||||
MAX_HTML_UNESCAPE_PASSES = 50
|
||||
|
||||
FETCHED_LINK_RELS = {
|
||||
"apple-touch-icon",
|
||||
"apple-touch-startup-image",
|
||||
"icon",
|
||||
"manifest",
|
||||
"mask-icon",
|
||||
"modulepreload",
|
||||
"prefetch",
|
||||
"preload",
|
||||
"prerender",
|
||||
"stylesheet",
|
||||
}
|
||||
|
||||
CSS_URL_RE = re.compile(
|
||||
r"url\(\s*(?:\"([^\"]*)\"|'([^']*)'|([^)]*?))\s*\)", re.IGNORECASE
|
||||
)
|
||||
CSS_IMPORT_RE = re.compile(
|
||||
r"@import\s+(?:url\(\s*(?:\"([^\"]*)\"|'([^']*)'|([^)]*?))\s*\)|\"([^\"]*)\"|'([^']*)')",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def srcset_urls(value: str) -> list[str]:
|
||||
urls: list[str] = []
|
||||
index = 0
|
||||
while index < len(value):
|
||||
while index < len(value) and value[index] in " \t\r\n,":
|
||||
index += 1
|
||||
if index >= len(value):
|
||||
break
|
||||
|
||||
start = index
|
||||
if value.startswith("data:", index):
|
||||
while index < len(value) and not value[index].isspace():
|
||||
index += 1
|
||||
else:
|
||||
while index < len(value) and not value[index].isspace() and value[index] != ",":
|
||||
index += 1
|
||||
|
||||
url = value[start:index]
|
||||
if url:
|
||||
urls.append(url)
|
||||
|
||||
while index < len(value) and value[index] != ",":
|
||||
index += 1
|
||||
if index < len(value):
|
||||
index += 1
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def css_url_refs(text: str) -> list[str]:
|
||||
refs: list[str] = []
|
||||
for match in CSS_URL_RE.finditer(text):
|
||||
ref = next((group for group in match.groups() if group is not None), "")
|
||||
ref = ref.strip()
|
||||
if ref:
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
def css_import_refs(text: str) -> list[str]:
|
||||
refs: list[str] = []
|
||||
for match in CSS_IMPORT_RE.finditer(text):
|
||||
ref = next((group for group in match.groups() if group is not None), "")
|
||||
ref = ref.strip()
|
||||
if ref:
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
def rel_tokens(value: str) -> set[str]:
|
||||
return {token.lower() for token in value.split()}
|
||||
|
||||
|
||||
def is_fetched_link_resource(attrs: dict[str, str]) -> bool:
|
||||
return bool(rel_tokens(attrs.get("rel", "")) & FETCHED_LINK_RELS)
|
||||
|
||||
|
||||
class LinkParser(html.parser.HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.ids: set[str] = set()
|
||||
self.links: list[str] = []
|
||||
self.link_attrs: list[dict[str, str]] = []
|
||||
self.assets: list[str] = []
|
||||
self.style_attrs: list[str] = []
|
||||
self.style_blocks: list[str] = []
|
||||
self.meta: list[dict[str, str]] = []
|
||||
self.svg_use_hrefs: list[str] = []
|
||||
self._in_style = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attr = {key: value or "" for key, value in attrs}
|
||||
if "id" in attr:
|
||||
self.ids.add(attr["id"])
|
||||
if tag == "a" and "href" in attr:
|
||||
self.links.append(attr["href"])
|
||||
self.link_attrs.append(attr)
|
||||
if tag in {"img", "script", "source"} and "src" in attr:
|
||||
self.assets.append(attr["src"])
|
||||
if tag in {"img", "source"} and "srcset" in attr:
|
||||
self.assets.extend(srcset_urls(attr["srcset"]))
|
||||
if tag == "video" and "poster" in attr:
|
||||
self.assets.append(attr["poster"])
|
||||
if tag == "link" and "href" in attr and is_fetched_link_resource(attr):
|
||||
self.assets.append(attr["href"])
|
||||
if tag == "meta":
|
||||
self.meta.append(attr)
|
||||
if tag == "use" and "href" in attr:
|
||||
self.svg_use_hrefs.append(attr["href"])
|
||||
if "style" in attr:
|
||||
self.style_attrs.append(attr["style"])
|
||||
if tag == "style":
|
||||
self._in_style = True
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._in_style:
|
||||
self.style_blocks.append(data)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "style":
|
||||
self._in_style = False
|
||||
|
||||
|
||||
def route_to_file(route: str) -> pathlib.Path:
|
||||
if route == "/":
|
||||
return SITE / "index.html"
|
||||
return SITE / route.strip("/") / "index.html"
|
||||
|
||||
|
||||
def route_to_markdown_file(route: str) -> pathlib.Path:
|
||||
if route == "/":
|
||||
return SITE / "index.md"
|
||||
return SITE / f"{route.strip('/')}.md"
|
||||
|
||||
|
||||
def is_local_file_path(path: str) -> bool:
|
||||
return pathlib.PurePosixPath(path).suffix != ""
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"FAIL: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def svg_length(value: str, path: pathlib.Path, attr: str) -> float:
|
||||
match = re.fullmatch(r"\s*([0-9]+(?:\.[0-9]+)?)(?:px)?\s*", value)
|
||||
if match is None:
|
||||
fail(f"invalid SVG {attr} value {value!r} in {path}")
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def check_compact_svg_assets() -> None:
|
||||
for name, max_height in COMPACT_SVG_MAX_HEIGHTS.items():
|
||||
path = SITE / name
|
||||
if not path.is_file():
|
||||
fail(f"missing compact SVG asset {name}")
|
||||
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
fail(f"invalid compact SVG asset {name}: {exc}")
|
||||
|
||||
height_attr = root.get("height")
|
||||
if height_attr is None:
|
||||
fail(f"compact SVG asset {name} is missing height")
|
||||
|
||||
height = svg_length(height_attr, path, "height")
|
||||
if height > max_height:
|
||||
fail(f"compact SVG asset {name} is {height:g}px tall, expected at most {max_height:g}px")
|
||||
|
||||
|
||||
def require_site_child(target: pathlib.Path, reference: str, current: pathlib.Path) -> pathlib.Path:
|
||||
resolved = target.resolve()
|
||||
site = SITE.resolve()
|
||||
if not resolved.is_relative_to(site):
|
||||
fail(f"local reference escapes site output: {reference} in {current}")
|
||||
return resolved
|
||||
|
||||
|
||||
def target_file(current: pathlib.Path, href: str) -> pathlib.Path | None:
|
||||
parsed = urllib.parse.urlparse(href)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None
|
||||
decoded_path = urllib.parse.unquote(parsed.path)
|
||||
if decoded_path.startswith("/"):
|
||||
if is_local_file_path(decoded_path):
|
||||
return require_site_child(SITE / decoded_path.lstrip("/"), href, current)
|
||||
route = decoded_path if decoded_path.endswith("/") else decoded_path + "/"
|
||||
return require_site_child(route_to_file(route), href, current)
|
||||
|
||||
base = current.parent
|
||||
path = decoded_path or current.name
|
||||
resolved = (base / path).resolve()
|
||||
require_site_child(resolved, href, current)
|
||||
if resolved.is_dir():
|
||||
return require_site_child(resolved / "index.html", href, current)
|
||||
if resolved.suffix:
|
||||
return resolved
|
||||
return require_site_child(resolved / "index.html", href, current)
|
||||
|
||||
|
||||
def parse_html(path: pathlib.Path) -> LinkParser:
|
||||
parser = LinkParser()
|
||||
parser.feed(path.read_text(encoding="utf-8"))
|
||||
return parser
|
||||
|
||||
|
||||
def html_unescape_variants(text: str) -> list[str]:
|
||||
variants = [text]
|
||||
current = text
|
||||
for _ in range(MAX_HTML_UNESCAPE_PASSES):
|
||||
unescaped = html.unescape(current)
|
||||
if unescaped == current:
|
||||
break
|
||||
variants.append(unescaped)
|
||||
current = unescaped
|
||||
return variants
|
||||
|
||||
|
||||
def check_global_metadata(current: pathlib.Path, parser: LinkParser) -> None:
|
||||
for (kind, name), value in REQUIRED_META.items():
|
||||
found = any(
|
||||
meta.get(kind) == name and meta.get("content") == value for meta in parser.meta
|
||||
)
|
||||
if not found:
|
||||
fail(f"missing metadata {kind}={name} with value {value} in {current}")
|
||||
|
||||
|
||||
def check_no_svg_use_href(current: pathlib.Path, parser: LinkParser) -> None:
|
||||
for href in parser.svg_use_hrefs:
|
||||
fail(
|
||||
f"svg <use href={href!r}> found in {current}; SVGUseElement.href is "
|
||||
"read-only, so Zensical navigation.instant throws while loading the "
|
||||
"page and site navigation freezes. Use xlink:href instead."
|
||||
)
|
||||
|
||||
|
||||
def check_discord_header_link(current: pathlib.Path, parser: LinkParser) -> None:
|
||||
found = any(
|
||||
link.get("href") == "https://discord.gg/fDnmxB8Wkq"
|
||||
and "agentsview-discord-link" in link.get("class", "").split()
|
||||
and link.get("aria-label") == "Join Discord"
|
||||
for link in parser.link_attrs
|
||||
)
|
||||
if not found:
|
||||
fail(f"missing Discord header link in {current}")
|
||||
|
||||
|
||||
def check_local_asset(current: pathlib.Path, asset: str) -> pathlib.Path | None:
|
||||
parsed = urllib.parse.urlparse(asset)
|
||||
if parsed.scheme or parsed.netloc or asset.startswith("data:"):
|
||||
return None
|
||||
decoded_path = urllib.parse.unquote(parsed.path)
|
||||
if not decoded_path:
|
||||
return None
|
||||
if decoded_path.startswith("/"):
|
||||
target = SITE / decoded_path.lstrip("/")
|
||||
else:
|
||||
target = current.parent / decoded_path
|
||||
target = require_site_child(target, asset, current)
|
||||
if not target.is_file():
|
||||
fail(f"missing asset {asset} referenced by {current}")
|
||||
return target
|
||||
|
||||
|
||||
def check_css_text(current: pathlib.Path, text: str, visited: set[pathlib.Path]) -> None:
|
||||
for asset in css_import_refs(text):
|
||||
target = check_local_asset(current, asset)
|
||||
if target is not None and target.suffix.lower() == ".css":
|
||||
check_css_assets(target, visited)
|
||||
|
||||
for asset in css_url_refs(text):
|
||||
check_local_asset(current, asset)
|
||||
|
||||
|
||||
def check_css_assets(css_file: pathlib.Path, visited: set[pathlib.Path] | None = None) -> None:
|
||||
if visited is None:
|
||||
visited = set()
|
||||
css_file = css_file.resolve()
|
||||
if css_file in visited:
|
||||
return
|
||||
visited.add(css_file)
|
||||
|
||||
text = css_file.read_text(encoding="utf-8", errors="ignore")
|
||||
check_css_text(css_file, text, visited)
|
||||
|
||||
|
||||
def fragment_id(fragment: str) -> str:
|
||||
return urllib.parse.unquote(fragment)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not SITE.exists():
|
||||
fail("site directory does not exist. Run the Zensical build first.")
|
||||
|
||||
for route in ROUTES:
|
||||
path = route_to_file(route)
|
||||
if not path.exists():
|
||||
fail(f"missing route {route}: {path}")
|
||||
markdown_path = route_to_markdown_file(route)
|
||||
if not markdown_path.exists():
|
||||
fail(f"missing route markdown {route}: {markdown_path}")
|
||||
|
||||
if not (SITE / "404.html").exists():
|
||||
fail("missing 404.html")
|
||||
if not (SITE / "sitemap.xml").exists():
|
||||
fail("missing sitemap.xml")
|
||||
sitemap_text = (SITE / "sitemap.xml").read_text(encoding="utf-8", errors="ignore")
|
||||
for url in REQUIRED_SITEMAP_URLS:
|
||||
if f"<loc>{url}</loc>" not in sitemap_text:
|
||||
fail(f"missing sitemap URL {url}")
|
||||
|
||||
html_files = list(SITE.rglob("*.html"))
|
||||
all_text = "\n".join(
|
||||
path.read_text(encoding="utf-8", errors="ignore") for path in html_files
|
||||
)
|
||||
text_variants = html_unescape_variants(all_text)
|
||||
for pattern in FORBIDDEN_PATTERNS:
|
||||
if any(pattern in text for text in text_variants):
|
||||
fail(f"forbidden generated marker found: {pattern}")
|
||||
|
||||
parsed_by_file: dict[pathlib.Path, LinkParser] = {}
|
||||
for path in html_files:
|
||||
parsed_by_file[path.resolve()] = parse_html(path)
|
||||
|
||||
for current, parser in parsed_by_file.items():
|
||||
check_global_metadata(current, parser)
|
||||
check_discord_header_link(current, parser)
|
||||
check_no_svg_use_href(current, parser)
|
||||
|
||||
for spec in REQUIRED_FRAGMENTS:
|
||||
parsed = urllib.parse.urlparse(spec)
|
||||
path = route_to_file(parsed.path)
|
||||
parser = parsed_by_file.get(path.resolve())
|
||||
if parser is None:
|
||||
fail(f"required fragment page missing: {spec}")
|
||||
if fragment_id(parsed.fragment) not in parser.ids:
|
||||
fail(f"required fragment missing: {spec}")
|
||||
|
||||
css_files: set[pathlib.Path] = set()
|
||||
visited_css: set[pathlib.Path] = set()
|
||||
for current, parser in parsed_by_file.items():
|
||||
for href in parser.links:
|
||||
parsed = urllib.parse.urlparse(href)
|
||||
if href.startswith("#"):
|
||||
fragment = fragment_id(parsed.fragment)
|
||||
if fragment and fragment not in parser.ids:
|
||||
fail(f"missing local fragment {href} in {current}")
|
||||
continue
|
||||
target = target_file(current, href)
|
||||
if target is None:
|
||||
continue
|
||||
if target.suffix == ".html":
|
||||
if parsed.fragment:
|
||||
target_parser = parsed_by_file.get(target.resolve())
|
||||
if target_parser is None:
|
||||
fail(f"missing linked page for fragment {href} in {current}")
|
||||
if fragment_id(parsed.fragment) not in target_parser.ids:
|
||||
fail(f"missing fragment {href} in {target}")
|
||||
elif not target.exists():
|
||||
fail(f"missing internal page {href} in {current}")
|
||||
elif not target.exists():
|
||||
fail(f"missing linked file {href} in {current}")
|
||||
|
||||
for asset in parser.assets:
|
||||
target = check_local_asset(current, asset)
|
||||
if target is not None and target.suffix.lower() == ".css":
|
||||
css_files.add(target)
|
||||
|
||||
for css_text in parser.style_attrs:
|
||||
for asset in css_url_refs(css_text):
|
||||
check_local_asset(current, asset)
|
||||
|
||||
for css_text in parser.style_blocks:
|
||||
check_css_text(current, css_text, visited_css)
|
||||
|
||||
for css_file in css_files:
|
||||
check_css_assets(css_file, visited_css)
|
||||
|
||||
check_compact_svg_assets()
|
||||
|
||||
print("built site checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
FRONTMATTER_LIMIT = 80
|
||||
ADMONITION_DIRECTIVE_RE = re.compile(
|
||||
r'!!!\s+[A-Za-z][\w-]*(?:\s+"[^"]+")?\s*'
|
||||
)
|
||||
|
||||
|
||||
def public_markdown_files() -> list[pathlib.Path]:
|
||||
return [
|
||||
path
|
||||
for path in sorted(ROOT.glob("*.md"))
|
||||
if path.name != "README.md"
|
||||
]
|
||||
|
||||
|
||||
def check_frontmatter(path: pathlib.Path, lines: list[str], errors: list[str]) -> None:
|
||||
if not lines or lines[0] != "---":
|
||||
errors.append(f"{path.relative_to(ROOT)}: missing YAML frontmatter")
|
||||
return
|
||||
|
||||
closing = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines[1:FRONTMATTER_LIMIT], start=1)
|
||||
if line == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if closing is None:
|
||||
errors.append(
|
||||
f"{path.relative_to(ROOT)}: missing closing YAML frontmatter delimiter"
|
||||
)
|
||||
return
|
||||
|
||||
frontmatter = "\n".join(lines[1:closing])
|
||||
if not re.search(r"^title:\s+\S", frontmatter, flags=re.MULTILINE):
|
||||
errors.append(f"{path.relative_to(ROOT)}: missing title in YAML frontmatter")
|
||||
if not re.search(r"^description:\s+\S", frontmatter, flags=re.MULTILINE):
|
||||
errors.append(
|
||||
f"{path.relative_to(ROOT)}: missing description in YAML frontmatter"
|
||||
)
|
||||
|
||||
|
||||
def check_admonitions(path: pathlib.Path, lines: list[str], errors: list[str]) -> None:
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("!!! "):
|
||||
continue
|
||||
if ADMONITION_DIRECTIVE_RE.fullmatch(stripped) is None:
|
||||
errors.append(
|
||||
f"{path.relative_to(ROOT)}:{index + 1}: "
|
||||
"malformed or collapsed admonition"
|
||||
)
|
||||
continue
|
||||
|
||||
following = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in lines[index + 1 :]
|
||||
if candidate.strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if following is None or not following.startswith(" "):
|
||||
errors.append(
|
||||
f"{path.relative_to(ROOT)}:{index + 1}: "
|
||||
"admonition body must be indented"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
errors: list[str] = []
|
||||
|
||||
for path in public_markdown_files():
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
check_frontmatter(path, lines, errors)
|
||||
check_admonitions(path, lines, errors)
|
||||
|
||||
if errors:
|
||||
print("docs markdown source check failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f" {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("docs markdown source checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
VERCEL = ROOT / "vercel.json"
|
||||
|
||||
PERMANENT: dict[str, str] = {}
|
||||
|
||||
TEMPORARY = {
|
||||
"/install.sh": "https://raw.githubusercontent.com/kenn-io/agentsview/main/scripts/install.sh",
|
||||
"/install.ps1": "https://raw.githubusercontent.com/kenn-io/agentsview/main/scripts/install.ps1",
|
||||
}
|
||||
|
||||
MAX_REDIRECTS = 2048
|
||||
MAX_CONDITIONS = 16
|
||||
MAX_SCHEMA_STRING_LENGTH = 4096
|
||||
MAX_ENV_ITEMS = 64
|
||||
MAX_ENV_VALUE_LENGTH = 256
|
||||
|
||||
ALLOWED_REDIRECT_KEYS = {
|
||||
"source",
|
||||
"destination",
|
||||
"permanent",
|
||||
"statusCode",
|
||||
"has",
|
||||
"missing",
|
||||
"env",
|
||||
}
|
||||
|
||||
ALLOWED_CONDITION_KEYS = {"type", "key", "value"}
|
||||
CONDITION_TYPES_REQUIRING_KEY = {"header", "cookie", "query"}
|
||||
CONDITION_TYPES = {"host", *CONDITION_TYPES_REQUIRING_KEY}
|
||||
CONDITION_OPERATION_KEYS = {
|
||||
"eq",
|
||||
"neq",
|
||||
"inc",
|
||||
"ninc",
|
||||
"pre",
|
||||
"suf",
|
||||
"re",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
}
|
||||
STRING_CONDITION_OPERATIONS = {"neq", "pre", "suf", "re"}
|
||||
STRING_LIST_CONDITION_OPERATIONS = {"inc", "ninc"}
|
||||
NUMBER_CONDITION_OPERATIONS = {"gt", "gte", "lt", "lte"}
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"FAIL: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def reject_json_constant(constant: str) -> None:
|
||||
raise ValueError(f"non-finite numeric constant {constant}")
|
||||
|
||||
|
||||
def validate_schema_string(path: str, value: str) -> None:
|
||||
if len(value) > MAX_SCHEMA_STRING_LENGTH:
|
||||
fail(f"{path} must be at most {MAX_SCHEMA_STRING_LENGTH} characters")
|
||||
|
||||
|
||||
def validate_finite_json_numbers(path: str, value: object) -> None:
|
||||
if isinstance(value, bool):
|
||||
return
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
fail(f"{path} contains non-finite number")
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
validate_finite_json_numbers(f"{path}.{key}", item)
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
validate_finite_json_numbers(f"{path}[{index}]", item)
|
||||
|
||||
|
||||
def is_number(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, int):
|
||||
return True
|
||||
return isinstance(value, float) and math.isfinite(value)
|
||||
|
||||
|
||||
def validate_condition_operation_value(path: str, operation: str, value: object) -> None:
|
||||
if operation == "eq":
|
||||
if not isinstance(value, str) and not is_number(value):
|
||||
fail(f"{path} eq must be a string or finite number")
|
||||
if isinstance(value, str):
|
||||
validate_schema_string(f"{path} eq", value)
|
||||
elif operation in STRING_CONDITION_OPERATIONS:
|
||||
if not isinstance(value, str):
|
||||
fail(f"{path} {operation} must be a string")
|
||||
validate_schema_string(f"{path} {operation}", value)
|
||||
elif operation in STRING_LIST_CONDITION_OPERATIONS:
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
fail(f"{path} {operation} must be a list of strings")
|
||||
for item_index, item in enumerate(value):
|
||||
validate_schema_string(f"{path} {operation}[{item_index}]", item)
|
||||
elif operation in NUMBER_CONDITION_OPERATIONS:
|
||||
if not is_number(value):
|
||||
fail(f"{path} {operation} must be a finite number")
|
||||
|
||||
|
||||
def validate_condition_value(path: str, value: object) -> None:
|
||||
if isinstance(value, str):
|
||||
validate_schema_string(path, value)
|
||||
return
|
||||
if not isinstance(value, dict):
|
||||
fail(f"{path} must be a string or condition operation object")
|
||||
if not value:
|
||||
fail(f"{path} condition operation object must not be empty")
|
||||
unknown_keys = set(value) - CONDITION_OPERATION_KEYS
|
||||
if unknown_keys:
|
||||
keys = ", ".join(sorted(unknown_keys))
|
||||
fail(f"{path} has unknown operation keys: {keys}")
|
||||
for operation, operation_value in value.items():
|
||||
validate_condition_operation_value(path, operation, operation_value)
|
||||
|
||||
|
||||
def validate_condition_list(index: int, item: dict[str, object], key: str) -> None:
|
||||
if key not in item:
|
||||
return
|
||||
|
||||
conditions = item[key]
|
||||
if not isinstance(conditions, list):
|
||||
fail(f"redirect entry {index} {key} must be a list")
|
||||
if len(conditions) > MAX_CONDITIONS:
|
||||
fail(f"redirect entry {index} {key} must contain at most {MAX_CONDITIONS} items")
|
||||
|
||||
for condition_index, condition in enumerate(conditions):
|
||||
if not isinstance(condition, dict):
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] must be an object")
|
||||
unknown_keys = set(condition) - ALLOWED_CONDITION_KEYS
|
||||
if unknown_keys:
|
||||
keys = ", ".join(sorted(unknown_keys))
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] has unknown keys: {keys}")
|
||||
condition_type = condition.get("type")
|
||||
if not isinstance(condition_type, str):
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] type must be a string")
|
||||
if condition_type not in CONDITION_TYPES:
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] has invalid type: {condition_type}")
|
||||
condition_key = condition.get("key")
|
||||
if condition_type in CONDITION_TYPES_REQUIRING_KEY:
|
||||
if not isinstance(condition_key, str) or not condition_key:
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] missing key")
|
||||
validate_schema_string(
|
||||
f"redirect entry {index} {key}[{condition_index}] key",
|
||||
condition_key,
|
||||
)
|
||||
elif "key" in condition:
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] host condition must not set key")
|
||||
elif "value" not in condition:
|
||||
fail(f"redirect entry {index} {key}[{condition_index}] host condition missing value")
|
||||
if "value" in condition:
|
||||
validate_condition_value(
|
||||
f"redirect entry {index} {key}[{condition_index}] value",
|
||||
condition["value"],
|
||||
)
|
||||
|
||||
|
||||
def validate_redirect_mode(index: int, item: dict[str, object]) -> None:
|
||||
has_permanent = "permanent" in item
|
||||
has_status_code = "statusCode" in item
|
||||
if not has_permanent and not has_status_code:
|
||||
fail(f"redirect entry {index} must set permanent or statusCode")
|
||||
if has_permanent and has_status_code:
|
||||
fail(f"redirect entry {index} must not set both permanent and statusCode")
|
||||
if has_permanent and not isinstance(item["permanent"], bool):
|
||||
fail(f"redirect entry {index} permanent must be boolean")
|
||||
if has_status_code:
|
||||
status_code = item["statusCode"]
|
||||
if not isinstance(status_code, int) or isinstance(status_code, bool):
|
||||
fail(f"redirect entry {index} statusCode must be an integer")
|
||||
if status_code < 100 or status_code > 999:
|
||||
fail(f"redirect entry {index} statusCode must be between 100 and 999")
|
||||
|
||||
|
||||
def validate_redirect_env(index: int, item: dict[str, object]) -> None:
|
||||
if "env" not in item:
|
||||
return
|
||||
|
||||
env = item["env"]
|
||||
if not isinstance(env, list):
|
||||
fail(f"redirect entry {index} env must be a list")
|
||||
if not env:
|
||||
fail(f"redirect entry {index} env must not be empty")
|
||||
if len(env) > MAX_ENV_ITEMS:
|
||||
fail(f"redirect entry {index} env must contain at most {MAX_ENV_ITEMS} items")
|
||||
for env_index, value in enumerate(env):
|
||||
if not isinstance(value, str):
|
||||
fail(f"redirect entry {index} env[{env_index}] must be a string")
|
||||
if len(value) > MAX_ENV_VALUE_LENGTH:
|
||||
fail(
|
||||
f"redirect entry {index} env[{env_index}] "
|
||||
f"must be at most {MAX_ENV_VALUE_LENGTH} characters"
|
||||
)
|
||||
|
||||
|
||||
def collect_redirects(data: dict[str, object]) -> dict[str, dict[str, object]]:
|
||||
raw_redirects = data.get("redirects", [])
|
||||
if not isinstance(raw_redirects, list):
|
||||
fail("vercel redirects must be a list")
|
||||
if len(raw_redirects) > MAX_REDIRECTS:
|
||||
fail(f"vercel redirects must contain at most {MAX_REDIRECTS} items")
|
||||
|
||||
redirects: dict[str, dict[str, object]] = {}
|
||||
for index, item in enumerate(raw_redirects):
|
||||
if not isinstance(item, dict):
|
||||
fail(f"redirect entry {index} must be an object")
|
||||
unknown_keys = set(item) - ALLOWED_REDIRECT_KEYS
|
||||
if unknown_keys:
|
||||
keys = ", ".join(sorted(unknown_keys))
|
||||
fail(f"redirect entry {index} has unknown keys: {keys}")
|
||||
source = item.get("source")
|
||||
if not isinstance(source, str) or not source:
|
||||
fail(f"redirect entry {index} missing source")
|
||||
validate_schema_string(f"redirect entry {index} source", source)
|
||||
destination = item.get("destination")
|
||||
if not isinstance(destination, str) or not destination:
|
||||
fail(f"redirect entry {index} missing destination")
|
||||
validate_schema_string(f"redirect entry {index} destination", destination)
|
||||
validate_redirect_mode(index, item)
|
||||
validate_redirect_env(index, item)
|
||||
validate_condition_list(index, item, "has")
|
||||
validate_condition_list(index, item, "missing")
|
||||
if source in redirects:
|
||||
fail(f"duplicate redirect source {source}")
|
||||
redirects[source] = item
|
||||
return redirects
|
||||
|
||||
|
||||
def load_vercel() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(
|
||||
VERCEL.read_text(encoding="utf-8"),
|
||||
parse_constant=reject_json_constant,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
fail("missing vercel.json")
|
||||
except json.JSONDecodeError as error:
|
||||
fail(f"invalid vercel.json: {error}")
|
||||
except ValueError as error:
|
||||
fail(f"invalid vercel.json: {error}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
fail("vercel.json must contain an object")
|
||||
validate_finite_json_numbers("vercel.json", data)
|
||||
return data
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data = load_vercel()
|
||||
if "framework" not in data or data["framework"] is not None:
|
||||
fail("vercel framework must be null")
|
||||
if data.get("installCommand") != "uv sync --frozen --no-dev":
|
||||
fail("unexpected Vercel installCommand")
|
||||
if data.get("buildCommand") != "uv run --frozen bash ./vercel-build.sh":
|
||||
fail("unexpected Vercel buildCommand")
|
||||
if data.get("outputDirectory") != "site":
|
||||
fail("unexpected Vercel outputDirectory")
|
||||
if data.get("trailingSlash") is not True:
|
||||
fail("vercel.json must set trailingSlash true")
|
||||
|
||||
redirects = collect_redirects(data)
|
||||
for source, destination in PERMANENT.items():
|
||||
item = redirects.get(source)
|
||||
if not item:
|
||||
fail(f"missing permanent redirect {source}")
|
||||
if item.get("destination") != destination or item.get("permanent") is not True:
|
||||
fail(f"incorrect permanent redirect {source}")
|
||||
|
||||
for source, destination in TEMPORARY.items():
|
||||
item = redirects.get(source)
|
||||
if not item:
|
||||
fail(f"missing temporary redirect {source}")
|
||||
if item.get("destination") != destination or item.get("permanent") is not False:
|
||||
fail(f"incorrect temporary redirect {source}")
|
||||
|
||||
print("vercel redirect checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user