diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4fb9d4d..808ee0c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,8 +18,11 @@ jobs: steps: - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history, so the page's "Updated" date is the last commit that touched the content. + fetch-depth: 0 - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - - run: uv run --with zensical zensical build --clean + - run: uv run build.py - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 06fff19..217a23c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,12 @@ jobs: - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - name: Markdown lint - run: uv run --with mdlint mdlint check docs/index.md README.md + run: uv run --with mdlint mdlint check README.md + + - name: Python lint + run: | + uv run --with ruff ruff check + uv run --with ruff ruff format --check - name: Validate docs - run: uv run --with zensical zensical build + run: uv run build.py diff --git a/.mdlint.toml b/.mdlint.toml index 37fc984..0cceb2f 100644 --- a/.mdlint.toml +++ b/.mdlint.toml @@ -1,6 +1,4 @@ ignore = ["MD051", "MD060"] # MD060: table rows are not kept pipe-aligned -# Content is included from README.md via pymdownx.snippets; nothing to lint here. -exclude = ["docs/index.md"] [rules.MD013] line_length = 120 diff --git a/AGENTS.md b/AGENTS.md index 2a20c94..dc7f6dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,22 @@ script (`cooldowns.sh`) that automates checking and configuring cooldowns. ## Conventions - `README.md` is the single source of truth for documentation content. - `docs/index.md` includes it via `pymdownx.snippets` (`--8<-- "README.md"`) - and only adds frontmatter. Edit `README.md`, not `docs/index.md`. + `build.py` renders it into the single-page site in `site/`, using the + Jinja2 templates in `docs/templates/` and the CSS, JS, and self-hosted + fonts (Red Hat Text and Red Hat Mono, with their licenses) in `docs/static/`. + +- `docs/tools.toml` holds per-tool metadata the README can't express + (support level, first version, default cooldown, unit, three-day value). + It drives the sidebar, the per-tool support cards, and the support matrix. + When adding, renaming, or removing a tool section in `README.md`, update + `docs/tools.toml` and the README quick reference table together. A new H2 + section must be added to an `[[ecosystem]]` or `[[group]]`. The build fails + if these disagree, or if a tool's `three_days` value no longer appears in + its README section. + +- `docs/anchors.txt` lists heading ids that inbound links depend on. Renaming + a heading changes its id and fails the build; only remove an entry if you + mean to break links to it. - `cooldowns.sh` must work on both Linux and macOS. Use portable constructs (e.g. `awk` instead of `grep -oP`, pure-bash version comparison instead @@ -25,14 +39,29 @@ script (`cooldowns.sh`) that automates checking and configuring cooldowns. - Run `shellcheck cooldowns.sh` after each change to the script. +- Lint and format `build.py` with ruff after each change to it (settings + in `ruff.toml`): + + ```sh + uv run --with ruff ruff check + uv run --with ruff ruff format + ``` + - Lint markdown after each change to the documentation files: ```sh uv run --with mdlint mdlint check ``` -- Build and validate docs after each change to README.md: +- Build and validate docs after each change to README.md or `docs/`: + + ```sh + uv run build.py + ``` + + To view the result locally, build and serve it on + (rerun after each edit): ```sh - uv run --with zensical zensical build + uv run build.py --serve ``` diff --git a/Makefile b/Makefile index 0e07246..8704eec 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,14 @@ export UV_EXCLUDE_NEWER ?= 3 days .PHONY: docs lint smoke-test docs: - uv run --with zensical zensical serve + uv run build.py --serve lint: shellcheck cooldowns.sh - uv run --with mdlint mdlint check docs/index.md README.md - uv run --with zensical zensical build + uv run --with mdlint mdlint check README.md + uv run --with ruff ruff check + uv run --with ruff ruff format --check + uv run build.py smoke-test: podman run --rm -i \ diff --git a/build.py b/build.py new file mode 100644 index 0000000..32afd06 --- /dev/null +++ b/build.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.14" +# dependencies = [ +# "jinja2", +# "markdown-it-py", +# "pygments", +# ] +# /// +"""Render README.md into the single-page cooldowns.dev site. + +README.md stays the source of truth for all prose and snippets. docs/tools.toml adds +the metadata that README.md cannot express (support level, versions, grouping), +which drives the sidebar, the per-tool support cards, and the support matrix. + +Usage: uv run build.py [--serve [PORT]] +""" + +import argparse +import contextlib +import functools +import re +import shutil +import subprocess +import tomllib +import unicodedata +from collections import Counter +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader +from markdown_it import MarkdownIt +from markdown_it.token import Token +from markupsafe import Markup +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import TextLexer, get_lexer_by_name +from pygments.token import STANDARD_TYPES, Comment, Keyword, Name, Number, Operator, Punctuation, String +from pygments.util import ClassNotFound + +ROOT = Path(__file__).resolve().parent +DOCS = ROOT / "docs" +OUT = ROOT / "site" +SITE = { + "url": "https://cooldowns.dev/", + "name": "Dependency Cooldowns", + "description": "A guide to configuring dependency cooldowns across package managers " + "to protect against supply chain attacks.", + "repo": "https://github.com/mprpic/cooldowns", + "repo_api": "https://api.github.com/repos/mprpic/cooldowns", +} +SUPPORT_LABELS = {"native": "Native", "partial": "Partial", "third-party": "Third-party", "none": "No support"} +LANG_LABELS = { + "bash": "Shell", + "ini": "INI", + "toml": "TOML", + "yaml": "YAML", + "json": "JSON", + "python": "Python", + "ruby": "Ruby", + "elixir": "Elixir", + "dockerfile": "Dockerfile", + "text": "Text", +} +# Pygments token families mapped to the --hl-* color tokens in style.css; first match wins. +HL_COLORS = [ + (Comment, "comment"), + (String, "string"), + (Number, "number"), + (Keyword, "keyword"), + (Name.Builtin, "func"), + (Name.Function, "func"), + (Name.Class, "func"), + (Name.Tag, "name"), + (Name.Attribute, "name"), + (Name.Variable, "name"), + (Name.Constant, "name"), + (Name.Namespace, "name"), + (Operator, "punct"), + (Punctuation, "punct"), +] +FORMATTER = HtmlFormatter(nowrap=True) + + +def check(ok, msg): + """Fail the build with a readable message (a bare assert would be skipped under python -O).""" + if not ok: + raise SystemExit(f"build.py: {msg}") + + +def slugify(text): + """Python-Markdown's toc slugify, so existing inbound #links keep working.""" + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + text = re.sub(r"[^\w\s-]", "", text).strip().lower() + return re.sub(r"[-\s]+", "-", text) + + +def render_fence(self, tokens, idx, options, env): + tok = tokens[idx] + lang = tok.info.strip().split()[0] if tok.info.strip() else "text" + try: + lexer = get_lexer_by_name(lang) + except ClassNotFound: + lexer = TextLexer() + code = highlight(tok.content, lexer, FORMATTER) + label = LANG_LABELS.get(lang, lang) + return ( + f'
{label}
' + f'
{code}
\n' + ) + + +def highlight_css(): + rules = [] + for ttype, cls in STANDARD_TYPES.items(): + var = next((v for base, v in HL_COLORS if cls and ttype in base), None) + if var: + italic = "; font-style: italic" if var == "comment" else "" + rules.append(f".code .{cls} {{ color: var(--hl-{var}){italic} }}") + return "/* Generated by build.py from Pygments token types. */\n" + "\n".join(rules) + "\n" + + +def html_block(content): + tok = Token("html_block", "", 0) + tok.content = f"{content}\n" + return tok + + +def collect_headings(tokens): + """Assign Python-Markdown style ids to headings and add hover permalinks.""" + headings = [] + for i, tok in enumerate(tokens): + if tok.type != "heading_open": + continue + inline = tokens[i + 1] + text = "".join(c.content for c in inline.children if c.type in ("text", "code_inline")) + hid = slugify(text) + check(hid, f"heading {text!r} has no characters usable in an id") + check(hid not in {h["id"] for h in headings}, f"duplicate heading id: {hid}") + tok.attrSet("id", hid) + if tok.tag != "h1": + link = Token("html_inline", "", 0) + link.content = f'' + inline.children.append(link) + headings.append({"level": int(tok.tag[1]), "id": hid, "text": text, "tok": tok}) + return headings + + +def section(headings, anchor): + """Headings nested under the heading with the given id.""" + start = next(i for i, h in enumerate(headings) if h["id"] == anchor) + level = headings[start]["level"] + for h in headings[start + 1 :]: + if h["level"] <= level: + break + yield h + + +def build_nav(meta, headings, tools_by_anchor): + by_id = {h["id"]: h for h in headings} + nav = [] + for group in meta["group"]: + if not group.get("ecosystems"): + labels = group.get("label", {}) + items = [] + for a in group["anchors"]: + text = labels.get(a, by_id[a]["text"]) + items.append({"id": a, "text": text, "terms": text.lower()}) + nav.append({"name": group["name"], "items": items}) + continue + ecos = [] + for eco in meta["ecosystem"]: + items = [] + for h in [by_id[eco["anchor"]], *section(headings, eco["anchor"])]: + for t in tools_by_anchor.get(h["id"], []): + terms = f"{t['name']} {h['text']} {eco['name']}".lower() + items.append({"id": t["anchor"], "text": t["name"], "support": t["support"], "terms": terms}) + if h["level"] == 3 and h["id"] not in tools_by_anchor: + items.append({"id": h["id"], "text": h["text"], "terms": f"{h['text']} {eco['name']}".lower()}) + ecos.append({**eco, "items": items}) + nav.append({"name": group["name"], "ecosystems": ecos}) + return nav + + +def nav_items(nav): + for g in nav: + yield from g.get("items", []) + for e in g.get("ecosystems", []): + yield from e["items"] + + +def add_subsections(nav, headings, eco_anchors): + """Nest headings the sidebar doesn't list (pnpm v10, FAQ questions) under their + closest listed ancestor, so the sidebar alone can stand in for a page outline.""" + items = {} + for item in nav_items(nav): + items.setdefault(item["id"], item) # Other ecosystems: three tools share one anchor + stack = [] + for h in headings: + while stack and stack[-1]["level"] >= h["level"]: + stack.pop() + parent = next((x for x in reversed(stack) if x["id"] in items), None) + if h["id"] not in items and h["id"] not in eco_anchors and parent and h["level"] > 1: + item = items[parent["id"]] + item.setdefault("children", []).append({"id": h["id"], "text": h["text"]}) + item["terms"] += f" {h['text'].lower()}" + stack.append(h) + + +def tool_sections(headings, tools_by_anchor): + """Yield each tool heading with the heading that ends its section (None at the end). + A section ends at the next heading of the same or a higher level, or at the next tool.""" + for i, h in enumerate(headings): + if h["id"] in tools_by_anchor: + end = (x for x in headings[i + 1 :] if x["level"] <= h["level"] or x["id"] in tools_by_anchor) + yield h, next(end, None) + + +def insert_components(tokens, headings, cards, after): + """Add each heading's component from `after` (keyed by heading id) below it, and wrap each + tool section in a container whose margin rail holds its support card, so the sticky card + travels with the section and stops where the section ends.""" + starts, ends = set(), set() + for h, end in tool_sections(headings, cards): + starts.add(id(h["tok"])) + ends.add(id(end["tok"]) if end else None) + by_tok = {id(h["tok"]): after[h["id"]] for h in headings if h["id"] in after} + out, pending = [], None + for tok in tokens: + if id(tok) in ends: + out.append(html_block("")) + if id(tok) in starts: + out.append(html_block('
')) + pending = by_tok.get(id(tok), pending) + out.append(tok) + if tok.type == "heading_close" and pending: + out.append(html_block(pending)) + pending = None + if None in ends: + out.append(html_block("
")) + return out + + +def check_three_days(readme, headings, tools_by_anchor): + """tools.toml's three_days values are typed in by hand; make sure the code in each tool's + README section still uses them (as a whole token, so "3" doesn't match "4.0.13").""" + lines = readme.splitlines() + for h, end in tool_sections(headings, tools_by_anchor): + text = "\n".join(lines[h["tok"].map[0] : end["tok"].map[0] if end else None]) + code = "\n".join(re.findall(r"```.*?```|`[^`\n]+`", text, re.S)) + for t in tools_by_anchor[h["id"]]: + if "three_days" in t: + found = re.search(rf"(?\n') + md.add_render_rule("table_close", lambda *a: "
\n") + tokens = md.parse(readme) + headings = collect_headings(tokens) + ids = {h["id"] for h in headings} + eco_by_anchor = {e["anchor"]: e for e in meta["ecosystem"]} + for anchor in [*tools_by_anchor, *eco_by_anchor]: + check(anchor in ids, f"tools.toml anchor #{anchor} is not a README heading") + grouped = {a for g in meta["group"] for a in g.get("anchors", [])} | eco_by_anchor.keys() + for h in headings: + check(h["level"] > 2 or h["id"] in grouped, f"#{h['id']} is not in any tools.toml [[group]]") + + env = Environment( + loader=FileSystemLoader(DOCS / "templates"), + autoescape=True, + trim_blocks=True, + lstrip_blocks=True, + keep_trailing_newline=True, + ) + env.globals.update(site=SITE, labels=SUPPORT_LABELS) + ui = env.get_template("partials.html.j2").module + nav = build_nav(meta, headings, tools_by_anchor) + add_subsections(nav, headings, eco_by_anchor) + check_three_days(readme, headings, tools_by_anchor) + + updated = last_updated() + if not updated: + print("warning: no git history for README.md; the hero shows no Updated date") + counts = Counter(t["support"] for t in tools) + i = next(j for j, t in enumerate(tokens) if t.attrGet("id") == "quick-reference") + open_idx, close_idx, head, groups = build_matrix(md, tokens, i, meta["ecosystem"], tools) + tokens[open_idx : close_idx + 1] = [html_block(ui.matrix(head, groups, [(s, counts[s]) for s in SUPPORT_LABELS]))] + # Tools with no support (Go, Maven / Gradle, NuGet) get no card. + cards = {a: ts for a, ts in tools_by_anchor.items() if any(t["support"] != "none" for t in ts)} + for a, ts in cards.items(): + check(len(ts) == 1, f"#{a}: only one tool per heading can have a support card") + after = {a: ui.tool_card(ts[0]) for a, ts in cards.items()} + after |= {a: ui.eco_index(e) for a, e in eco_by_anchor.items() if len(e["tools"]) > 1 and a not in tools_by_anchor} + after[headings[0]["id"]] = ui.hero_meta(updated) + tokens = insert_components(tokens, headings, cards, after) + + body = md.renderer.render(tokens, md.options, {}) + body = body.replace("
", "
") + body = re.sub(r"

(Note|Important):", r'

\1:', body) + + css = (DOCS / "static/style.css").read_text(encoding="utf-8") + dark = (DOCS / "static/dark-tokens.css").read_text(encoding="utf-8").strip() + # The OS dark preference applies unless the toggle chose light; the toggle's dark always applies. + dark_css = ( + f'@media (prefers-color-scheme: dark) {{\n :root:not([data-theme="light"]) {{\n{dark}\n }}\n}}\n\n' + f':root[data-theme="dark"] {{\n{dark}\n}}' + ) + css = css.replace("/* @dark-tokens */", dark_css) + css += "\n" + highlight_css() + js = (DOCS / "static/app.js").read_text(encoding="utf-8") + ctx = {"nav": nav, "body": Markup(body), "title": SITE["name"]} + + page = env.get_template("page.html.j2").render(**ctx, base="", home="#dependency-cooldowns") + count = check_links(readme, page, DOCS / "anchors.txt") + shutil.rmtree(OUT, ignore_errors=True) + (OUT / "assets").mkdir(parents=True) + (OUT / "index.html").write_text(page, encoding="utf-8") + (OUT / "404.html").write_text(env.get_template("404.html.j2").render(**ctx, base="/", home="/"), encoding="utf-8") + (OUT / "assets/style.css").write_text(css, encoding="utf-8") + (OUT / "assets/app.js").write_text(js, encoding="utf-8") + for font in re.findall(r"url\(fonts/([^)]+)\)", css): + check((DOCS / "static/fonts" / font).is_file(), f"style.css references missing font {font}") + shutil.copytree(DOCS / "static/fonts", OUT / "assets/fonts") + shutil.copy(DOCS / "static/favicon.svg", OUT / "favicon.svg") + shutil.copy(DOCS / "CNAME", OUT / "CNAME") + (OUT / ".nojekyll").write_text("") + print(f"site/: {len(headings)} headings, {count} ids, anchors and internal links OK") + + if args.serve: + serve(OUT, args.serve) + + +def serve(directory, port): + """For viewing the site locally; rerun the build after editing README.md or docs/.""" + handler = functools.partial(SimpleHTTPRequestHandler, directory=directory) + with ThreadingHTTPServer(("127.0.0.1", port), handler) as httpd, contextlib.suppress(KeyboardInterrupt): + print(f"Serving site/ at http://127.0.0.1:{port}/ (Ctrl+C to stop)", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/docs/anchors.txt b/docs/anchors.txt new file mode 100644 index 0000000..03c27f7 --- /dev/null +++ b/docs/anchors.txt @@ -0,0 +1,63 @@ +# Heading ids that existed when cooldowns.dev moved off zensical. Inbound links +# (cooldowns.dev/#uv and so on) rely on them, so build.py fails if one goes missing. +# Renaming a heading changes its id; only remove an entry here if you mean to break it. +h1 dependency-cooldowns +h2 does-it-actually-work +h2 python-ecosystem +h3 uv +h3 pip +h4 pip-261 +h3 pipenv +h3 pipx +h3 poetry +h3 pdm +h3 conda +h4 micromamba +h3 pixi +h3 private-pypi-registries +h2 javascript-ecosystem +h3 npm-javascriptnodejs +h3 pnpm-javascriptnodejs +h4 pnpm-v11 +h4 pnpm-v10 +h3 yarn-javascriptnodejs +h3 bun-javascriptnodejs +h3 deno-javascripttypescript +h3 npm-check-updates-javascriptnodejs +h2 rust-ecosystem +h3 cargo +h2 ruby-ecosystem +h3 bundler +h2 elixir-ecosystem +h3 hex +h2 scala-jvm-ecosystem +h3 scala-steward +h2 php-ecosystem +h3 composer +h2 tool-managers +h3 mise +h2 ide-extensions +h3 vs-code +h3 cursor +h2 github-actions +h3 actions-up +h2 other-ecosystems +h2 dependency-update-bots +h2 security-scanners-and-pr-checks +h2 registry-level-proxies +h2 container-images +h3 relative-durations +h3 absolute-timestamps +h2 cooldownssh +h3 setting-cooldowns +h3 checking-cooldowns +h3 usage-in-containers +h2 quick-reference +h2 bypassing-cooldowns +h2 faq +h3 how-long-should-my-cooldown-be +h3 would-cooldowns-still-work-if-everyone-adopted-them-am-i-offloading-my-risks-onto-others +h3 dont-cooldowns-block-security-fixes +h3 should-cooldowns-replace-lockfiles +h2 conclusion +h2 changelog diff --git a/docs/images/shield.svg b/docs/images/shield.svg deleted file mode 100644 index 77ef4fb..0000000 --- a/docs/images/shield.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index cb313ab..0000000 --- a/docs/index.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -hide: - - navigation -search: - exclude: true ---- - ---8<-- "README.md" diff --git a/docs/js/no-search.js b/docs/js/no-search.js deleted file mode 100644 index 5f919e9..0000000 --- a/docs/js/no-search.js +++ /dev/null @@ -1,6 +0,0 @@ -document.addEventListener("keydown", function (e) { - if ((e.ctrlKey || e.metaKey) && e.key === "k") { - e.preventDefault(); - e.stopPropagation(); - } -}, true); diff --git a/docs/static/app.js b/docs/static/app.js new file mode 100644 index 0000000..c67f158 --- /dev/null +++ b/docs/static/app.js @@ -0,0 +1,326 @@ +// Progressive enhancement only: theme toggle, nav drawer, nav filter, collapsible +// groups, scrollspy, and copy buttons. Every section stays readable without it. +(() => { + const root = document.documentElement; + const $ = (sel, el = document) => el.querySelector(sel); + const $$ = (sel, el = document) => [...el.querySelectorAll(sel)]; + const live = $("#live"); + const say = (msg) => { + live.textContent = ""; + setTimeout(() => (live.textContent = msg), 60); + }; + + // Theme: three modes. "system" follows the OS; "light" and "dark" are remembered. The cycle goes + // system -> the opposite of the OS theme -> the OS theme, so the first click always changes the look. + const themeBtn = $(".theme-btn"); + const systemDark = matchMedia("(prefers-color-scheme: dark)"); + const mode = () => root.getAttribute("data-theme") || "system"; + const nextMode = () => { + const os = systemDark.matches ? "dark" : "light"; + const order = ["system", os === "dark" ? "light" : "dark", os]; + return order[(order.indexOf(mode()) + 1) % order.length]; + }; + const labelTheme = () => { + const label = `Theme: ${mode()}. Switch to ${nextMode()}.`; + themeBtn.dataset.mode = mode(); + themeBtn.setAttribute("aria-label", label); + themeBtn.setAttribute(themeBtn.hasAttribute("data-tip") ? "data-tip" : "title", label); + }; + themeBtn.addEventListener("click", () => { + const next = nextMode(); + if (next === "system") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", next); + try { + if (next === "system") localStorage.removeItem("theme"); + else localStorage.setItem("theme", next); + } catch {} + labelTheme(); + }); + systemDark.addEventListener("change", labelTheme); + labelTheme(); + + // Copy buttons on code blocks. + const COPY_ICON = + ''; + for (const block of $$(".code")) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "copy-btn"; + btn.innerHTML = `${COPY_ICON}Copy`; + const label = btn.lastChild; + btn.addEventListener("click", async () => { + const text = $("code", block).innerText.replace(/\n$/, ""); + let ok = true; + try { + await navigator.clipboard.writeText(text); + } catch { + ok = false; + } + label.textContent = ok ? "Copied" : "Copy failed"; + say(ok ? "Copied to clipboard" : "Copy failed. Select the code and copy it manually."); + setTimeout(() => (label.textContent = "Copy"), 1600); + }); + $(".code-head", block).append(btn); + } + + // Star and fork counts from the GitHub API, like a README badge. The result is cached for the + // tab session to stay far below the API's limit of 60 unauthenticated requests per hour per IP. + const repo = $(".repo[data-api]"); + if (repo) { + const compact = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k`.replace(".0k", "k") : String(n)); + const show = ({ stars, forks }) => { + if (!Number.isFinite(stars) || !Number.isFinite(forks)) return; + $('[data-stat="stars"]', repo).textContent = compact(stars); + $('[data-stat="forks"]', repo).textContent = compact(forks); + $(".repo-stats", repo).hidden = false; + repo.setAttribute("aria-label", `Source on GitHub: ${compact(stars)} stars, ${compact(forks)} forks`); + }; + let cached = null; + try { + cached = JSON.parse(sessionStorage.getItem("repo-stats")); + } catch {} + if (cached) { + show(cached); + } else { + fetch(repo.dataset.api, { headers: { Accept: "application/vnd.github+json" } }) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then((d) => { + const stats = { stars: d.stargazers_count, forks: d.forks_count }; + try { + sessionStorage.setItem("repo-stats", JSON.stringify(stats)); + } catch {} + show(stats); + }) + .catch(() => {}); // Leave the counts hidden. + } + } + + // Tooltips from title attributes: a short delay for the first, then instant while warm. + const tip = Object.assign(document.createElement("div"), { className: "tooltip", id: "tooltip" }); + tip.setAttribute("role", "tooltip"); + document.body.append(tip); + let shownFor = null; + let pendingFor = null; + let openTimer = 0; + let coolTimer = 0; + let warm = false; + const placeTip = (el) => { + const at = el.getBoundingClientRect(); + const left = Math.min(at.left + at.width / 2 - tip.offsetWidth / 2, root.clientWidth - tip.offsetWidth - 4); + let top = at.bottom + 6; + if (top > root.clientHeight - tip.offsetHeight - 4) top = at.top - tip.offsetHeight - 6; + tip.style.left = `${Math.round(Math.max(4, left))}px`; + tip.style.top = `${Math.round(Math.max(4, top))}px`; + }; + const showTip = (el) => { + clearTimeout(coolTimer); + pendingFor = null; + tip.textContent = el.dataset.tip; + tip.dataset.instant = String(warm); + tip.style.left = tip.style.top = "0px"; + placeTip(el); + tip.dataset.show = "true"; + el.setAttribute("aria-describedby", tip.id); + shownFor = el; + warm = true; + }; + const hideTip = () => { + clearTimeout(openTimer); + pendingFor = null; + if (!shownFor) return; + tip.dataset.show = "false"; + tip.textContent = ""; + shownFor.removeAttribute("aria-describedby"); + shownFor = null; + clearTimeout(coolTimer); + coolTimer = setTimeout(() => (warm = false), 300); + }; + const tipFor = (node) => node?.closest?.("[data-tip]"); + document.addEventListener("mouseover", (e) => { + const el = tipFor(e.target); + if (!el) { + if (!shownFor?.matches(":focus-visible")) hideTip(); + return; + } + if (el === shownFor || el === pendingFor) return; + clearTimeout(openTimer); + if (warm) { + showTip(el); + } else { + pendingFor = el; + openTimer = setTimeout(() => showTip(el), 200); + } + }); + document.addEventListener("mouseleave", hideTip); + document.addEventListener("focusin", (e) => { + const el = tipFor(e.target); + if (el?.matches(":focus-visible")) showTip(el); + }); + document.addEventListener("focusout", hideTip); + document.addEventListener("click", hideTip); + document.addEventListener("toggle", hideTip, true); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") hideTip(); + }); + const reflowTip = () => { + if (!shownFor) return; + const at = shownFor.getBoundingClientRect(); + if (at.bottom < 0 || at.top > root.clientHeight) hideTip(); + else placeTip(shownFor); + }; + addEventListener("scroll", reflowTip, { passive: true }); + addEventListener("resize", reflowTip); + const canHover = matchMedia("(hover: hover)"); + const adoptTitles = () => { + for (const el of $$("[title]")) { + el.dataset.tip = el.title; + el.removeAttribute("title"); + } + }; + const restoreTitles = () => { + hideTip(); + for (const el of $$("[data-tip]")) { + el.title = el.dataset.tip; + delete el.dataset.tip; + } + }; + canHover.addEventListener("change", () => (canHover.matches ? adoptTitles() : restoreTitles())); + if (canHover.matches) adoptTitles(); + + const sidebar = $("#sidebar"); + if (!sidebar) return; // 404 page has no navigation. + + // Mobile drawer. + const menuBtn = $(".menu-btn"); + const narrow = matchMedia("(max-width: 959px)"); + const isOpen = () => root.classList.contains("drawer-open"); + const setDrawer = (open) => { + root.classList.toggle("drawer-open", open); + menuBtn.setAttribute("aria-expanded", String(open)); + menuBtn.setAttribute("aria-label", open ? "Close navigation" : "Open navigation"); + for (const el of [$("main"), $(".site-footer")]) el.inert = open; + if (open) sidebar.focus({ preventScroll: true }); + }; + menuBtn.addEventListener("click", () => setDrawer(!isOpen())); + $(".backdrop").addEventListener("click", () => setDrawer(false)); + sidebar.addEventListener("click", (e) => { + if (e.target.closest("a") && narrow.matches) setDrawer(false); + }); + narrow.addEventListener("change", () => setDrawer(false)); + + // Collapsible ecosystem groups. + for (const btn of $$(".eco-toggle")) { + btn.addEventListener("click", () => { + const open = btn.getAttribute("aria-expanded") !== "true"; + btn.setAttribute("aria-expanded", String(open)); + btn.closest(".nav-eco").classList.toggle("collapsed", !open); + }); + } + + // Nav filter: matches tool names, heading and subsection text, and ecosystem names. + const filter = $("#nav-filter"); + const empty = $(".nav-empty"); + filter.addEventListener("input", () => { + const q = filter.value.trim().toLowerCase(); + sidebar.classList.toggle("filtering", q !== ""); + let total = 0; + for (const list of $$(".nav-list", sidebar)) { + let shown = 0; + for (const li of $$(":scope > li", list)) { + let hit = !q || li.dataset.terms.includes(q); + if (li.classList.contains("nav-eco")) { + const groupHit = hit; + for (const t of $$(":scope > ul > li", li)) { + t.hidden = !(groupHit || t.dataset.terms.includes(q)); + hit ||= !t.hidden; + } + } + li.hidden = !hit; + shown += hit; + } + list.hidden = list.previousElementSibling.hidden = shown === 0; + total += shown; + } + empty.hidden = total > 0; + }); + filter.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + const first = $$("li:not([hidden]) > a", sidebar).find((a) => a.offsetParent !== null); + if (first) { + e.preventDefault(); + first.click(); + } + } else if (e.key === "Escape" && filter.value) { + e.stopPropagation(); + filter.value = ""; + filter.dispatchEvent(new Event("input")); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + if (isOpen()) { + setDrawer(false); + menuBtn.focus(); + } + } else if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (e.target.closest("input, textarea, select, [contenteditable]")) return; + e.preventDefault(); + if (narrow.matches) setDrawer(true); + filter.focus(); + } + }); + + // Scrollspy: highlight the current section in the sidebar and unfold its subsections. + // Several tools can share one anchor (Other ecosystems); keep only the first link. + const navLinks = new Map(); + for (const a of $$('a[href^="#"]', sidebar)) if (!navLinks.has(a.hash.slice(1))) navLinks.set(a.hash.slice(1), a); + // Headings the sidebar doesn't link leave the previous section highlighted. + const heads = $$(".doc :is(h1, h2, h3, h4)[id]").filter((h) => navLinks.has(h.id)); + const navScroll = $(".nav-scroll"); + const bar = $(".progress span"); + const keepVisible = (box, el) => { + if (!el?.offsetParent || box.scrollHeight <= box.clientHeight) return; + const b = box.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + if (r.top < b.top + 24 || r.bottom > b.bottom - 24) box.scrollTop += r.top - b.top - b.height / 3; + }; + let active = null; + const spy = () => { + const y = (parseFloat(getComputedStyle(root).scrollPaddingTop) || 80) + 8; + // At the very bottom, short final sections can't reach the top line; take the last visible heading. + const limit = innerHeight + scrollY >= root.scrollHeight - 2 ? innerHeight : y; + let h = heads[0]; + for (const el of heads) { + if (el.getBoundingClientRect().top > limit) break; + h = el; + } + if (h !== active) { + active = h; + for (const a of $$("[aria-current]", sidebar)) a.removeAttribute("aria-current"); + for (const li of $$(".nav-parent.open", sidebar)) li.classList.remove("open"); + const link = navLinks.get(h.id); + link.setAttribute("aria-current", "location"); + link.closest(".nav-parent")?.classList.add("open"); + // In a collapsed group the link is hidden; keep the group's heading in view instead. + keepVisible(navScroll, link.offsetParent ? link : link.closest(".nav-eco")?.querySelector(".eco-head a")); + } + const max = root.scrollHeight - innerHeight; + bar.style.transform = `scaleX(${max > 0 ? Math.min(1, scrollY / max) : 0})`; + }; + let queued = false; + addEventListener( + "scroll", + () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + spy(); + }); + }, + { passive: true }, + ); + addEventListener("resize", spy); + spy(); +})(); diff --git a/docs/static/dark-tokens.css b/docs/static/dark-tokens.css new file mode 100644 index 0000000..284fe7c --- /dev/null +++ b/docs/static/dark-tokens.css @@ -0,0 +1,40 @@ + /* Dark palette. build.py emits this block twice: under prefers-color-scheme + (unless the toggle chose light) and under [data-theme="dark"]. */ + color-scheme: dark; + --bg: #232529; + --bg-side: #1e2023; + --surface: #2a2c31; + --surface-2: #2c2e33; + --code-bg: #2b2d32; + --code-head: #303238; + --ink: #ece6ea; + --ink-2: #c8bfc5; + --ink-3: #a69ca3; + --line: #383a40; + --line-strong: #4b4e55; + --link: #d9a441; + --link-hover: #f0d298; + --link-line: rgba(217, 164, 65, 0.45); + --focus: #f0d298; + --hover-bg: #2e3035; + --active-bg: #3a2b36; + --active-ink: #f3d9a6; + --callout-bg: #2e2a25; + --callout-line: #5a4a30; + --ok: #79d4a2; + --ok-bg: #1d3a2c; + --warn: #f2a66e; + --warn-bg: #45301f; + --info: #9fbcf3; + --info-bg: #27334e; + --off: #b7adb3; + --off-bg: #36383d; + --hl-comment: #a89fa5; + --hl-keyword: #e9a3d7; + --hl-string: #e8b965; + --hl-number: #82d5c4; + --hl-name: #a3c1f5; + --hl-func: #f2a983; + --hl-punct: #c9c0c6; + --shadow-pop: 0 14px 36px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3); + --backdrop: rgba(8, 4, 8, 0.6); \ No newline at end of file diff --git a/docs/static/favicon.svg b/docs/static/favicon.svg new file mode 100644 index 0000000..b48c64f --- /dev/null +++ b/docs/static/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/static/fonts/LICENSE-red-hat-mono.txt b/docs/static/fonts/LICENSE-red-hat-mono.txt new file mode 100644 index 0000000..d0a53ff --- /dev/null +++ b/docs/static/fonts/LICENSE-red-hat-mono.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont) RedHatMono-Italic[wght].ttf: Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/static/fonts/LICENSE-red-hat-text.txt b/docs/static/fonts/LICENSE-red-hat-text.txt new file mode 100644 index 0000000..5db8d6a --- /dev/null +++ b/docs/static/fonts/LICENSE-red-hat-text.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont) RedHatText-Italic[wght].ttf: Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/static/fonts/red-hat-mono-latin-ext-wght-italic.woff2 b/docs/static/fonts/red-hat-mono-latin-ext-wght-italic.woff2 new file mode 100644 index 0000000..5a2f3c1 Binary files /dev/null and b/docs/static/fonts/red-hat-mono-latin-ext-wght-italic.woff2 differ diff --git a/docs/static/fonts/red-hat-mono-latin-ext-wght-normal.woff2 b/docs/static/fonts/red-hat-mono-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..6c2775b Binary files /dev/null and b/docs/static/fonts/red-hat-mono-latin-ext-wght-normal.woff2 differ diff --git a/docs/static/fonts/red-hat-mono-latin-wght-italic.woff2 b/docs/static/fonts/red-hat-mono-latin-wght-italic.woff2 new file mode 100644 index 0000000..c291c94 Binary files /dev/null and b/docs/static/fonts/red-hat-mono-latin-wght-italic.woff2 differ diff --git a/docs/static/fonts/red-hat-mono-latin-wght-normal.woff2 b/docs/static/fonts/red-hat-mono-latin-wght-normal.woff2 new file mode 100644 index 0000000..ee5d5f3 Binary files /dev/null and b/docs/static/fonts/red-hat-mono-latin-wght-normal.woff2 differ diff --git a/docs/static/fonts/red-hat-text-latin-ext-wght-italic.woff2 b/docs/static/fonts/red-hat-text-latin-ext-wght-italic.woff2 new file mode 100644 index 0000000..6dbfded Binary files /dev/null and b/docs/static/fonts/red-hat-text-latin-ext-wght-italic.woff2 differ diff --git a/docs/static/fonts/red-hat-text-latin-ext-wght-normal.woff2 b/docs/static/fonts/red-hat-text-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..2d36087 Binary files /dev/null and b/docs/static/fonts/red-hat-text-latin-ext-wght-normal.woff2 differ diff --git a/docs/static/fonts/red-hat-text-latin-wght-italic.woff2 b/docs/static/fonts/red-hat-text-latin-wght-italic.woff2 new file mode 100644 index 0000000..e88ae81 Binary files /dev/null and b/docs/static/fonts/red-hat-text-latin-wght-italic.woff2 differ diff --git a/docs/static/fonts/red-hat-text-latin-wght-normal.woff2 b/docs/static/fonts/red-hat-text-latin-wght-normal.woff2 new file mode 100644 index 0000000..ea28f1c Binary files /dev/null and b/docs/static/fonts/red-hat-text-latin-wght-normal.woff2 differ diff --git a/docs/static/style.css b/docs/static/style.css new file mode 100644 index 0000000..11d4ad8 --- /dev/null +++ b/docs/static/style.css @@ -0,0 +1,1140 @@ +/* Layout: two-column reference docs. Sticky grouped tool nav | reading column with a margin rail + that holds each tool's support card. Below 1200px cards move inline; below 960px the nav becomes a drawer. */ + +/* Self-hosted Red Hat Text and Red Hat Mono (SIL OFL 1.1, from Fontsource 5.3.0; licenses in fonts/). + Variable weight 300 to 700, latin and latin-ext only; other scripts fall back to system fonts. */ +@font-face { + font-family: "Red Hat Text"; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-text-latin-wght-normal.woff2) format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: "Red Hat Text"; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-text-latin-ext-wght-normal.woff2) format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: "Red Hat Text"; + font-style: italic; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-text-latin-wght-italic.woff2) format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: "Red Hat Text"; + font-style: italic; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-text-latin-ext-wght-italic.woff2) format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: "Red Hat Mono"; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-mono-latin-wght-normal.woff2) format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: "Red Hat Mono"; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-mono-latin-ext-wght-normal.woff2) format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: "Red Hat Mono"; + font-style: italic; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-mono-latin-wght-italic.woff2) format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: "Red Hat Mono"; + font-style: italic; + font-weight: 300 700; + font-display: swap; + src: url(fonts/red-hat-mono-latin-ext-wght-italic.woff2) format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +:root { + color-scheme: light; + /* Brand */ + --plum: #3d0f35; + --amber: #b3651d; + --amber-light: #f0d298; + --brand-gradient: linear-gradient(90deg, #3d0f35 0%, #b3651d 100%); + /* Neutrals, tinted toward plum */ + --bg: #fcfafb; + --bg-side: #f6f1f4; + --surface: #ffffff; + --surface-2: #f4eef1; + --code-bg: #f9f5f7; + --code-head: #f2ebef; + --ink: #241a21; + --ink-2: #4f434b; + --ink-3: #6a5c65; + --line: #e7dee3; + --line-strong: #d3c5cd; + /* Interaction */ + --link: #8f6210; + --link-hover: #7a4a0c; + --link-line: rgba(154, 106, 18, 0.4); + --focus: #8a2a78; + --hover-bg: #efe6eb; + --active-bg: #f1e0ea; + --active-ink: #561449; + --callout-bg: #fbf3e4; + --callout-line: #ead3a6; + /* Support status: shape and label carry meaning, color reinforces it */ + --ok: #1d7a4a; + --ok-bg: #e2f2e8; + --warn: #a3521a; + --warn-bg: #fbeadb; + --info: #3558a8; + --info-bg: #e6ecf8; + --off: #6d5f67; + --off-bg: #eee8eb; + /* Syntax highlighting (mapped from Pygments token types in build.py) */ + --hl-comment: #6c5f67; + --hl-keyword: #8b1d6f; + --hl-string: #7f5100; + --hl-number: #0d6e64; + --hl-name: #2f55a0; + --hl-func: #9b3412; + --hl-punct: #5a4d55; + /* Misc */ + --shadow-pop: 0 14px 36px rgba(61, 15, 53, 0.16), 0 2px 6px rgba(61, 15, 53, 0.08); + --backdrop: rgba(29, 10, 25, 0.45); + /* Type */ + --font-sans: "Red Hat Text", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Helvetica Neue", Arial, + sans-serif; + --font-mono: "Red Hat Mono", ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, + "Adwaita Mono", "Liberation Mono", monospace; + /* Metrics */ + --header-h: 56px; + --safe-top: env(safe-area-inset-top, 0px); + --gutter-l: max(16px, env(safe-area-inset-left, 0px)); + --gutter-r: max(16px, env(safe-area-inset-right, 0px)); + --measure: 46rem; + --rail-w: 13.5rem; + --rail-gap: 2.5rem; + --doc-w: var(--measure); +} + +/* @dark-tokens */ + +/* ---------- Base ---------- */ + +[hidden] { display: none !important; } +*, *::before, *::after { box-sizing: border-box; } + +html { + scroll-padding-top: calc(var(--header-h) + var(--safe-top) + 20px); + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + /* No rubber-band bounce: it reveals a strip of page background above the gradient header. */ + overscroll-behavior-y: none; +} + +@media (prefers-reduced-motion: no-preference) { + html { scroll-behavior: smooth; } +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 16px/1.65 var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +/* Paint the phone's top inset in the header color. */ +body::before { + content: ""; + position: fixed; + inset: 0 0 auto; + height: var(--safe-top); + z-index: 60; + background: var(--brand-gradient); +} + +::selection { background: rgba(179, 101, 29, 0.28); } + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +a { color: var(--link); } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +html:not(.js) .js-only { display: none !important; } + +.i { flex: none; } + +.skip { + position: absolute; + top: calc(var(--safe-top) + 8px); + left: var(--gutter-l); + z-index: 100; + padding: 8px 14px; + border-radius: 8px; + background: var(--surface); + color: var(--ink); + font-weight: 600; + box-shadow: var(--shadow-pop); + transform: translateY(-300%); +} + +.skip:focus { transform: none; } + +/* ---------- Header ---------- */ + +.site-header { + position: sticky; + top: var(--safe-top); + z-index: 50; + background: var(--brand-gradient); + color: #fff; +} + +/* A faint top sheen keeps the band from looking flat. (No glow at the warm end: it pushed the + white GitHub label below AA contrast.) */ +.site-header::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(rgba(255, 255, 255, 0.07), transparent 60%); + pointer-events: none; +} + +.header-inner { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + max-width: 1480px; + height: var(--header-h); + margin: 0 auto; + padding-left: var(--gutter-l); + padding-right: var(--gutter-r); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px 6px 4px 2px; + border-radius: 8px; + color: #fff; + font-size: 1.08rem; + font-weight: 700; + letter-spacing: -0.01em; + text-decoration: none; +} + +.brand .tld { font-weight: 500; opacity: 0.8; } +.logo-shield { fill: #fff; } +.logo-ring { stroke: var(--plum); } +.logo-sweep { fill: var(--plum); } + +.header-actions { + display: flex; + gap: 2px; + margin-left: auto; +} + +.icon-btn { + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: #fff; + cursor: pointer; +} + +.icon-btn:hover { background: rgba(255, 255, 255, 0.16); } + +/* Repository link with star and fork counts (fetched at build time). */ +.repo { + display: inline-flex; + align-items: center; + gap: 9px; + height: 40px; + margin-right: 4px; + padding: 0 10px 0 8px; + border-radius: 8px; + color: #fff; + font-size: 0.8rem; + line-height: 1.25; + text-decoration: none; +} + +.repo:hover { background: rgba(255, 255, 255, 0.16); } +.repo .i-github { width: 22px; height: 22px; } +.repo-text { display: grid; } +.repo-name { font-weight: 600; } + +.repo-stats { + display: flex; + gap: 10px; + opacity: 0.85; + font-variant-numeric: tabular-nums; +} + +.repo-stats > span { display: inline-flex; align-items: center; gap: 3px; } +.repo-stats .i { width: 12px; height: 12px; } + +/* Phones: just the GitHub icon; the counts stay in the link's aria-label. */ +@media (max-width: 479px) { + .repo { margin-right: 0; padding-inline: 8px; } + .repo-text { display: none; } +} +.site-header :focus-visible { outline-color: #fff; } +/* The theme button shows the current mode, which app.js sets: system, light, or dark. */ +.theme-btn .i { display: none; } +.theme-btn[data-mode="system"] .i-auto, +.theme-btn[data-mode="light"] .i-sun, +.theme-btn[data-mode="dark"] .i-moon { display: block; } + +/* Reading progress along the bottom edge of the header. */ +.progress { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + height: 2px; +} + +.progress span { + display: block; + height: 100%; + background: var(--amber-light); + transform: scaleX(0); + transform-origin: left; +} + +/* ---------- Layout ---------- */ + +.layout { + display: grid; + grid-template-columns: 17rem minmax(0, 1fr); + grid-template-areas: "nav main" "nav footer"; + max-width: 1480px; + margin: 0 auto; +} + +main { + grid-area: main; + min-width: 0; + padding: 44px 48px 96px; +} + +main:focus { outline: none; } + +/* ---------- Sidebar ---------- */ + +.sidebar { + grid-area: nav; + position: sticky; + top: calc(var(--header-h) + var(--safe-top)); + display: flex; + flex-direction: column; + height: calc(100vh - var(--header-h) - var(--safe-top)); + height: calc(100dvh - var(--header-h) - var(--safe-top)); + border-right: 1px solid var(--line); + background: var(--bg-side); +} + +.sidebar:focus { outline: none; } + +.nav-filter { + position: relative; + padding: 16px 14px 8px; +} + +.nav-filter .i-search { + position: absolute; + top: 26px; + left: 25px; + width: 16px; + height: 16px; + color: var(--ink-3); + pointer-events: none; +} + +.nav-filter input { + width: 100%; + height: 36px; + padding: 0 34px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 0.9rem; +} + +.nav-filter input::placeholder { color: var(--ink-3); opacity: 1; } +.nav-filter input:focus-visible { outline-offset: 0; border-color: var(--focus); } +.nav-filter input::-webkit-search-cancel-button { display: none; } + +.nav-filter kbd { + position: absolute; + top: 24px; + right: 22px; + min-width: 20px; + padding: 0 5px; + border: 1px solid var(--line-strong); + border-radius: 4px; + color: var(--ink-3); + font: 600 0.72rem/18px var(--font-mono); + text-align: center; +} + +.nav-scroll { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 10px 40px; + scrollbar-width: thin; + scrollbar-color: var(--line-strong) transparent; +} + +.nav-title { + margin: 18px 8px 4px; + color: var(--ink-3); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.nav-list, +.nav-list ul { + margin: 0; + padding: 0; + list-style: none; +} + +.nav-list a { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 8px; + border-radius: 6px; + color: var(--ink-2); + font-size: 0.9rem; + line-height: 1.4; + text-decoration: none; +} + +.nav-list a:hover { background: var(--hover-bg); color: var(--ink); } + +.nav-list a[aria-current] { + background: var(--active-bg); + color: var(--active-ink); + font-weight: 600; +} + +.eco-head { + display: flex; + align-items: center; + gap: 2px; + margin-top: 2px; +} + +.eco-head a { + flex: 1; + color: var(--ink); + font-weight: 600; +} + +.eco-count { + min-width: 18px; + color: var(--ink-3); + font: 500 0.72rem var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; +} + +html:not(.js) .eco-count { padding-right: 8px; } + +.eco-toggle { + display: grid; + place-items: center; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: var(--ink-3); + cursor: pointer; +} + +.eco-toggle:hover { background: var(--hover-bg); color: var(--ink); } +.eco-toggle .i { width: 16px; height: 16px; transition: transform 0.15s; } +.eco-toggle[aria-expanded="false"] .i { transform: rotate(-90deg); } + +.nav-eco > ul { + margin: 1px 0 6px 13px; + padding-left: 6px; + border-left: 1px solid var(--line); +} + +.nav-eco.collapsed > ul { display: none; } +.filtering .nav-eco.collapsed > ul { display: block; } + +/* Subsections of a nav item. Without JS they always show; with JS only the current item's. */ +.nav-list .nav-sub { + margin: 1px 0 4px 21px; + padding-left: 8px; + border-left: 1px solid var(--line); +} + +.nav-list .nav-sub a { + padding-block: 3px; + color: var(--ink-3); + font-size: 0.84rem; +} + +.js .nav-sub { display: none; } +.js .nav-parent.open > .nav-sub { display: block; } +.filtering .nav-parent.open > .nav-sub { display: none; } +.nav-parent.open > a:not([aria-current]) { color: var(--ink); font-weight: 600; } + +.nav-empty { + margin: 12px 8px; + color: var(--ink-3); + font-size: 0.88rem; +} + +/* ---------- Status dots and chips ---------- */ + +.dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.dot-native { background: var(--ok); } +.dot-partial { + background: linear-gradient(90deg, var(--warn) 50%, transparent 50%); + box-shadow: inset 0 0 0 1.5px var(--warn); +} +.dot-third-party { box-shadow: inset 0 0 0 1.5px var(--info); } +.dot-none { height: 2px; border-radius: 1px; background: var(--off); } +.dot-blank { visibility: hidden; } + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 9px 0 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 650; + line-height: 1.7; + white-space: nowrap; +} + +.chip-native { background: var(--ok-bg); color: var(--ok); } +.chip-partial { background: var(--warn-bg); color: var(--warn); } +.chip-third-party { background: var(--info-bg); color: var(--info); } +.chip-none { background: var(--off-bg); color: var(--off); } + +/* ---------- Document ---------- */ + +.doc { + max-width: var(--measure); + margin: 0 auto; +} + +.doc h1, +.doc h2, +.doc h3, +.doc h4 { + color: var(--ink); + text-wrap: balance; +} + +.doc h1 { + margin: 0 0 14px; + font-size: clamp(2.1rem, 1.6rem + 1.8vw, 2.9rem); + font-weight: 800; + line-height: 1.08; + letter-spacing: -0.03em; +} + +.doc h2 { + margin: 72px 0 16px; + padding-top: 36px; + border-top: 1px solid var(--line); + font-size: 1.7rem; + font-weight: 750; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.doc h3 { + margin: 44px 0 12px; + font-size: 1.28rem; + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.01em; +} + +.doc h4 { + margin: 32px 0 10px; + font-size: 1.05rem; + font-weight: 700; +} + +.anchor { + margin-left: 0.4rem; + color: var(--ink-3); + font-weight: 400; + text-decoration: none; + opacity: 0; +} + +.doc :is(h2, h3, h4):hover .anchor { opacity: 1; } + +/* Zero specificity beyond .doc, so generated components can override these without !important. */ +.doc :where(p, ul, ol) { margin: 0 0 16px; } +.doc :where(ul, ol) { padding-left: 1.35rem; } +.doc :where(li + li) { margin-top: 6px; } +.doc li::marker { color: var(--ink-3); } + +.doc a { + text-decoration-line: underline; + text-decoration-color: var(--link-line); + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +.doc a:hover { color: var(--link-hover); text-decoration-color: currentColor; } + +.doc :not(pre) > code { + padding: 0.1em 0.38em; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-2); + font-family: var(--font-mono); + font-size: 0.85em; + overflow-wrap: anywhere; + white-space-collapse: preserve; +} + +.doc strong { font-weight: 650; } + +.doc-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 20px; + margin: 0 0 32px; + padding-bottom: 22px; + border-bottom: 1px solid var(--line); + color: var(--ink-3); + font-size: 0.88rem; +} + +.doc-meta time { font-family: var(--font-mono); font-size: 0.84rem; font-variant-numeric: tabular-nums; } + +.callout { + padding: 12px 16px; + border: 1px solid var(--callout-line); + border-radius: 8px; + background: var(--callout-bg); +} + +/* Per-tool support card generated from tools.toml. Narrow screens: a strip under the + heading. Wide screens (see below): a sticky card in the margin beside its section. */ +.tool-sec { position: relative; } + +.tool-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 28px; + margin: 0 0 22px; + padding: 10px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); +} + +.tc-title { display: none; } + +.tc-support { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 0.88rem; + font-weight: 650; +} + +.tc-native { color: var(--ok); } +.tc-partial { color: var(--warn); } +.tc-third-party { color: var(--info); } + +.tc-rows { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; + margin: 0; +} + +.tc-rows > div { display: grid; gap: 1px; min-width: 0; } + +.tc-rows dt { + color: var(--ink-3); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.tc-rows dt:is([title], [data-tip]) { + text-decoration: underline dotted var(--line-strong); + text-underline-offset: 3px; +} + +.tc-rows dd { margin: 0; font-size: 0.9rem; } +.tool-card .mono { font-family: var(--font-mono); font-size: 0.86rem; font-variant-numeric: tabular-nums; } +.tc-note { flex: 1 1 14rem; } +.tc-note dd { color: var(--ink-2); } + +@media (min-width: 1200px) { + :root { --doc-w: calc(var(--measure) + var(--rail-gap) + var(--rail-w)); } + + /* Reserve a margin rail to the right of the reading measure. */ + .doc { + max-width: var(--doc-w); + padding-right: calc(var(--rail-gap) + var(--rail-w)); + } + + /* The rail spans the whole section, so the sticky card inside it stops at the section end. */ + .tool-rail { + position: absolute; + top: 6px; + bottom: 0; + left: calc(100% + var(--rail-gap)); + width: var(--rail-w); + } + + + .tool-card { + position: sticky; + top: calc(var(--header-h) + var(--safe-top) + 28px); + display: block; + margin: 0; + padding: 14px 0 0; + border: 0; + border-top: 1px solid var(--line-strong); + border-radius: 0; + background: none; + } + + .tc-title { + display: block; + margin: 0 0 6px; + color: var(--ink); + font-size: 1rem; + font-weight: 700; + line-height: 1.3; + } + + .tc-rows { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 9px 18px; + margin-top: 14px; + } + + .tc-rows > div { display: contents; } + .tc-rows dt { padding-top: 3px; } + .tc-note dt, .tc-note dd { grid-column: 1 / -1; } + .tc-note dt { margin-top: 2px; } + .tc-note dd { margin-top: -5px; font-size: 0.86rem; line-height: 1.45; } + + /* Tables may use the slack beside the reading measure; outside tool sections, also the rail's width. */ + .doc .table-wrap { margin-left: -40px; } + .doc > .table-wrap, + .doc > .matrix > .table-wrap { margin-right: calc(-1 * (var(--rail-gap) + var(--rail-w))); } +} + +/* Tools of an ecosystem, under its heading. */ +.eco-index { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.eco-index li { margin: 0; } + +.doc .eco-index a { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 2px 11px 2px 10px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--surface); + color: var(--ink-2); + font-size: 0.86rem; + text-decoration: none; +} + +.doc .eco-index a:hover { border-color: var(--link); color: var(--ink); } + +/* Code blocks */ +.code { + margin: 16px 0 20px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--code-bg); +} + +.code-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 6px 0 14px; + border-bottom: 1px solid var(--line); + background: var(--code-head); +} + +.code-lang { + color: var(--ink-3); + font: 600 0.7rem var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.code pre { + margin: 0; + padding: 12px 16px 14px; + overflow-x: auto; + color: var(--ink); + font: 0.84rem/1.6 var(--font-mono); + tab-size: 4; +} + +.code pre:focus-visible { outline-offset: -2px; } +.code code { font: inherit; } + +.copy-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ink-3); + font: 600 0.74rem var(--font-sans); + cursor: pointer; +} + +.copy-btn:hover { background: var(--hover-bg); color: var(--ink); } + +/* Tables */ +.table-wrap { + margin: 16px 0 24px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.table-wrap:focus-visible { outline-offset: 2px; } + +.doc table { + width: 100%; + min-width: 36rem; + border-collapse: collapse; + font-size: 0.88rem; + line-height: 1.5; +} + +.doc th, +.doc td { + padding: 8px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.doc thead th { + background: var(--surface-2); + color: var(--ink-3); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.doc tbody:last-child tr:last-child > * { border-bottom: 0; } + +/* Support matrix (replaces the README quick reference table). */ +.legend { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + margin: 4px 0 12px; + padding: 0; + list-style: none; +} + +.legend li { + display: flex; + align-items: center; + gap: 7px; + margin: 0; +} + +.legend-n { + color: var(--ink-2); + font: 600 0.85rem var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.matrix-table tr.group th { + padding: 7px 12px 6px; + background: var(--bg-side); + color: var(--ink-3); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.doc .matrix-table tr.group a { color: inherit; text-decoration: none; } +.doc .matrix-table tr.group a:hover { color: var(--link-hover); } + +.matrix-table th[scope="row"] { font-weight: 600; white-space: nowrap; } +.doc .matrix-table th[scope="row"] a { color: var(--ink); text-decoration-color: var(--line-strong); } +.doc .matrix-table th[scope="row"] a:hover { color: var(--link-hover); text-decoration-color: currentColor; } +.matrix-table .m-support { color: var(--ink-2); min-width: 11rem; } +.matrix-table tbody tr:not(.group):hover { background: var(--hover-bg); } +.doc .matrix-table .m-config code { white-space: nowrap; overflow-wrap: normal; } + +/* Changelog */ +.doc details { + margin: 16px 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.doc summary { + padding: 12px 16px; + border-radius: 10px; + cursor: pointer; + font-weight: 650; +} + +.doc details > ul { + margin: 0; + padding: 4px 20px 18px 36px; + font-size: 0.92rem; +} + +.doc details li strong { + font-family: var(--font-mono); + font-size: 0.86rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.notfound { padding-block: 80px 120px; } + +/* ---------- Footer ---------- */ + +.site-footer { + padding-block: 24px 32px; + padding-left: var(--gutter-l); + padding-right: var(--gutter-r); + border-top: 1px solid var(--line); + color: var(--ink-3); + font-size: 0.85rem; +} + +/* On the docs page the footer sits in the content column, aligned with the text. */ +.layout > .site-footer { + grid-area: footer; + min-width: 0; + padding-inline: 48px; +} + +.layout > .site-footer .footer-inner { max-width: var(--doc-w); } + +.footer-inner { + max-width: 1480px; + margin: 0 auto; +} + +/* ---------- Medium: slimmer sidebar ---------- */ + +@media (max-width: 1279px) { + .layout { grid-template-columns: 16rem minmax(0, 1fr); } +} + +/* ---------- Narrow: nav becomes a drawer ---------- */ + +@media (max-width: 959px) { + .layout { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "main" "footer"; + } + + html:not(.js) .layout { grid-template-areas: "nav" "main" "footer"; } + + html:not(.js) .sidebar { + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .js .sidebar { + position: fixed; + top: calc(var(--header-h) + var(--safe-top)); + bottom: 0; + left: 0; + z-index: 45; + width: min(20rem, 86vw); + height: auto; + padding-left: env(safe-area-inset-left, 0px); + transform: translateX(-100%); + visibility: hidden; + transition: transform 0.22s ease, visibility 0s linear 0.22s; + } + + .drawer-open .sidebar { + transform: none; + visibility: visible; + box-shadow: var(--shadow-pop); + transition: transform 0.22s ease; + } + + .backdrop { + position: fixed; + inset: calc(var(--header-h) + var(--safe-top)) 0 0; + z-index: 44; + background: var(--backdrop); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0s linear 0.2s; + } + + .drawer-open .backdrop { opacity: 1; visibility: visible; transition: opacity 0.2s; } + .drawer-open body { overflow: hidden; } + + main { padding: 28px var(--gutter-r) 72px var(--gutter-l); } + .layout > .site-footer { padding-left: var(--gutter-l); padding-right: var(--gutter-r); } + .doc h2 { margin-top: 56px; padding-top: 28px; font-size: 1.5rem; } + .doc h3 { margin-top: 36px; } +} + +@media (min-width: 960px) { + .menu-btn, .backdrop { display: none; } +} + +/* ---------- Phone: matrix rows stack ---------- */ + +@media (max-width: 639px) { + .matrix .table-wrap { overflow: visible; } + .doc .matrix-table { min-width: 0; } + .matrix-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } + .matrix-table tbody, .matrix-table tr.group, .matrix-table tr.group th { display: block; } + + .matrix-table tr:not(.group) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); + } + + .doc .matrix-table tr:not(.group) > * { padding: 0; border: 0; } + .matrix-table .m-support, .matrix-table .m-config { grid-column: 1 / -1; min-width: 0; } + .doc tbody:last-child tr:last-child { border-bottom: 0; } + .tool-card, .tc-rows { gap: 8px 22px; } +} + +.tooltip { + position: fixed; + z-index: 70; + max-width: calc(100vw - 1rem); + padding: 4px 8px; + overflow: hidden; + border-radius: 5px; + background: var(--ink); + color: var(--bg); + font-size: 0.75rem; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0ms; +} + +.tooltip[data-show="true"] { + opacity: 1; + transition: opacity 120ms ease-out; +} + +.tooltip[data-show="true"][data-instant="true"] { transition-duration: 0ms; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css deleted file mode 100644 index 9ab16f1..0000000 --- a/docs/stylesheets/extra.css +++ /dev/null @@ -1,84 +0,0 @@ -.md-search { - display: none; -} - -/* Same gradient in both schemes, so pin the foreground to white. */ -.md-header { - background: linear-gradient(90deg, #3d0f35 0%, #b3651d 100%); - color: #fff; -} - -/* Black-stroked SVG loaded as an . */ -.md-header .md-logo img, -[data-md-color-scheme="slate"] .md-logo img { - filter: invert(1); -} - -/* Zensical's slate background is near black; soften it. */ -[data-md-color-scheme="slate"] { - --md-default-bg-color: #232529; - --md-default-bg-color--light: #2325298a; - --md-default-bg-color--lighter: #23252942; - --md-default-bg-color--lightest: #23252912; - --md-code-bg-color: #2f3137; - --md-code-bg-color--light: #2f3137e6; - --md-code-bg-color--lighter: #2f31378a; - --md-footer-bg-color: #1a1b1ede; - --md-footer-bg-color--dark: #16171a; -} - -/* Amber links glare on dark and fail contrast on white; accent is the hover - color. The body prefix outranks [data-md-color-primary=amber]. */ -body[data-md-color-scheme="slate"] { - --md-typeset-a-color: #d9a441; - --md-accent-fg-color: #f0d298; - --md-accent-fg-color--transparent: #f0d2981a; - --md-accent-bg-color: #000000de; -} - -body[data-md-color-scheme="default"] { - --md-typeset-a-color: #9a6a12; - --md-accent-fg-color: #7a4a0c; - --md-accent-fg-color--transparent: #7a4a0c1a; - --md-accent-bg-color: #ffffff; -} - -/* Title duplicates the header banner. Kept in the DOM for the outline and the - "Skip to content" target. */ -.md-content__inner > h1:first-child { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -/* Site name and page title are identical, so don't swap them on scroll. */ -.md-header__topic + .md-header__topic { - display: none; -} - -.md-header__title--active .md-header__topic { - z-index: 0; - pointer-events: auto; - opacity: 1; - transform: none; -} - -/* Close the gap the hidden title leaves, and align content with the TOC. */ -.md-main__inner { - margin-top: 0.4rem; -} - -.md-content__inner > h1:first-child + * { - margin-top: 0; -} - -.md-sidebar--secondary { - padding-top: 0.9rem; -} - -code { - white-space-collapse: preserve; -} diff --git a/docs/templates/404.html.j2 b/docs/templates/404.html.j2 new file mode 100644 index 0000000..e5c5506 --- /dev/null +++ b/docs/templates/404.html.j2 @@ -0,0 +1,12 @@ +{% extends "base.html.j2" %} +{% set title = "Page not found · " ~ site.name %} +{% block layout %} +

+
+

Page not found

+

Nothing lives at this address. The whole guide is on a single page.

+

Go to the guide or jump to the support matrix.

+
+
+{{ ui.footer() }} +{% endblock %} diff --git a/docs/templates/base.html.j2 b/docs/templates/base.html.j2 new file mode 100644 index 0000000..ba25b67 --- /dev/null +++ b/docs/templates/base.html.j2 @@ -0,0 +1,49 @@ +{% import "partials.html.j2" as ui %} + + + + + +{{ title }} + + + + + + + + + + + + + + + +{# If app.js can't load, drop back to the no-JS layout instead of a menu that never opens. #} + + + + + +{% block layout %}{% endblock %} +
+ + diff --git a/docs/templates/page.html.j2 b/docs/templates/page.html.j2 new file mode 100644 index 0000000..e5f795e --- /dev/null +++ b/docs/templates/page.html.j2 @@ -0,0 +1,51 @@ +{% extends "base.html.j2" %} +{% set nav_page = true %} +{% block layout %} + +
+ +
+
+{{ body }} +
+
+ {# Inside the grid so the sticky sidebar's container reaches the page bottom; otherwise + the footer pushes the sidebar's top under the header at the end of the page. #} + {{ ui.footer() }} +
+{% endblock %} diff --git a/docs/templates/partials.html.j2 b/docs/templates/partials.html.j2 new file mode 100644 index 0000000..5b91be3 --- /dev/null +++ b/docs/templates/partials.html.j2 @@ -0,0 +1,109 @@ +{# Small components shared by the page templates and inserted into the README body by build.py. #} + +{% macro icon(name) %} + +{% endmacro %} + +{# Star and fork counts are filled in by app.js from the GitHub API. #} +{% macro repo_link() %} + + {{ icon("github") }} + + +{% endmacro %} + +{% macro footer() %} + +{% endmacro %} + +{% macro dot(support) %}{% endmacro %} + +{% macro chip(support) %}{{ dot(support) }}{{ labels[support] }}{% endmacro %} + +{% macro hero_meta(updated) %} +

+ {% if updated %}Updated {% endif %} + Support matrix + Bypassing cooldowns + Changelog +

+{% endmacro %} + +{% macro eco_index(eco) %} + +{% endmacro %} + +{# Support card for a tool section: a strip under the heading on narrow screens, + a sticky card in the section's margin rail on wide ones. #} +{% macro tool_card(t) %} +
+
+ +

{{ dot(t.support) }}{{ labels[t.support] }} support

+
+ {% if t.since %}
Since
{{ t.since }}
{% endif %} +
Default
{{ t.default or "None" }}
+ {% if t.unit %}
Unit
{{ t.unit }}
{% endif %} + {% if t.three_days %}
3 days
{{ t.three_days }}
{% endif %} + {% if t.note %}
Note
{{ t.note }}
{% endif %} +
+
+
+{% endmacro %} + +{% macro nav_item(item, dots=false) %} + +{% endmacro %} + +{% macro matrix(head, groups, counts) %} +
+
    +{% for support, n in counts %} +
  • {{ chip(support) }}{{ n }}
  • +{% endfor %} +
+
+ + +{% for g in groups %} + + +{% for r in g.rows %} + +{% endfor %} + +{% endfor %} +
{{ head[0] }}Status{{ head[1] }}{{ head[2] }}
{{ g.name }}
{{ r.name }}{{ chip(r.tool.support) }}{{ r.support }}{{ r.config }}
+
+
+{% endmacro %} diff --git a/docs/tools.toml b/docs/tools.toml new file mode 100644 index 0000000..1b84d3a --- /dev/null +++ b/docs/tools.toml @@ -0,0 +1,364 @@ +# Metadata that complements README.md for the site build (build.py). +# +# README.md stays the source of truth for prose and config snippets. This file +# only classifies things so the site can build the sidebar, the per-tool support +# cards, and the support matrix. Each `anchor` is a heading id in README.md. +# Every [[tool]] must match one row of README's quick reference table, and every +# H2 section must belong to an [[ecosystem]] or a [[group]]; the build checks both. +# +# support values: +# native built-in, configurable cooldown +# partial built-in but limited (e.g. update delay only, unstable/nightly) +# third-party only via an external tool or wrapper +# none no cooldown support +# +# Optional fields: since (first version with support), default (cooldown that +# applies out of the box; shown as "None" when absent), note (short qualifier), +# unit (what the cooldown value counts), three_days (a three-day cooldown +# written in that tool's syntax; the build checks it appears in the tool's +# README section), table_name (row name in the README quick reference table +# when it differs from `name`). + +[[ecosystem]] +id = "python" +name = "Python" +anchor = "python-ecosystem" + +[[ecosystem]] +id = "javascript" +name = "JavaScript" +anchor = "javascript-ecosystem" + +[[ecosystem]] +id = "rust" +name = "Rust" +anchor = "rust-ecosystem" + +[[ecosystem]] +id = "ruby" +name = "Ruby" +anchor = "ruby-ecosystem" + +[[ecosystem]] +id = "elixir" +name = "Elixir" +anchor = "elixir-ecosystem" + +[[ecosystem]] +id = "jvm" +name = "Scala / JVM" +anchor = "scala-jvm-ecosystem" + +[[ecosystem]] +id = "php" +name = "PHP" +anchor = "php-ecosystem" + +[[ecosystem]] +id = "tool-managers" +name = "Tool managers" +anchor = "tool-managers" + +[[ecosystem]] +id = "ide" +name = "IDE extensions" +anchor = "ide-extensions" + +[[ecosystem]] +id = "github-actions" +name = "GitHub Actions" +anchor = "github-actions" + +[[ecosystem]] +id = "other" +name = "Other ecosystems" +anchor = "other-ecosystems" + +# --- Python --- + +[[tool]] +name = "uv" +ecosystem = "python" +anchor = "uv" +support = "native" +since = "0.9.17" +unit = "duration" +three_days = '"3 days"' + +[[tool]] +name = "pip" +ecosystem = "python" +anchor = "pip" +support = "native" +since = "26.1" +note = "Older versions accept absolute timestamps only" +unit = "ISO 8601" +three_days = "P3D" + +[[tool]] +name = "pipenv" +ecosystem = "python" +anchor = "pipenv" +support = "native" +since = "2026.6.2" +unit = "days" +three_days = '"3d"' + +[[tool]] +name = "pipx" +ecosystem = "python" +anchor = "pipx" +support = "native" +since = "1.16.0" +unit = "days" +three_days = "3" + +[[tool]] +name = "Poetry" +ecosystem = "python" +anchor = "poetry" +support = "native" +since = "2.4.0" +unit = "days" +three_days = "3" + +[[tool]] +name = "PDM" +ecosystem = "python" +anchor = "pdm" +support = "native" +since = "2.26.9" +unit = "duration" +three_days = '"3d"' + +[[tool]] +name = "conda" +ecosystem = "python" +anchor = "conda" +support = "native" +since = "26.9.0" +note = "Classic solver only" +unit = "duration" +three_days = "3d" + +[[tool]] +name = "micromamba" +ecosystem = "python" +anchor = "micromamba" +support = "native" +since = "2.10.0" +unit = "duration" +three_days = "3d" + +[[tool]] +name = "pixi" +ecosystem = "python" +anchor = "pixi" +support = "native" +since = "0.67.0" +unit = "duration" +three_days = '"3d"' + +# --- JavaScript --- + +[[tool]] +name = "npm" +ecosystem = "javascript" +anchor = "npm-javascriptnodejs" +support = "native" +since = "11.10.0" +unit = "days" +three_days = "3" + +[[tool]] +name = "pnpm" +ecosystem = "javascript" +anchor = "pnpm-javascriptnodejs" +support = "native" +since = "10.16.0" +default = "1 day (v11+)" +unit = "minutes" +three_days = "4320" + +[[tool]] +name = "Yarn" +ecosystem = "javascript" +anchor = "yarn-javascriptnodejs" +support = "native" +since = "4.10.0" +default = "1 day (4.15.0+)" +unit = "duration" +three_days = '"3d"' + +[[tool]] +name = "Bun" +ecosystem = "javascript" +anchor = "bun-javascriptnodejs" +support = "native" +since = "1.3" +unit = "seconds" +three_days = "259200" + +[[tool]] +name = "Deno" +ecosystem = "javascript" +anchor = "deno-javascripttypescript" +support = "native" +since = "2.6" +default = "24 hours (2.9+)" +unit = "ISO 8601 or minutes" +three_days = '"P3D"' + +[[tool]] +name = "npm-check-updates" +ecosystem = "javascript" +anchor = "npm-check-updates-javascriptnodejs" +support = "native" +since = "18.2.0" +unit = "days" +three_days = "3" + +# --- Rust --- + +[[tool]] +name = "Cargo" +ecosystem = "rust" +anchor = "cargo" +support = "partial" +note = "Unstable on nightly; cargo-cooldown wrapper on stable" +unit = "duration" +three_days = '"3 days"' + +# --- Ruby --- + +[[tool]] +name = "Bundler" +ecosystem = "ruby" +anchor = "bundler" +support = "native" +since = "4.0.13" +unit = "days" +three_days = "3" + +# --- Elixir --- + +[[tool]] +name = "Hex" +ecosystem = "elixir" +anchor = "hex" +support = "native" +since = "2.5.0" +unit = "duration" +three_days = '"3d"' + +# --- Scala / JVM --- + +[[tool]] +name = "Scala Steward" +ecosystem = "jvm" +anchor = "scala-steward" +support = "native" +since = "0.38.0" +unit = "duration" +three_days = '"3 days"' + +# --- PHP --- + +[[tool]] +name = "Composer" +ecosystem = "php" +anchor = "composer" +support = "native" +since = "2.11.0" +unit = "duration or seconds" +three_days = '"3 days"' + +# --- Tool managers --- + +[[tool]] +name = "mise" +ecosystem = "tool-managers" +anchor = "mise" +support = "native" +default = "24 hours (2026.6.2+)" +unit = "duration" +three_days = '"3d"' + +# --- IDE extensions --- + +[[tool]] +name = "VS Code" +ecosystem = "ide" +anchor = "vs-code" +support = "partial" +since = "1.125" +default = "12 hours" +note = "Delays automatic updates only" +unit = "hours" +three_days = "72" + +[[tool]] +name = "Cursor" +ecosystem = "ide" +anchor = "cursor" +support = "native" +note = "Delays installs and updates" +unit = "hours" +three_days = "72" + +# --- GitHub Actions --- + +[[tool]] +name = "actions-up" +ecosystem = "github-actions" +anchor = "actions-up" +table_name = "GitHub Actions" +support = "third-party" +since = "1.6.0" +default = "1 day (1.16.0+)" +unit = "days" +three_days = "3" + +# --- No native support --- + +[[tool]] +name = "Go" +ecosystem = "other" +anchor = "other-ecosystems" +support = "none" + +[[tool]] +name = "Maven / Gradle" +ecosystem = "other" +anchor = "other-ecosystems" +support = "none" +note = "Scala Steward can cover Maven projects" + +[[tool]] +name = "NuGet" +ecosystem = "other" +anchor = "other-ecosystems" +support = "none" + +# --- Sidebar groups for sections that are not tools --- +# +# Every h1/h2 heading in README.md must be listed here or be an ecosystem +# anchor; the build fails otherwise. `ecosystems = true` marks where the +# ecosystem groups go. `label` overrides the heading text in the sidebar. + +[[group]] +name = "Overview" +anchors = ["dependency-cooldowns", "does-it-actually-work"] +label = { dependency-cooldowns = "Introduction" } + +[[group]] +name = "Tools by ecosystem" +ecosystems = true + +[[group]] +name = "Beyond package managers" +anchors = ["dependency-update-bots", "security-scanners-and-pr-checks", "registry-level-proxies", "container-images"] + +[[group]] +name = "Reference" +anchors = ["cooldownssh", "quick-reference", "bypassing-cooldowns", "faq", "conclusion", "changelog"] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..ee58ac0 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,18 @@ +# Lint and format settings for the Python build script (build.py). +# Run with `uv run --with ruff ruff check` and `uv run --with ruff ruff format`. +line-length = 120 +target-version = "py314" +# Python files only: ruff would otherwise also reformat the Python examples in README.md. +include = ["*.py"] + +[lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # import sorting + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "RUF", # ruff-specific rules +] diff --git a/zensical.toml b/zensical.toml deleted file mode 100644 index 8949777..0000000 --- a/zensical.toml +++ /dev/null @@ -1,84 +0,0 @@ -[project] -site_name = "Dependency Cooldowns" -site_url = "https://cooldowns.dev" -site_description = "A guide to configuring dependency cooldowns across package managers to protect against supply chain attacks." -site_author = "Martin Prpič" -repo_url = "https://github.com/mprpic/cooldowns" -extra_css = ["stylesheets/extra.css"] -extra_javascript = ["js/no-search.js"] -watch = ["README.md"] - -[[project.extra.social]] -icon = "fontawesome/brands/github" -link = "https://github.com/mprpic/cooldowns" - -[project.theme] -features = [ - "content.code.copy" -] -favicon = "images/shield.svg" -logo = "images/shield.svg" - -# Palette toggle for automatic mode -[[project.theme.palette]] -primary = "amber" -media = "(prefers-color-scheme)" -toggle.icon = "lucide/sun-moon" -toggle.name = "Switch to light mode" - -# Palette toggle for light mode -[[project.theme.palette]] -primary = "amber" -media = "(prefers-color-scheme: light)" -scheme = "default" -toggle.icon = "lucide/sun" -toggle.name = "Switch to dark mode" - -# Palette toggle for dark mode -[[project.theme.palette]] -primary = "amber" -media = "(prefers-color-scheme: dark)" -scheme = "slate" -toggle.icon = "lucide/moon" -toggle.name = "Switch to system preference" - -# Declaring any markdown extension replaces all zensical defaults, so the -# full default set must be re-declared here (from `zensical new` output). -[project.markdown_extensions.abbr] -[project.markdown_extensions.admonition] -[project.markdown_extensions.attr_list] -[project.markdown_extensions.def_list] -[project.markdown_extensions.footnotes] -[project.markdown_extensions.md_in_html] -[project.markdown_extensions.toc] -permalink = true -[project.markdown_extensions.pymdownx.arithmatex] -generic = true -[project.markdown_extensions.pymdownx.betterem] -[project.markdown_extensions.pymdownx.caret] -[project.markdown_extensions.pymdownx.details] -[project.markdown_extensions.pymdownx.emoji] -emoji_generator = "zensical.extensions.emoji.to_svg" -emoji_index = "zensical.extensions.emoji.twemoji" -[project.markdown_extensions.pymdownx.highlight] -anchor_linenums = true -line_spans = "__span" -pygments_lang_class = true -[project.markdown_extensions.pymdownx.inlinehilite] -[project.markdown_extensions.pymdownx.keys] -[project.markdown_extensions.pymdownx.magiclink] -[project.markdown_extensions.pymdownx.mark] -[project.markdown_extensions.pymdownx.smartsymbols] -[project.markdown_extensions.pymdownx.superfences] -custom_fences = [ - { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" } -] -[project.markdown_extensions.pymdownx.tabbed] -alternate_style = true -combine_header_slug = true -[project.markdown_extensions.pymdownx.tasklist] -custom_checkbox = true -[project.markdown_extensions.pymdownx.tilde] - -# Include README.md in docs/index.md without duplicating content. -[project.markdown_extensions.pymdownx.snippets]