#!/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"(? 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'{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) 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""" {html.escape(title)} — {html.escape(BUNDLE_NAME)} {content} """ 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"{html.escape(str(key))}{shown}" ) return ( '
    Metadane (frontmatter)' f'
    {"".join(rows)}
    ' "
    " ) _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'{escaped}' if value.strip().startswith("mailto:") or "@" in value and " " not in value.strip(): return f'{escaped}' 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

    # strony zamiast dokładać drugi. Sam `title` i tak jest w tabelce metadanych. match = re.match(r"\s*

    (.*?)

    ", 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'

    {html.escape(description)}

    ' if description else "" content = ( f'{chip}\n' f"

    {headline}

    \n{lead}\n" f"{_meta_table(frontmatter)}\n{rendered}" ) return _page_shell(concept.title, concept.id.count("/"), content) _INDEX_HEADING_RE = re.compile(r"^#\s+(.+)$", re.M) def group_heading(dirname: str) -> str: """Nagłówek grupy = pierwszy nagłówek `index.md` katalogu (§8). Indexy podkatalogów same nazywają swoją zawartość, więc karta zbiorcza nie musi mieć zaszytej listy katalogów — dorzucenie nowego regionu do bundle'a wystarczy, żeby pojawił się tu z własną nazwą. """ index = BUNDLE_ROOT / dirname / "index.md" if index.is_file(): match = _INDEX_HEADING_RE.search(index.read_text(encoding="utf-8")) if match: return match.group(1).strip() return dirname def render_index(concepts: list[Concept]) -> str: """Karta zbiorcza: koncepty z korzenia, potem grupa na katalog najwyższego poziomu. Każdy koncept bundle'a ląduje w dokładnie jednej grupie.""" by_dir: dict[str, list[Concept]] = {} for concept in concepts: if "/" in concept.id: by_dir.setdefault(concept.id.split("/", 1)[0], []).append(concept) groups: list[tuple[str, list[Concept]]] = [ ("Wyjazd i wiedza ogólna", [c for c in concepts if "/" not in c.id]) ] for dirname in sorted(by_dir): # region.md przed oferty/* — płycej w drzewie znaczy ogólniej. items = sorted(by_dir[dirname], key=lambda c: (c.id.count("/"), c.id)) groups.append((group_heading(dirname), items)) 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'{html.escape(str(status).split("—")[0].strip())} ' if status else "" ) cards.append( f'
  • ' f"{html.escape(concept.title)} " f'{badge}{html.escape(str(concept.frontmatter.get("type") or "—"))}' f"

    {html.escape(description)}

  • " ) sections.append(f"

    {heading}

    \n") content = ( f"

    {html.escape(BUNDLE_NAME)}

    \n" '

    Karty konceptów bundle\'a — pełna treść każdego dokumentu ' "(ceny, dostępność, plusy/minusy). Struktura powiązań: " 'graf.

    \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 16–23.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())