feat(kb): generator publicznej warstwy KB (gen_pages.py)

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
<link rel="canonical">. 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.
This commit is contained in:
oskar 2026-08-04 17:57:15 +02:00
parent afa86a8aae
commit 65093815a8
3 changed files with 671 additions and 1 deletions

2
.gitignore vendored
View file

@ -22,6 +22,8 @@ venv/
*.egg-info/ *.egg-info/
packages/*/build/ packages/*/build/
jobs/*/build/ jobs/*/build/
# wyjscie generatorow (scripts/kb/gen_pages.py -> build/kb-site/) — artefakt, nie zrodlo
build/
# Tools # Tools
.aider* .aider*

View file

@ -21,7 +21,8 @@ reguły tego repo:
Zakres domyślny: kb/ oraz docs/sessions/, z wyłączeniem README-wskaźników Zakres domyślny: kb/ oraz docs/sessions/, z wyłączeniem README-wskaźników
(POINTER_GLOBS) te nawigacją do kb-doca, nie dokumentami KB, i celowo nie (POINTER_GLOBS) te 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. leży poza bazą wiedzy i nie podlega walidacji.
Tylko biblioteka standardowa: minimalny parser YAML wystarczający dla Tylko biblioteka standardowa: minimalny parser YAML wystarczający dla
@ -64,6 +65,11 @@ POINTER_GLOBS = (
"hosts/*/README.md", "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}$") 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) 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]: def split_frontmatter(text: str) -> tuple[str | None, str]:
"""Zwraca (blok frontmattera lub None, reszta dokumentu).""" """Zwraca (blok frontmattera lub None, reszta dokumentu)."""
if not text.startswith("---\n"): if not text.startswith("---\n"):
@ -140,6 +150,7 @@ def scope_files(root: Path) -> list[Path]:
p for p in base.rglob("*.md") p for p in base.rglob("*.md")
if ".git" not in p.parts if ".git" not in p.parts
and not is_pointer(p.relative_to(root).as_posix()) and not is_pointer(p.relative_to(root).as_posix())
and not is_excluded(p.relative_to(root).as_posix())
) )
return sorted(set(files)) return sorted(set(files))

657
scripts/kb/gen_pages.py Normal file
View file

@ -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`
<katalog>/<nazwa>.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 "
"<code>visibility: public</code> 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"(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)")
_ITALIC_UNDER_RE = re.compile(r"(?<![\w\\])_(?!\s)([^_]+?)(?<!\s)_(?![\w])")
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})$")
_LIST_RE = re.compile(r"^\s*([-*+]|\d+[.)])\s+(.*)$")
_TASK_RE = re.compile(r"^\[([ xX])\]\s+(.*)$")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
def _linkable(href: str) -> 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'<a href="{html.escape(href, quote=True)}"{attrs}>{label}</a>'
text = _LINK_RE.sub(link, text)
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
text = _ITALIC_STAR_RE.sub(r"<em>\1</em>", text)
text = _ITALIC_UNDER_RE.sub(r"<em>\1</em>", text)
for i, code in enumerate(codes):
text = text.replace(f"\x00c{i}\x00", f"<code>{html.escape(code, quote=False)}</code>")
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"<pre><code>{html.escape(chr(10).join(code), quote=False)}</code></pre>")
continue
heading = _HEADING_RE.match(stripped)
if heading:
level = len(heading.group(1))
out.append(f"<h{level}>{render_inline(heading.group(2))}</h{level}>")
i += 1
continue
if _HR_RE.match(stripped):
out.append("<hr>")
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'<th style="text-align:{align[n] if n < len(align) else "left"}">'
f"{render_inline(c)}</th>"
for n, c in enumerate(header)
)
body = "".join(
"<tr>"
+ "".join(
f'<td style="text-align:{align[n] if n < len(align) else "left"}">'
f"{render_inline(c)}</td>"
for n, c in enumerate(row)
)
+ "</tr>"
for row in rows
)
out.append(
f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
f"<tbody>{body}</tbody></table></div>"
)
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"<blockquote>{render_blocks(chr(10).join(quoted))}</blockquote>")
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'<li class="task"><input type="checkbox" disabled{checked}> '
f"{render_inline(task.group(2))}</li>"
)
else:
rendered.append(f"<li>{render_inline(raw)}</li>")
tag = "ol" if ordered else "ul"
out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
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"<p>{render_inline(' '.join(paragraph))}</p>")
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'<a href="{up}index.html">← All documents</a>'
nav_html = f"<nav>{nav}</nav>\n" if nav else ""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)} {html.escape(SITE_NAME)}</title>
<link rel="canonical" href="{html.escape(canonical, quote=True)}">
<style>{CSS}</style>
</head>
<body>
{nav_html}{content}
<footer>{stamp}</footer>
</body>
</html>
"""
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'<span class="chip">{html.escape(doc.doc_type)}</span>']
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 '<p class="meta">' + " · ".join(parts) + "</p>"
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 <h1> strony zamiast dokładać drugi
# tytuł nad nim (dokument może zaczynać się od `#` albo od `###`).
match = re.match(r"\s*<h[1-6]>(.*?)</h[1-6]>", rendered, re.S)
if match:
headline = match.group(1)
rendered = rendered[match.end() :]
else:
headline = html.escape(doc.title)
content = f"<h1>{headline}</h1>\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'<li><a href="{html.escape(doc.page, quote=True)}">'
f"{html.escape(doc.title)}</a>"
f'<span class="chip">{html.escape(doc.doc_type)}</span>'
+ (f"<p>{html.escape(lead)}</p>" if lead else "")
+ "</li>"
)
heading = labels.get(doc_type, doc_type)
sections.append(
f"<h2>{html.escape(heading)} <span class=\"chip\">{len(items)}</span></h2>\n"
f'<ul class="cards">{"".join(cards)}</ul>'
)
content = (
f"<h1>{html.escape(SITE_NAME)}</h1>\n"
f'<p class="lead">{SITE_LEAD}</p>\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 <code>{html.escape(git_commit())}</code> "
f"· <code>scripts/kb/gen_pages.py</code> — 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())