#!/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'' ) 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())