From 65093815a8c8c3c364b15938e2e8568948d5863e Mon Sep 17 00:00:00 2001 From: oskar Date: Tue, 4 Aug 2026 17:57:15 +0200 Subject: [PATCH] feat(kb): generator publicznej warstwy KB (gen_pages.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/kb/gen_pages.py — kb/**/*.md -> build/kb-site/ (index.html pogrupowany per type + strona na dokument). Renderer markdown na samej bibliotece standardowej, wzorowany na ~/narty-2027/saalbach-kb/gen_pages.py; parser frontmattera wspoldzielony z check_okf.py, zeby lint i generator widzialy frontmatter tak samo. Kwalifikacja fail-closed: publikowany jest wylacznie dokument z jawnym `visibility: public`. Brak frontmattera, niepoprawny YAML, brak pola albo inna wartosc = private. Linki do dokumentow nieopublikowanych nie sa renderowane jako linki — zostaje etykieta z dopiskiem [private]; render_inline ma druga bramke (linkuje tylko http/mailto/kotwice/.html), wiec martwy odnosnik nie ma jak przeciec na strone. BASE_URL jest parametrem (--base-url, domyslnie https://kb.okit.pl) i trafia do . Stopka kazdej strony: data generacji + git rev-parse --short HEAD. check_okf.py: EXCLUDE_DIRS = ("build",) — wyjscie generatora nie jest zrodlem i nie podlega lintowi. build/ dopisany do .gitignore. Uruchomione lokalnie: 150 dokumentow kb/, 10 public, 140 pominietych. --- .gitignore | 2 + scripts/kb/check_okf.py | 13 +- scripts/kb/gen_pages.py | 657 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 scripts/kb/gen_pages.py diff --git a/.gitignore b/.gitignore index 2a6a877..66fc9dc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ venv/ *.egg-info/ packages/*/build/ jobs/*/build/ +# wyjscie generatorow (scripts/kb/gen_pages.py -> build/kb-site/) — artefakt, nie zrodlo +build/ # Tools .aider* diff --git a/scripts/kb/check_okf.py b/scripts/kb/check_okf.py index f19dbdd..bbf96e7 100755 --- a/scripts/kb/check_okf.py +++ b/scripts/kb/check_okf.py @@ -21,7 +21,8 @@ reguły tego repo: Zakres domyślny: kb/ oraz docs/sessions/, z wyłączeniem README-wskaźników (POINTER_GLOBS) — te są nawigacją do kb-doca, nie dokumentami KB, i celowo nie -mają frontmattera OKF. Reszta repo (CLAUDE.md, README.md, .claude/skills/ itd.) +mają frontmattera OKF — oraz katalogów z artefaktami (EXCLUDE_DIRS: build/). +Reszta repo (CLAUDE.md, README.md, .claude/skills/ itd.) leży poza bazą wiedzy i nie podlega walidacji. Tylko biblioteka standardowa: minimalny parser YAML wystarczający dla @@ -64,6 +65,11 @@ POINTER_GLOBS = ( "hosts/*/README.md", ) +# Katalogi z artefaktami generatorow (build/kb-site/ z scripts/kb/gen_pages.py). +# Wyjscie generatora nie jest zrodlem — nie ma podlegac lintowi ani teraz, ani +# gdyby SCOPE kiedys sie poszerzyl. +EXCLUDE_DIRS = ("build",) + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -71,6 +77,10 @@ def is_pointer(rel: str) -> bool: return any(PurePosixPath(rel).match(pat) for pat in POINTER_GLOBS) +def is_excluded(rel: str) -> bool: + return any(part in EXCLUDE_DIRS for part in PurePosixPath(rel).parts) + + def split_frontmatter(text: str) -> tuple[str | None, str]: """Zwraca (blok frontmattera lub None, reszta dokumentu).""" if not text.startswith("---\n"): @@ -140,6 +150,7 @@ def scope_files(root: Path) -> list[Path]: p for p in base.rglob("*.md") if ".git" not in p.parts and not is_pointer(p.relative_to(root).as_posix()) + and not is_excluded(p.relative_to(root).as_posix()) ) return sorted(set(files)) diff --git a/scripts/kb/gen_pages.py b/scripts/kb/gen_pages.py new file mode 100644 index 0000000..72d1a32 --- /dev/null +++ b/scripts/kb/gen_pages.py @@ -0,0 +1,657 @@ +#!/usr/bin/env python3 +"""Generator publicznej warstwy bazy wiedzy (kb.okit.pl) z dokumentów OKF v0.1. + +Wzorzec: ~/narty-2027/saalbach-kb/gen_pages.py — renderer markdown na samej +bibliotece standardowej plus prosty, czytelny szablon HTML. Tutaj dochodzi +jedna reguła nadrzędna nad wszystkim innym: + + PUBLIKUJEMY WYŁĄCZNIE DOKUMENTY Z `visibility: public`. + +Kwalifikacja jest **fail-closed** — dokument trafia na stronę tylko wtedy, gdy +da się jednoznacznie stwierdzić, że jest publiczny. Brak frontmattera, niepoprawny +YAML, brak pola `visibility`, pusta albo nierozpoznana wartość = PRIVATE. Każdy +inny wariant (np. „domyślnie public, gdy nic nie napisano") oznaczałby, że +przeoczony frontmatter wypycha wewnętrzny dokument do internetu. + +Ta sama reguła obowiązuje linki: odnośnik do dokumentu, który nie został +opublikowany, NIE jest renderowany jako link — zostaje sam tekst etykiety +z dopiskiem `[private]`. Dzięki temu strona publiczna nigdy nie wskazuje +ścieżek prywatnych dokumentów ani nie generuje martwych 404. + +Wejście: kb/**/*.md +Wyjście: build/kb-site/ + index.html spis stron pogrupowany per `type` + /.html strona na dokument (lustro drzewa kb/) + +Stopka każdej strony: data generacji + krótki hash commita (`git rev-parse --short HEAD`). + +Tylko biblioteka standardowa (jak scripts/npm/npm_api.py) — skrypt uruchamiany +doraźnie z SATURN/SOLARIA, bez własnego obrazu i bez `pip install`. Parser +frontmattera jest współdzielony z check_okf.py, żeby obie ścieżki widziały +frontmatter dokładnie tak samo. + +Uruchomienie: + python3 scripts/kb/gen_pages.py [--base-url URL] [--out KATALOG] +""" + +from __future__ import annotations + +import argparse +import html +import posixpath +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +# Ten sam parser frontmattera co walidator OKF — check_okf.py leży w tym samym +# katalogu, więc import działa niezależnie od katalogu uruchomienia. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_okf import parse_yaml, split_frontmatter # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[2] +KB_DIR = REPO_ROOT / "kb" +DEFAULT_OUT = REPO_ROOT / "build" / "kb-site" +DEFAULT_BASE_URL = "https://kb.okit.pl" + +SITE_NAME = "homelab-codex — knowledge base" +SITE_LEAD = ( + "Public slice of the homelab knowledge base. Only documents marked " + "visibility: public are published here; everything else stays " + "in the private repository." +) + +PUBLIC = "public" + +# Nagłówki grup na stronie spisu. Kolejność listy = kolejność sekcji; typy spoza +# listy trafiają na koniec, alfabetycznie, pod własną nazwą. +TYPE_ORDER = [ + ("subsystem", "Subsystems"), + ("service", "Services"), + ("node", "Nodes"), + ("runbook", "Runbooks"), + ("decision", "Decisions"), + ("incident", "Incidents"), + ("phase", "Phases"), + ("audit", "Audits"), + ("session-log", "Session logs"), +] + + +# --- model ------------------------------------------------------------ + + +@dataclass +class Doc: + id: str # ścieżka względem kb/ bez rozszerzenia, np. "subsystems/observer" + path: Path + frontmatter: dict + body: str + public: bool + + @property + def page(self) -> str: + """Ścieżka strony względem katalogu wyjściowego.""" + return f"{self.id}.html" + + @property + def doc_type(self) -> str: + return str(self.frontmatter.get("type") or "").strip() or "?" + + @property + def title(self) -> str: + heading = first_heading(self.body) + return heading or self.id.rsplit("/", 1)[-1].replace("-", " ") + + +# Nie każdy dokument zaczyna się od `#` — np. kb/subsystems/action-approval-model.md +# otwiera się `###`. Tytułem strony jest pierwszy nagłówek DOWOLNEGO poziomu. +_ANY_HEADING_RE = re.compile(r"^#{1,6}\s+(.+)$", re.M) + + +def first_heading(body: str) -> str | None: + match = _ANY_HEADING_RE.search(body) + return match.group(1).strip() if match else None + + +def is_public(frontmatter: dict | None) -> bool: + """Fail-closed: publiczny jest wyłącznie dokument z jawnym `visibility: public`.""" + if not isinstance(frontmatter, dict): + return False + return str(frontmatter.get("visibility", "")).strip() == PUBLIC + + +def load_docs() -> list[Doc]: + docs: list[Doc] = [] + for path in sorted(KB_DIR.rglob("*.md")): + if ".git" in path.parts: + continue + text = path.read_text(encoding="utf-8") + frontmatter: dict = {} + body = text + try: + block, rest = split_frontmatter(text) + if block is not None: + # Niepoprawny YAML => frontmatter nieznany => dokument prywatny. + frontmatter = parse_yaml(block) + body = rest + except ValueError: + frontmatter = {} + body = text + docs.append( + Doc( + id=path.relative_to(KB_DIR).with_suffix("").as_posix(), + path=path, + frontmatter=frontmatter, + body=body.lstrip("\n"), + public=is_public(frontmatter), + ) + ) + return docs + + +# --- linki ------------------------------------------------------------ + +# Etykieta linku bywa złamana na dwie linie (np. kb/decisions/ai-cluster-legacy.md), +# więc klasa [^\]] musi łapać też znak nowej linii — domyślnie łapie. +_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)((?:#[^)\s]*)?)\)") + +_EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "#") + + +def rewrite_links(doc: Doc, published: dict[str, str]) -> str: + """Linki wewnątrz-repowe → strony; wszystko nieopublikowane → zwykły tekst. + + `published` mapuje id dokumentu (ścieżka względem kb/ bez rozszerzenia) na + ścieżkę wygenerowanej strony. Cel spoza tej mapy jest z definicji niepubliczny + (private albo w ogóle nie jest dokumentem KB) i traci link. + """ + here = posixpath.dirname(doc.page) + + def repl(match: re.Match) -> str: + label, target, fragment = match.group(1), match.group(2), match.group(3) + if target.startswith(_EXTERNAL_PREFIXES): + return match.group(0) + + if not target.endswith(".md"): + # Odnośnik do pliku repo (compose, yaml, skrypt) — na stronie + # publicznej nie ma czego pokazać, więc zostaje sama etykieta. + return label + + if target.startswith("/"): + rel = target.lstrip("/") + rel = rel[3:] if rel.startswith("kb/") else rel + else: + rel = posixpath.normpath( + posixpath.join(posixpath.dirname(doc.id), target) + ) + doc_id = rel[:-3] + + page = published.get(doc_id) + if page is None: + return f"{label} [private]" + return f"[{label}]({posixpath.relpath(page, here or '.')}{fragment})" + + return _MD_LINK_RE.sub(repl, doc.body) + + +# --- renderer markdown (biblioteka standardowa) ------------------------ + +_CODE_RE = re.compile(r"`([^`]+)`") +_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)") +_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S) +_ITALIC_STAR_RE = re.compile(r"(? bool: + """Druga bramka fail-closed: linkujemy tylko adresy zewnętrzne, kotwice + i wygenerowane strony. Cokolwiek innego przeciekło przez rewrite_links() + (ścieżka repo, .md bez odpowiednika) zostaje tekstem, nie martwym linkiem.""" + return href.startswith(_EXTERNAL_PREFIXES) or href.split("#", 1)[0].endswith(".html") + + +def render_inline(text: str) -> str: + codes: list[str] = [] + + def stash(match: re.Match) -> str: + codes.append(match.group(1)) + return f"\x00c{len(codes) - 1}\x00" + + text = _CODE_RE.sub(stash, text) + text = html.escape(text, quote=False) + + def link(match: re.Match) -> str: + label, href = match.group(1), match.group(2) + if not _linkable(href): + return label + attrs = "" + if href.startswith(("http://", "https://", "mailto:")): + attrs = ' target="_blank" rel="noopener" class="ext"' + return f'{label}' + + text = _LINK_RE.sub(link, text) + text = _BOLD_RE.sub(r"\1", text) + text = _ITALIC_STAR_RE.sub(r"\1", text) + text = _ITALIC_UNDER_RE.sub(r"\1", text) + + for i, code in enumerate(codes): + text = text.replace(f"\x00c{i}\x00", f"{html.escape(code, quote=False)}") + return text + + +def _alignments(sep: str) -> list[str]: + out = [] + for cell in _cells(sep): + left, right = cell.startswith(":"), cell.endswith(":") + out.append("center" if left and right else "right" if right else "left") + return out + + +def _cells(row: str) -> list[str]: + return [c.strip() for c in row.strip().strip("|").split("|")] + + +def _starts_block(line: str) -> bool: + s = line.strip() + return ( + not s + or bool(_HEADING_RE.match(s)) + or bool(_HR_RE.match(s)) + or s.startswith(("```", ">")) + or bool(_LIST_RE.match(line)) + ) + + +def render_blocks(text: str) -> str: + lines = text.split("\n") + out: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped: + i += 1 + continue + + if stripped.startswith("```"): + i += 1 + code: list[str] = [] + while i < len(lines) and not lines[i].strip().startswith("```"): + code.append(lines[i]) + i += 1 + i += 1 + out.append(f"
{html.escape(chr(10).join(code), quote=False)}
") + continue + + heading = _HEADING_RE.match(stripped) + if heading: + level = len(heading.group(1)) + out.append(f"{render_inline(heading.group(2))}") + i += 1 + continue + + if _HR_RE.match(stripped): + out.append("
") + i += 1 + continue + + if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]): + header = _cells(stripped) + align = _alignments(lines[i + 1]) + i += 2 + rows = [] + while i < len(lines) and "|" in lines[i] and lines[i].strip(): + rows.append(_cells(lines[i])) + i += 1 + head = "".join( + f'' + f"{render_inline(c)}" + for n, c in enumerate(header) + ) + body = "".join( + "" + + "".join( + f'' + f"{render_inline(c)}" + for n, c in enumerate(row) + ) + + "" + for row in rows + ) + out.append( + f'
{head}' + f"{body}
" + ) + continue + + if stripped.startswith(">"): + quoted = [] + while i < len(lines) and lines[i].strip().startswith(">"): + quoted.append(lines[i].strip()[1:].lstrip()) + i += 1 + out.append(f"
{render_blocks(chr(10).join(quoted))}
") + continue + + item = _LIST_RE.match(line) + if item: + ordered = item.group(1)[0].isdigit() + items: list[str] = [] + while i < len(lines): + current = lines[i] + match = _LIST_RE.match(current) + if match: + items.append(match.group(2)) + i += 1 + elif current.startswith((" ", "\t")) and current.strip() and items: + items[-1] += " " + current.strip() + i += 1 + else: + break + rendered = [] + for raw in items: + task = _TASK_RE.match(raw) + if task: + checked = " checked" if task.group(1).lower() == "x" else "" + rendered.append( + f'
  • ' + f"{render_inline(task.group(2))}
  • " + ) + else: + rendered.append(f"
  • {render_inline(raw)}
  • ") + tag = "ol" if ordered else "ul" + out.append(f"<{tag}>{''.join(rendered)}") + continue + + paragraph = [stripped] + i += 1 + while i < len(lines) and not _starts_block(lines[i]): + if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]): + break + paragraph.append(lines[i].strip()) + i += 1 + out.append(f"

    {render_inline(' '.join(paragraph))}

    ") + + return "\n".join(out) + + +# --- szablon ---------------------------------------------------------- + +CSS = """ +:root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff; + --line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; } +@media (prefers-color-scheme: dark) { + :root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155; + --accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; } +} +* { box-sizing: border-box; } +body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg); + -webkit-text-size-adjust: 100%; } +nav { font-size: .85rem; margin-bottom: 1.25rem; } +nav a { color: var(--muted); } +h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; } +h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem; + border-top: 1px solid var(--line); } +h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; } +h4, h5, h6 { font-size: .95rem; margin: 1.1rem 0 .3rem; } +p, ul, ol { margin: .6rem 0; } +ul, ol { padding-left: 1.3rem; } +li { margin: .25rem 0; } +li.task { list-style: none; margin-left: -1.3rem; } +li.task input { margin-right: .45rem; } +a { color: var(--accent); } +a.ext::after { content: " \\2197"; font-size: .8em; color: var(--muted); } +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; + background: var(--chip); padding: .1em .35em; border-radius: 4px; } +pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; } +pre code { background: none; padding: 0; } +blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote); + border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; } +blockquote p { margin: .2rem 0; } +hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; } +.table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; } +table { border-collapse: collapse; width: 100%; font-size: .92rem; } +th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; } +th { background: var(--chip); font-weight: 600; } +.chip { display: inline-block; background: var(--chip); color: var(--muted); + font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; + padding: .15rem .5rem; border-radius: 999px; } +.lead { color: var(--muted); margin: .35rem 0 1.25rem; } +.meta { color: var(--muted); font-size: .82rem; margin: .2rem 0 1.5rem; } +.cards { list-style: none; padding: 0; margin: 0; } +.cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem; + margin: .6rem 0; } +.cards a { font-weight: 600; text-decoration: none; } +.cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); } +.cards .chip { margin-left: .35rem; } +footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); + font-size: .78rem; color: var(--muted); } +""" + + +def page_shell( + title: str, + depth: int, + content: str, + *, + canonical: str, + stamp: str, + nav: str | None = None, +) -> str: + up = "../" * depth + if nav is None: + nav = f'← All documents' + nav_html = f"\n" if nav else "" + return f""" + + + + +{html.escape(title)} — {html.escape(SITE_NAME)} + + + + +{nav_html}{content} +
    {stamp}
    + + +""" + + +def meta_line(doc: Doc) -> str: + """Krótki pasek metadanych. Świadomie BEZ pola `links` — wpisy potrafią + wskazywać dokumenty prywatne, a sama ścieżka to już wyciek nazwy.""" + parts = [f'{html.escape(doc.doc_type)}'] + status = str(doc.frontmatter.get("status") or "").strip() + updated = str(doc.frontmatter.get("updated") or "").strip() + if status: + parts.append(html.escape(status)) + if updated: + parts.append("updated " + html.escape(updated)) + return '

    ' + " · ".join(parts) + "

    " + + +def render_page(doc: Doc, published: dict[str, str], base_url: str, stamp: str) -> str: + rendered = render_blocks(rewrite_links(doc, published)) + + # Pierwszy nagłówek treści awansujemy na

    strony zamiast dokładać drugi + # tytuł nad nim (dokument może zaczynać się od `#` albo od `###`). + match = re.match(r"\s*(.*?)", rendered, re.S) + if match: + headline = match.group(1) + rendered = rendered[match.end() :] + else: + headline = html.escape(doc.title) + + content = f"

    {headline}

    \n{meta_line(doc)}\n{rendered}" + canonical = f"{base_url.rstrip('/')}/{doc.page}" + return page_shell(doc.title, doc.id.count("/"), content, canonical=canonical, stamp=stamp) + + +_INLINE_MARKUP_RE = re.compile(r"[*`_]|\[private\]") + + +def summary(doc: Doc, limit: int = 190) -> str: + """Pierwszy akapit treści (bez nagłówka) jako zajawka na spisie.""" + body = _ANY_HEADING_RE.sub("", doc.body, count=1) + paragraph = "" + for chunk in body.split("\n\n"): + text = " ".join(line.strip() for line in chunk.strip().splitlines()) + if not text or text.startswith(("#", "|", ">", "```", "-", "*")): + continue + paragraph = text + break + if not paragraph: + return "" + paragraph = _MD_LINK_RE.sub(r"\1", paragraph) + paragraph = _INLINE_MARKUP_RE.sub("", paragraph) + if len(paragraph) > limit: + paragraph = paragraph[:limit].rsplit(" ", 1)[0] + "…" + return paragraph + + +def render_index(docs: list[Doc], base_url: str, stamp: str) -> str: + known = [t for t, _ in TYPE_ORDER] + labels = dict(TYPE_ORDER) + by_type: dict[str, list[Doc]] = {} + for doc in docs: + by_type.setdefault(doc.doc_type, []).append(doc) + + order = [t for t in known if t in by_type] + order += sorted(t for t in by_type if t not in known) + + sections = [] + for doc_type in order: + items = sorted(by_type[doc_type], key=lambda d: d.title.lower()) + cards = [] + for doc in items: + lead = summary(doc) + cards.append( + f'
  • ' + f"{html.escape(doc.title)}" + f'{html.escape(doc.doc_type)}' + + (f"

    {html.escape(lead)}

    " if lead else "") + + "
  • " + ) + heading = labels.get(doc_type, doc_type) + sections.append( + f"

    {html.escape(heading)} {len(items)}

    \n" + f'
      {"".join(cards)}
    ' + ) + + content = ( + f"

    {html.escape(SITE_NAME)}

    \n" + f'

    {SITE_LEAD}

    \n' + "\n".join(sections) + ) + return page_shell( + "Index", + 0, + content, + canonical=base_url.rstrip("/") + "/", + stamp=stamp, + nav="", + ) + + +# --- budowa ----------------------------------------------------------- + + +def git_commit() -> str: + try: + out = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + return out.stdout.strip() or "unknown" + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +def build(out_dir: Path, base_url: str) -> list[Doc]: + docs = load_docs() + public = [d for d in docs if d.public] + published = {d.id: d.page for d in public} + + generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + stamp = ( + f"Generated {generated} · commit {html.escape(git_commit())} " + f"· scripts/kb/gen_pages.py — do not edit the HTML, " + f"edit the source document and regenerate." + ) + + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + for doc in public: + target = out_dir / doc.page + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(render_page(doc, published, base_url, stamp), encoding="utf-8") + + (out_dir / "index.html").write_text( + render_index(public, base_url, stamp), encoding="utf-8" + ) + + print(f"Repo: {REPO_ROOT}") + print(f"Źródło: {KB_DIR.relative_to(REPO_ROOT)}/**/*.md") + print(f"Wyjście: {out_dir}") + print(f"BASE_URL: {base_url}") + print( + f"Dokumenty: {len(docs)} razem, {len(public)} public, " + f"{len(docs) - len(public)} pominiętych (private / brak frontmattera)" + ) + + by_type: dict[str, list[Doc]] = {} + for doc in public: + by_type.setdefault(doc.doc_type, []).append(doc) + print() + for doc_type in sorted(by_type): + print(f" {doc_type} ({len(by_type[doc_type])}):") + for doc in sorted(by_type[doc_type], key=lambda d: d.id): + print(f" {doc.page} ← kb/{doc.id}.md") + print() + print(f" index.html ({len(public)} pozycji)") + return public + + +# --- CLI -------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generator publicznej warstwy KB (kb.okit.pl) z dokumentów OKF." + ) + parser.add_argument( + "--base-url", + default=DEFAULT_BASE_URL, + help=f"publiczny adres wystawki (domyślnie {DEFAULT_BASE_URL})", + ) + parser.add_argument( + "--out", + type=Path, + default=DEFAULT_OUT, + help="katalog wyjściowy (domyślnie build/kb-site)", + ) + args = parser.parse_args() + + build(args.out.resolve(), args.base_url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())