narty-2027/gen_pages.py
oskar c4f6cf3f1d Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.

Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).

Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.

Pliki .md nietknięte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00

550 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Renderuje karty HTML konceptów bundle'a do katalogu pages/.
Graf (viz.html) pokazuje strukturę; karty pokazują treść — ceny, dostępność,
plusy/minusy, listy do zrobienia. Węzeł w grafie linkuje do karty przez pole
`resource` (patrz gen_viz.py).
Renderer markdown: używa `python-markdown`, jeśli jest zainstalowany
(rozszerzenia tables + fenced_code + sane_lists); w przeciwnym razie własnego
renderera na samej bibliotece standardowej — obsługuje nagłówki, akapity,
tabele GFM, listy (w tym `- [ ]`), cytaty, bloki kodu, linie poziome oraz
inline: **bold**, *kursywa*, `kod`, [linki](...). Który tor zadziałał, skrypt
wypisuje przy uruchomieniu.
Pliki .md NIE są modyfikowane — to czysty generator, tak samo jak gen_viz.py.
Pliki zarezerwowane (index.md, log.md) nie są konceptami (§3.1) i nie
dostają kart; linki do nich prowadzą do pages/index.html.
Uruchomienie: python3 gen_pages.py
"""
from __future__ import annotations
import html
import posixpath
import re
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
import yaml
BUNDLE_ROOT = Path(__file__).resolve().parent
PAGES_DIRNAME = "pages"
PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME
BUNDLE_NAME = "Ski 2027 — Skicircus Saalbach"
RESERVED = {"index.md", "log.md"}
try: # renderer preferowany, jeśli dostępny w systemie
import markdown as _markdown_lib
except ModuleNotFoundError:
_markdown_lib = None
RENDERER = "python-markdown" if _markdown_lib else "fallback (biblioteka standardowa)"
# --- model ------------------------------------------------------------
@dataclass
class Concept:
id: str # np. "oferty/glemm-lodge"
path: Path
frontmatter: dict
body: str
@property
def page(self) -> str:
"""Ścieżka karty względem katalogu pages/."""
return f"{self.id}.html"
@property
def title(self) -> str:
return str(self.frontmatter.get("title") or self.id)
def load_concepts() -> list[Concept]:
concepts: list[Concept] = []
for path in sorted(BUNDLE_ROOT.rglob("*.md")):
if ".git" in path.parts or path.name in RESERVED:
continue
text = path.read_text(encoding="utf-8")
frontmatter: dict = {}
body = text
if text.startswith("---\n"):
end = text.find("\n---", 3)
if end != -1:
frontmatter = yaml.safe_load(text[4:end]) or {}
body = text[end + 4 :]
concepts.append(
Concept(
id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(),
path=path,
frontmatter=frontmatter,
body=body.lstrip("\n"),
)
)
return concepts
# --- linki ------------------------------------------------------------
_LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+\.md)((?:#[^)\s]*)?)\)")
def rewrite_links(body: str, source: Concept, pages: dict[str, str]) -> str:
"""Linki do plików .md → względne ścieżki do kart. External bez zmian."""
here = posixpath.dirname(source.page)
def repl(match: re.Match) -> str:
target, fragment = match.group(1), match.group(2)
if "://" in target:
return match.group(0)
if target.startswith("/"):
rel = target[1:]
else:
rel = posixpath.normpath(posixpath.join(posixpath.dirname(source.id), target))
concept_id = rel[:-3] if rel.endswith(".md") else rel
if concept_id in pages:
dest = pages[concept_id]
elif posixpath.basename(rel) in RESERVED:
dest = "index.html" # index.md / log.md nie mają kart
else:
return match.group(0)
return "](" + posixpath.relpath(dest, here or ".") + fragment + ")"
return _LINK_TARGET_RE.sub(repl, body)
# --- renderer markdown: fallback na stdlib -----------------------------
_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 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)
attrs = ""
if "://" in href or href.startswith("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)
def md_to_html(text: str) -> str:
if _markdown_lib:
return _markdown_lib.markdown(
text, extensions=["tables", "fenced_code", "sane_lists"]
)
return render_blocks(text)
# --- 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; }
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: ""; 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 { margin: 0 0 1.5rem; }
.meta summary { cursor: pointer; font-size: .85rem; color: var(--muted);
padding: .3rem 0; }
.meta table { font-size: .85rem; }
.meta th { width: 38%; text-align: left; font-weight: 500; word-break: break-word; }
.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); }
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) -> str:
up = "../" * depth
return f"""<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}{html.escape(BUNDLE_NAME)}</title>
<style>{CSS}</style>
</head>
<body>
<nav><a href="{up}index.html">← Wszystkie karty</a> · <a href="{up}../viz.html">Graf</a></nav>
{content}
<footer>Wygenerowane z bundle'a OKF przez <code>gen_pages.py</code> — nie edytuj
tych plików, edytuj <code>.md</code> i przegeneruj.</footer>
</body>
</html>
"""
def _meta_table(frontmatter: dict) -> str:
rows = []
for key, value in frontmatter.items():
if isinstance(value, list):
shown = ", ".join(_link_value(str(v)) for v in value)
else:
shown = _link_value(str(value))
rows.append(
f"<tr><th>{html.escape(str(key))}</th><td>{shown}</td></tr>"
)
return (
'<details class="meta" open><summary>Metadane (frontmatter)</summary>'
f'<div class="table-wrap"><table><tbody>{"".join(rows)}</tbody></table></div>'
"</details>"
)
_URLISH_RE = re.compile(
r"^(https?://\S+|(?:[a-z0-9][a-z0-9-]*\.)+(?:at|com|pl|de|eu|net|org|io|info))$"
)
def _link_value(value: str) -> str:
"""Wartość frontmattera; adresy robi klikalnymi, resztę tylko escape'uje."""
escaped = html.escape(value, quote=False)
if _URLISH_RE.match(value.strip()):
href = value.strip()
if "://" not in href:
href = "https://" + href
return f'<a href="{html.escape(href, quote=True)}" target="_blank" rel="noopener" class="ext">{escaped}</a>'
if value.strip().startswith("mailto:") or "@" in value and " " not in value.strip():
return f'<a href="mailto:{html.escape(value.strip(), quote=True)}">{escaped}</a>'
return escaped
def render_page(concept: Concept, pages: dict[str, str]) -> str:
body = rewrite_links(concept.body, concept, pages)
rendered = md_to_html(body)
# Pierwszy nagłówek H1 body dubluje tytuł karty — awansujemy go na <h1>
# strony zamiast dokładać drugi. Sam `title` i tak jest w tabelce metadanych.
match = re.match(r"\s*<h1>(.*?)</h1>", rendered, re.S)
if match:
headline = match.group(1)
rendered = rendered[match.end() :]
else:
headline = html.escape(concept.title)
frontmatter = concept.frontmatter
chip = html.escape(str(frontmatter.get("type") or ""))
description = str(frontmatter.get("description") or "")
lead = f'<p class="lead">{html.escape(description)}</p>' if description else ""
content = (
f'<span class="chip">{chip}</span>\n'
f"<h1>{headline}</h1>\n{lead}\n"
f"{_meta_table(frontmatter)}\n{rendered}"
)
return _page_shell(concept.title, concept.id.count("/"), content)
def render_index(concepts: list[Concept]) -> str:
groups: list[tuple[str, list[Concept]]] = [
("Wyjazd i wiedza ogólna", [c for c in concepts if "/" not in c.id]),
("Oferty noclegowe", [c for c in concepts if c.id.startswith("oferty/")]),
]
sections = []
for heading, items in groups:
if not items:
continue
cards = []
for concept in items:
description = str(concept.frontmatter.get("description") or "")
status = concept.frontmatter.get("status_ceny")
badge = (
f'<span class="chip">{html.escape(str(status).split("")[0].strip())}</span> '
if status
else ""
)
cards.append(
f'<li><a href="{html.escape(concept.page, quote=True)}">'
f"{html.escape(concept.title)}</a> "
f'{badge}<span class="chip">{html.escape(str(concept.frontmatter.get("type") or ""))}</span>'
f"<p>{html.escape(description)}</p></li>"
)
sections.append(f"<h2>{heading}</h2>\n<ul class=\"cards\">{''.join(cards)}</ul>")
content = (
f"<h1>{html.escape(BUNDLE_NAME)}</h1>\n"
'<p class="lead">Karty konceptów bundle\'a — pełna treść każdego dokumentu '
"(ceny, dostępność, plusy/minusy). Struktura powiązań: "
'<a href="../viz.html">graf</a>.</p>\n' + "\n".join(sections)
)
return _page_shell("Karty", 0, content)
# --- raport braków ----------------------------------------------------
def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]:
"""Czego brakuje w ofertach — bez zgadywania, tylko z tego, co jest."""
report = []
for concept in concepts:
if not str(concept.frontmatter.get("type", "")).startswith("oferta"):
continue
gaps = []
status = str(concept.frontmatter.get("status_ceny") or "")
if not status:
gaps.append("brak pola `status_ceny`")
elif "SZACUNEK" in status.upper():
gaps.append(f"cena niepotwierdzona ({status})")
# Danymi o dostępności jest osobna sekcja (jak w glemm-lodge), a nie
# samo słowo w tekście — "duża dostępność" w opisie się nie liczy.
if not re.search(r"^#+\s*.*dostępnoś", concept.body, re.IGNORECASE | re.M):
gaps.append("brak danych o dostępności terminu 1623.01.2027")
open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M))
if open_todos:
gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}")
if gaps:
report.append((concept.id, gaps))
return report
# --- main -------------------------------------------------------------
def main() -> int:
concepts = load_concepts()
pages = {c.id: c.page for c in concepts}
if PAGES_DIR.exists():
shutil.rmtree(PAGES_DIR)
PAGES_DIR.mkdir(parents=True)
written = []
for concept in concepts:
out = PAGES_DIR / concept.page
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render_page(concept, pages), encoding="utf-8")
written.append(out)
index = PAGES_DIR / "index.html"
index.write_text(render_index(concepts), encoding="utf-8")
written.append(index)
print(f"Renderer markdown: {RENDERER}")
print(f"Zapisano {len(written)} plików w {PAGES_DIR}:")
for path in written:
size = path.stat().st_size / 1024
print(f" {path.relative_to(BUNDLE_ROOT)} ({size:.1f} KiB)")
gaps = missing_data(concepts)
print()
if gaps:
print(f"Oferty z brakami w danych ({len(gaps)}) — do uzupełnienia ręcznie:")
for concept_id, items in gaps:
print(f" ! {concept_id}")
for item in items:
print(f" {item}")
else:
print("Braków w danych ofert nie wykryto.")
return 0
if __name__ == "__main__":
raise SystemExit(main())