narty-2027/gen_pages.py
oskar 06ab1b24aa UI: kolumna dostępności 6–13.02, sekcja Archiwum, nowy termin w nagłówkach
porownanie.html:
- nowa kolumna „Dostępność 6–13.02” z pola `dostepnosc_6_13_02_2027`,
  skrócona do emoji + werdyktu ( DOSTĘPNE / 🔍 do sprawdzenia /
   BRAK (zweryf.)), pełna notatka w atrybucie title
- sortowanie kolumny układa decyzyjnie (wolne → do sprawdzenia → brak),
  nie alfabetycznie po emoji
- wiersz Glemm Lodge na czerwono i z przekreśloną nazwą: zweryfikowany
  brak miejsc bije kolor statusu ceny (reguła CSS niżej wygrywa)
- karty `priorytet: ARCHIWALNA` wypadają z tabeli i z bilansu do osobnej
  sekcji „Archiwum” na dole strony
- legenda i bilans statusów pod tabelą

Termin wyjazdu wyjechał do stałych TERM_LABEL/TERM_SHORT/TERM_NOTE:
nagłówki wszystkich stron (BUNDLE_NAME, też w gen_viz.py), lead
start.html, opis kafla porównania i raport braków biorą go stąd.
Zmiana terminu = zmiana stałych i regeneracja.

Raport braków czyta teraz dostępność z pola frontmattera zamiast zgadywać
z nagłówków w body, i pomija karty archiwalne. Wypis po regeneracji
podaje bilans /🔍/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:00:23 +02:00

1617 lines
62 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/.
Wejściem dla człowieka jest `pages/start.html` (landing decyzyjny), a nie graf:
graf to załącznik pokazujący strukturę powiązań. 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).
Poza kartą na koncept skrypt generuje trzy strony zbiorcze:
pages/index.html spis wszystkich kart, pogrupowany katalogami
pages/porownanie.html jedna tabela WSZYSTKICH aktualnych ofert (nie
regionów), sortowalna klikiem, złożona z samych
frontmatterów; na górze podsumowanie per region
z `wyjazd.md`, na dole sekcja „Archiwum” z kartami
`priorytet: ARCHIWALNA` (riscercz na poprzedni termin)
pages/start.html landing: trzy kafle (porównanie / analiza / graf)
plus linki do kart czterech regionów
`start.html` jest jedyną stroną z linkami root-absolutnymi (`/viz.html`,
`/pages/...`), bo ten sam plik jest serwowany pod dwiema ścieżkami: jako
`/index.html` (korzeń serwisu) i jako `/pages/start.html`. Ścieżki względne
działałyby tylko pod jedną z nich. Wymaga to serwowania bundle'a z korzenia
domeny — lokalnie `python3 -m http.server` w katalogu bundle'a, na PIHA
korzeń wolumenu (komenda deployu na końcu wypisu skryptu).
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 — wyjazd 613.02.2027"
# Termin wyjazdu w jednym miejscu: leci do nagłówków stron i do raportu
# braków. Zmiana terminu = zmiana tych dwóch stałych i regeneracja.
TERM_LABEL = "613.02.2027"
TERM_SHORT = "613.02" # nagłówek kolumny w tabeli porównawczej
TERM_NOTE = "tydzień Fasching, szczyt sezonu"
RESERVED = {"index.md", "log.md"}
# Publiczny adres wystawki — jedyne miejsce, w którym siedzi domena. Używa go
# WYŁĄCZNIE pages/linki.html (spis do kopiowania i wysyłania grupie); wszystkie
# pozostałe strony linkują względnie albo root-absolutnie, żeby działały tak
# samo lokalnie (python3 -m http.server) i po deployu. Zmiana domeny = zmiana
# tej jednej stałej i regeneracja.
BASE_URL = "https://narty27.kapala.org"
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); }
/* landing: kafle i lista regionów */
body.wide { max-width: 72rem; }
.tiles, .regions { list-style: none; padding: 0; }
.tiles { margin: 1.25rem 0 2rem; display: grid; gap: .75rem; }
.regions { margin: .6rem 0 0; display: grid; gap: .5rem; }
@media (min-width: 34rem) {
.tiles { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
.regions { grid-template-columns: repeat(2, 1fr); }
}
.tiles a, .regions a { display: block; border: 1px solid var(--line); border-radius: 10px;
padding: .9rem 1rem; text-decoration: none; color: var(--fg); }
.tiles a { font-size: 1.05rem; font-weight: 600; }
.tiles a:hover, .tiles a:focus, .regions a:hover, .regions a:focus {
border-color: var(--accent); }
.tiles small, .regions small { display: block; margin-top: .3rem; font-weight: 400;
font-size: .85rem; color: var(--muted); }
.regions a { font-weight: 600; }
/* podsumowanie per region na karcie porównania */
.summary { list-style: none; padding: 0; margin: .8rem 0 2rem; display: grid; gap: .6rem; }
@media (min-width: 48rem) { .summary { grid-template-columns: repeat(2, 1fr); } }
.summary li { border: 1px solid var(--line); border-left: 3px solid var(--accent);
border-radius: 8px; padding: .65rem .85rem; }
.summary .budget { font-size: .95rem; }
.summary p { margin: .35rem 0 0; font-size: .88rem; color: var(--muted); }
/* tabela porównawcza: sortowanie klikiem + kolor statusu ceny */
table.sortable th { cursor: pointer; user-select: none; white-space: nowrap; }
table.sortable th::after { content: ""; font-size: .8em; opacity: .35; }
table.sortable th[data-dir="asc"]::after { content: ""; opacity: 1; }
table.sortable th[data-dir="desc"]::after { content: ""; opacity: 1; }
table.sortable td { font-size: .88rem; padding: .35rem .5rem; }
table.sortable td[data-num] { white-space: nowrap; }
table.sortable .name { font-weight: 600; }
tr.verified td { background: rgba(13, 148, 136, .14); }
tr.estimate td { background: rgba(100, 116, 139, .10); color: var(--muted); }
tr.estimate td.name a, tr.verified td.name a { color: var(--accent); }
/* zweryfikowany BRAK miejsc bije kolor statusu ceny — reguła niżej wygrywa */
tr.unavailable td { background: rgba(220, 38, 38, .16); }
tr.unavailable td.name a { text-decoration: line-through; }
td.avail { white-space: nowrap; font-weight: 600; }
.archive { margin-top: 2.5rem; }
.archive h2 { margin-bottom: .2rem; }
.archive .lead { font-size: .88rem; margin-top: .2rem; }
.legend { font-size: .82rem; color: var(--muted); margin: .5rem 0 0; }
.legend .box { display: inline-block; width: .8em; height: .8em; border-radius: 3px;
border: 1px solid var(--line); vertical-align: -.05em; margin-right: .25em; }
.legend .box.v { background: rgba(13, 148, 136, .35); }
.legend .box.e { background: rgba(100, 116, 139, .25); }
.legend .box.u { background: rgba(220, 38, 38, .35); }
/* miniatury w tabeli */
td.thumb { padding: .25rem; width: 128px; }
td.thumb img { width: 120px; height: 80px; object-fit: cover; border-radius: 6px;
display: block; }
td.thumb .placeholder { display: flex; width: 120px; height: 80px; border-radius: 6px;
align-items: center; justify-content: center; background: var(--chip);
color: var(--muted); font-weight: 700; letter-spacing: .05em;
text-decoration: none; }
/* galeria na karcie oferty */
.gallery { margin: 0 0 1.25rem; }
.gallery-main img { width: 100%; max-height: 24rem; object-fit: cover;
border-radius: 10px; display: block; background: var(--chip); }
.gallery-thumbs { display: flex; gap: .4rem; margin-top: .4rem; overflow-x: auto;
-webkit-overflow-scrolling: touch; padding-bottom: .2rem; }
.gallery-thumbs button { flex: 0 0 auto; padding: 0; border: 2px solid transparent;
border-radius: 6px; background: none; cursor: pointer; }
.gallery-thumbs button[aria-current] { border-color: var(--accent); }
.gallery-thumbs img { width: 5.5rem; height: 3.6rem; object-fit: cover;
border-radius: 4px; display: block; }
/* spis linków do kopiowania */
.copybar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
margin: 1rem 0 1.5rem; }
.copybar button { font: inherit; font-size: .9rem; padding: .45rem .9rem;
border: 1px solid var(--accent); border-radius: 8px;
background: var(--accent); color: #f8fafc; cursor: pointer; }
.copybar button + button { background: none; color: var(--accent); }
.copybar button:hover { filter: brightness(1.08); }
.copy-status { font-size: .85rem; color: var(--muted); }
.links { list-style: none; padding: 0; margin: .5rem 0 0; }
.links li { padding: .55rem 0; border-bottom: 1px solid var(--line); }
.links li:last-child { border-bottom: 0; }
.links a { font-weight: 600; text-decoration: none; }
.links code.url { display: block; margin: .2rem 0; font-size: .82rem;
background: none; padding: 0; color: var(--muted);
word-break: break-all; user-select: all; }
.links p { margin: 0; font-size: .85rem; color: var(--muted); }
/* kafel regionu ze zdjęciem w tle */
.regions a.hero { background-size: cover; background-position: center;
border-color: transparent; color: #f8fafc; min-height: 6.5rem;
display: flex; flex-direction: column; justify-content: flex-end; }
.regions a.hero small { color: #e2e8f0; }
"""
SORT_JS = """
document.querySelectorAll('table.sortable').forEach(function (table) {
var head = table.tHead.rows[0];
Array.prototype.forEach.call(head.cells, function (th, i) {
th.tabIndex = 0;
th.setAttribute('role', 'button');
th.addEventListener('click', function () { sortTable(table, i, th); });
th.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sortTable(table, i, th); }
});
});
});
function sortTable(table, index, th) {
var dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (h) {
delete h.dataset.dir;
h.setAttribute('aria-sort', 'none');
});
th.dataset.dir = dir;
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
var body = table.tBodies[0];
var rows = Array.prototype.slice.call(body.rows);
var mul = dir === 'asc' ? 1 : -1;
rows.sort(function (a, b) {
var x = a.cells[index], y = b.cells[index];
var nx = x.dataset.num, ny = y.dataset.num;
if (nx !== undefined && ny !== undefined) return (parseFloat(nx) - parseFloat(ny)) * mul;
// Wiersze bez wartości liczbowej zawsze na końcu — w obie strony.
if (nx !== undefined) return -1;
if (ny !== undefined) return 1;
var kx = x.dataset.key !== undefined ? x.dataset.key : x.textContent;
var ky = y.dataset.key !== undefined ? y.dataset.key : y.textContent;
if (!kx) return 1;
if (!ky) return -1;
return kx.localeCompare(ky, 'pl') * mul;
});
rows.forEach(function (row) { body.appendChild(row); });
}
"""
def _page_shell(
title: str,
depth: int,
content: str,
*,
body_class: str = "",
nav: str | None = None,
script: str = "",
) -> str:
up = "../" * depth
if nav is None:
nav = (
f'<a href="{up}start.html">← Start</a> · '
f'<a href="{up}index.html">Wszystkie karty</a> · '
f'<a href="{up}porownanie.html">Porównanie ofert</a> · '
f'<a href="{up}linki.html">Linki</a> · '
f'<a href="{up}../viz.html">Graf</a>'
)
css_class = f' class="{body_class}"' if body_class else ""
script_tag = f"\n<script>{script}</script>" if script else ""
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{css_class}>
<nav>{nav}</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.<br>
powered by <a href="https://gethumanai.pl" target="_blank" rel="noopener">gethumanai.pl</a></footer>{script_tag}
</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 ""
images = offer_images(concept) if is_offer(concept) else []
gallery = render_gallery(images, str(frontmatter.get("nazwa") or concept.title))
content = (
f'<span class="chip">{chip}</span>\n'
f"<h1>{headline}</h1>\n{lead}\n{gallery}\n"
f"{_meta_table(frontmatter)}\n{rendered}"
)
return _page_shell(
concept.title,
concept.id.count("/"),
content,
script=GALLERY_JS if len(images) > 1 else "",
)
_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'<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)
# --- zdjęcia obiektów (pages/img/<id>/, patrz gen_images.py) -----------
IMG_DIRNAME = "img"
IMG_DIR = PAGES_DIR / IMG_DIRNAME
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
def offer_image_paths(concept: Concept) -> list[str]:
"""Zdjęcia oferty jako ścieżki względne wobec katalogu `pages/`.
Mapowanie id → zdjęcia jest konwencją katalogową (pages/img/<id>/NN.jpg),
nie polem frontmattera — pliki .md zostają nietknięte.
"""
offer_id = str(concept.frontmatter.get("id") or "").strip()
if not offer_id:
return []
directory = IMG_DIR / offer_id
if not directory.is_dir():
return []
files = sorted(p for p in directory.iterdir() if p.suffix.lower() in IMG_EXTS)
return [f"{IMG_DIRNAME}/{offer_id}/{p.name}" for p in files]
def offer_images(concept: Concept) -> list[str]:
"""To samo, ale względem katalogu karty konceptu."""
up = "../" * concept.id.count("/")
return [up + path for path in offer_image_paths(concept)]
def initials(name: str) -> str:
"""Inicjały obiektu — placeholder dla ofert bez zdjęć."""
words = [w for w in re.split(r"[\s/–—-]+", name) if w and w[0].isalnum()]
return "".join(w[0].upper() for w in words[:2]) or "?"
GALLERY_JS = """
document.querySelectorAll('.gallery').forEach(function (gallery) {
var big = gallery.querySelector('.gallery-main img');
gallery.querySelectorAll('.gallery-thumbs button').forEach(function (button) {
button.addEventListener('click', function () {
var img = button.querySelector('img');
big.src = img.dataset.full || img.src;
gallery.querySelectorAll('.gallery-thumbs button').forEach(function (b) {
b.removeAttribute('aria-current');
});
button.setAttribute('aria-current', 'true');
});
});
});
"""
def render_gallery(images: list[str], alt: str) -> str:
if not images:
return ""
escaped_alt = html.escape(alt, quote=True)
first = html.escape(images[0], quote=True)
thumbs = ""
if len(images) > 1:
buttons = "".join(
f'<button type="button"{" aria-current=\"true\"" if n == 0 else ""}>'
f'<img src="{html.escape(src, quote=True)}" alt="{escaped_alt}'
f'zdjęcie {n + 1}" loading="lazy"></button>'
for n, src in enumerate(images)
)
thumbs = f'<div class="gallery-thumbs">{buttons}</div>'
return (
'<figure class="gallery">'
f'<div class="gallery-main"><img src="{first}" alt="{escaped_alt}" '
'loading="lazy"></div>'
f"{thumbs}</figure>"
)
# --- podsumowanie per region (z wyjazd.md) ----------------------------
TRIP_FILE = "wyjazd.md"
BUDGET_ROW = "budżet grupy razem"
FALLBACK_ROW = "charakter"
_TABLE_LINK_RE = re.compile(r"\[([^\]]+)\]\(/([^)\s]+\.md)\)")
_EMPHASIS_RE = re.compile(r"\*\*|\*|`")
@dataclass
class Region:
concept_id: str # np. "ischgl/region"
dirname: str # np. "ischgl" ("" dla Saalbach z korzenia)
label: str # np. "Ischgl/Paznaun"
budget: str = ""
conclusion: str = ""
def _plain(cell: str) -> str:
"""Komórka tabeli markdown → czysty tekst (linki na same etykiety)."""
text = _LINK_RE.sub(r"\1", cell)
return _EMPHASIS_RE.sub("", text).strip()
def parse_regions() -> list[Region]:
"""Regiony + budżet całkowity + jeden wniosek — z tabeli w `wyjazd.md`.
Nic nie jest tu zaszyte na sztywno: kolumny bierzemy z nagłówka tabeli
porównawczej (te, które linkują do `*/region.md`), budżet z wiersza
„Budżet grupy razem”, a wniosek z pierwszej pozycji „Wniosków
przekrojowych”, która dany region wymienia. Dorzucenie piątego wariantu
do `wyjazd.md` wystarczy, żeby pojawił się na stronach.
"""
path = BUNDLE_ROOT / TRIP_FILE
if not path.is_file():
return []
text = path.read_text(encoding="utf-8")
regions: list[Region] = []
rows: dict[str, list[str]] = {}
lines = text.split("\n")
for n, line in enumerate(lines):
if not line.strip().startswith("|"):
continue
header = _cells(line)
targets = [_TABLE_LINK_RE.search(c) for c in header[1:]]
if sum(1 for t in targets if t and t.group(2).endswith("region.md")) < 2:
continue
if n + 1 >= len(lines) or not _TABLE_SEP_RE.match(lines[n + 1]):
continue
for match in targets:
if not match or not match.group(2).endswith("region.md"):
continue
concept_id = match.group(2)[: -len(".md")]
regions.append(
Region(
concept_id=concept_id,
dirname=posixpath.dirname(concept_id),
label=_plain(match.group(1)),
)
)
for row in lines[n + 2 :]:
if not row.strip().startswith("|"):
break
cells = _cells(row)
rows[_plain(cells[0]).lower()] = [_plain(c) for c in cells[1:]]
break
conclusions = parse_conclusions(text)
for index, region in enumerate(regions):
for key in (BUDGET_ROW, FALLBACK_ROW):
values = rows.get(key) or []
if index < len(values) and values[index]:
if key == BUDGET_ROW:
region.budget = values[index]
elif not region.conclusion:
region.conclusion = values[index]
keyword = region.dirname or region.label.split()[0]
for raw in conclusions:
if keyword.lower() in raw.lower():
region.conclusion = _plain(raw.split(". ")[0]).rstrip(".") + "."
break
return regions
_ORDERED_ITEM_RE = re.compile(r"^\s*\d+[.)]\s+(.*)$")
def parse_conclusions(text: str) -> list[str]:
"""Pozycje listy numerowanej z sekcji „Wnioski przekrojowe”."""
section = re.split(r"^#{2,4}\s+Wnioski przekrojowe\s*$", text, flags=re.M)
if len(section) < 2:
return []
items: list[str] = []
for line in section[1].split("\n"):
if line.startswith("#"):
break
match = _ORDERED_ITEM_RE.match(line)
if match:
items.append(match.group(1).strip())
elif line.startswith((" ", "\t")) and line.strip() and items:
items[-1] += " " + line.strip()
return items
# --- karta porównania ofert -------------------------------------------
# (nagłówek, pole frontmattera / klucz specjalny, czy sortować liczbowo)
COLUMNS: list[tuple[str, str, bool]] = [
("Oferta", "_nazwa", False),
(f"Dostępność {TERM_SHORT}", "_dostepnosc", False),
("Priorytet", "priorytet", False),
("Region", "_region", False),
("Miejscowość", "miejscowosc", False),
("€ / apart. / tydzień", "cena_za_apartament_tydzien", True),
("Rodzina 2+2", "koszt_rodzina_2p2", True),
("Rodzina 2+1", "koszt_rodzina_2p1", True),
("Status ceny", "status_ceny", False),
("Od wyciągu", "odleglosc_od_wyciagu", False),
("WWW", "www", False),
]
_DIGIT_GAP_RE = re.compile(r"(?<=\d)[\s ](?=\d)")
_NUMBER_RE = re.compile(r"\d+(?:[.,]\d+)?")
def sort_number(value: str) -> float | None:
"""Pierwsza liczba w tekście, ze spacją jako separatorem tysięcy."""
match = _NUMBER_RE.search(_DIGIT_GAP_RE.sub("", value))
return float(match.group(0).replace(",", ".")) if match else None
def is_offer(concept: Concept) -> bool:
return str(concept.frontmatter.get("type", "")).startswith("oferta")
# --- dostępność terminu (pole `dostepnosc_6_13_02_2027`) ---------------
AVAILABILITY_FIELD = "dostepnosc_6_13_02_2027"
# (emoji z legendy, skrót do komórki, klucz sortowania). Kolejność kluczy
# układa kolumnę decyzyjnie: potwierdzone wolne → do sprawdzenia → brak.
AVAILABILITY_MARKS = (
("", "✅ DOSTĘPNE", "1"),
("🔍", "🔍 do sprawdzenia", "2"),
("", "❌ BRAK (zweryf.)", "3"),
)
def availability(concept: Concept) -> str:
return str(concept.frontmatter.get(AVAILABILITY_FIELD) or "").strip()
def availability_mark(value: str) -> tuple[str, str, str] | None:
for mark in AVAILABILITY_MARKS:
if value.startswith(mark[0]):
return mark
return None
def _availability_cell(value: str) -> str:
"""Komórka dostępności: emoji + jedno słowo, pełna treść w `title`.
Wartości z frontmattera są zdaniami („🔍 BRAK DANYCH — silnik online
pokaże od ręki”), a tabela ma się skanować wzrokiem — więc w komórce
zostaje sam werdykt, a uzasadnienie czeka pod kursorem.
"""
if not value:
return '<td class="avail" data-key="9">—</td>'
mark = availability_mark(value)
if not mark:
return _cell(value, False)
_, short, key = mark
return (
f'<td class="avail" data-key="{key}" '
f'title="{html.escape(value, quote=True)}">{html.escape(short)}</td>'
)
def is_archived(concept: Concept) -> bool:
"""Karty z riserczu na poprzedni termin — poza tabelą i statusami."""
return str(concept.frontmatter.get("priorytet") or "").upper().startswith(
"ARCHIWALNA"
)
def _shorten(value: str) -> str:
"""Sam rdzeń wartości; doprecyzowanie w nawiasie/po myślniku idzie do title.
„SZACUNEK — silnik online na alpenparks.at” w komórce rozpycha tabelę na
trzy wiersze, a i tak koduje ją kolor wiersza. Pełna treść zostaje pod
kursorem, nic nie ginie.
"""
for separator in ("", " (", " - ", " dla "):
head = value.split(separator)[0].strip()
if head and head != value:
return head
return value
# Kolumny, w których doprecyzowanie JEST treścią — skracanie zabrałoby sens.
NO_SHORTEN = {"odleglosc_od_wyciagu"}
def _cell(value: str, numeric: bool, suffix: str = "", short: bool = True) -> str:
attrs = ""
if not value:
return '<td data-key="">—</td>'
if numeric:
number = sort_number(value)
if number is not None:
attrs += f' data-num="{number:g}"'
else:
attrs += f' data-key="{html.escape(value, quote=True)}"'
shown = _shorten(value) if short else value
if shown != value:
attrs += f' title="{html.escape(value, quote=True)}"'
return f"<td{attrs}>{html.escape(shown + suffix)}</td>"
def _offer_rows(offers: list[Concept], by_dir: dict[str, Region]) -> str:
"""Wiersze tabeli porównawczej dla podanych ofert."""
rows = []
for concept in offers:
frontmatter = concept.frontmatter
status = str(frontmatter.get("status_ceny") or "")
upper = status.upper()
classes = [
"verified" if "ZWERYFIKOWANE" in upper
else "estimate" if "SZACUNEK" in upper
else ""
]
# Zweryfikowany brak miejsc to najmocniejszy komunikat w wierszu —
# ważniejszy niż to, czy cena jest pewna.
if availability(concept).startswith(""):
classes.append("unavailable")
dirname = posixpath.dirname(posixpath.dirname(concept.id))
region = by_dir.get(dirname)
region_label = region.label if region else (dirname or "")
href = html.escape(posixpath.relpath(concept.page, "."), quote=True)
images = offer_image_paths(concept) # porownanie.html leży w pages/
name = str(frontmatter.get("nazwa") or concept.title)
if images:
thumb = (
f'<a href="{href}"><img src="{html.escape(images[0], quote=True)}" '
f'alt="{html.escape(name, quote=True)}" loading="lazy"></a>'
)
else:
thumb = (
f'<a href="{href}" class="placeholder" title="brak zdjęć">'
f"{html.escape(initials(name))}</a>"
)
cells = [f'<td class="thumb" data-key="">{thumb}</td>']
for _, field, numeric in COLUMNS:
if field == "_nazwa":
cells.append(
f'<td class="name" data-key="{html.escape(name, quote=True)}">'
f'<a href="{href}">{html.escape(name)}</a></td>'
)
elif field == "_region":
page = html.escape(f"{region.concept_id}.html", quote=True) if region else ""
label = html.escape(region_label)
shown = f'<a href="{page}">{label}</a>' if region else label
cells.append(
f'<td data-key="{html.escape(region_label, quote=True)}">{shown}</td>'
)
elif field == "_dostepnosc":
cells.append(_availability_cell(availability(concept)))
elif field == "www":
url = str(frontmatter.get("www") or "").strip()
if url:
host = re.sub(r"^https?://(www\.)?", "", url).split("/")[0]
cells.append(
f'<td data-key="{html.escape(host, quote=True)}">'
f'<a href="{html.escape(url, quote=True)}" target="_blank" '
f'rel="noopener" class="ext">{html.escape(host)}</a></td>'
)
else:
cells.append('<td data-key="">—</td>')
else:
value = str(frontmatter.get(field) or "")
suffix = ""
if not value and field == "cena_za_apartament_tydzien":
# Dom dla całej grupy wyceniany jest w całości — pokazujemy
# to zamiast pustej komórki, ale z etykietą „za co”.
value = str(frontmatter.get("cena_za_calosc_tydzien") or "")
suffix = " (cały dom)" if value else ""
cells.append(
_cell(value, numeric, suffix, short=field not in NO_SHORTEN)
)
css_row = " ".join(c for c in classes if c)
klass = f' class="{css_row}"' if css_row else ""
rows.append(f"<tr{klass}>{''.join(cells)}</tr>")
return "".join(rows)
def _comparison_table(offers: list[Concept], by_dir: dict[str, Region]) -> str:
head = '<th data-key="">Zdjęcie</th>' + "".join(
f'<th scope="col" aria-sort="none">{html.escape(title)}</th>'
for title, _, _ in COLUMNS
)
return (
'<div class="table-wrap"><table class="sortable"><thead><tr>'
f"{head}</tr></thead><tbody>{_offer_rows(offers, by_dir)}</tbody>"
"</table></div>"
)
def _by_price(concept: Concept) -> tuple[float, str]:
# Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie,
# które grupa zadaje. Resztę porządków daje sortowanie klikiem.
return (
sort_number(str(concept.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9,
concept.title,
)
def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
"""Jedna tabela wszystkich aktualnych ofert bundle'a, prosto z frontmatterów.
Karty archiwalne (riscercz na poprzedni termin) idą do osobnej tabeli
na dole strony — nie mieszają się do porównania ani do statusów
dostępności, ale nie znikają z bazy.
"""
by_dir = {r.dirname: r for r in regions}
offers = sorted((c for c in concepts if is_offer(c)), key=_by_price)
active = [c for c in offers if not is_archived(c)]
archived = [c for c in offers if is_archived(c)]
table = _comparison_table(active, by_dir)
counts: dict[str, int] = {}
for concept in active:
mark = availability_mark(availability(concept))
counts[mark[1] if mark else ""] = counts.get(mark[1] if mark else "", 0) + 1
tally = " · ".join(f"{label}: {n}" for label, n in sorted(counts.items()))
archive = ""
if archived:
archive = (
'<section class="archive">\n'
"<h2>Archiwum — poza paczką riserczową 08.2026</h2>\n"
f'<p class="lead">Karty z riserczu na poprzedni termin '
f"(1623.01.2027). Ceny i dostępność nie były weryfikowane na "
f"{html.escape(TERM_LABEL)} — nie wchodzą do porównania wyżej ani "
"do statusów dostępności. Zostają, bo tropy są nadal dobre.</p>\n"
f"{_comparison_table(archived, by_dir)}\n"
"</section>"
)
summary = ""
if regions:
items = []
for region in regions:
budget = (
f'<span class="budget"><strong>Budżet grupy:</strong> '
f"{html.escape(region.budget)}</span>"
if region.budget
else ""
)
conclusion = (
f"<p>{render_inline(region.conclusion)}</p>" if region.conclusion else ""
)
items.append(
f'<li><a href="{html.escape(region.concept_id, quote=True)}.html">'
f"{html.escape(region.label)}</a><br>{budget}{conclusion}</li>"
)
summary = (
"<h2>Regiony w skrócie</h2>\n"
'<ul class="summary">' + "".join(items) + "</ul>"
)
content = (
"<h1>Porównanie ofert</h1>\n"
f'<p class="lead">Wszystkie {len(active)} rozważanych noclegów na termin '
f"<strong>{html.escape(TERM_LABEL)}</strong> ({html.escape(TERM_NOTE)}) "
"w jednej tabeli — dane prosto z frontmatterów kart. Kliknij nagłówek, "
"żeby posortować; domyślnie od najtańszej rodziny 2+2.</p>\n"
f"{summary}\n"
"<h2>Wszystkie oferty</h2>\n"
f"{table}\n"
'<p class="legend"><span class="box v"></span>cena zweryfikowana '
'&nbsp; <span class="box e"></span>szacunek do potwierdzenia '
'&nbsp; <span class="box u"></span>zweryfikowany brak miejsc<br>'
f"Dostępność {html.escape(TERM_SHORT)}: ✅ DOSTĘPNE (zweryfikowane) · "
"🔍 do sprawdzenia (obiekt nie publikuje kalendarza) · "
"❌ BRAK (zweryfikowane). "
f"Bilans: {html.escape(tally)}. Najedź na komórkę, żeby zobaczyć "
"pełną notatkę.</p>\n"
f"{archive}"
)
return _page_shell(
"Porównanie ofert", 0, content, body_class="wide", script=SORT_JS
)
# --- landing decyzyjny ------------------------------------------------
# start.html jest serwowany i jako /index.html, i jako /pages/start.html —
# tylko ścieżki root-absolutne działają pod obiema (patrz docstring modułu).
TILES = [
("/pages/porownanie.html", "Porównanie ofert",
"Wszystkie noclegi w jednej sortowalnej tabeli: dostępność na "
f"{TERM_SHORT}, ceny, status ceny, priorytet, odległość od wyciągu."),
("/pages/wyjazd.html", "Analiza wariantów",
"Cztery regiony obok siebie: skipassy, dojazd, budżet całkowity grupy "
"i wnioski przekrojowe."),
("/viz.html", "Graf powiązań",
"Struktura bazy wiedzy — koncepty, linki między nimi i adresy "
"zewnętrzne. Załącznik, nie punkt wejścia."),
("/pages/linki.html", "Wszystkie linki",
"Pełne adresy wszystkich stron wystawki, z przyciskiem „kopiuj” — "
"do wrzucenia grupie."),
]
def render_start(concepts: list[Concept], regions: list[Region]) -> str:
tiles = "".join(
f'<li><a href="{href}">{html.escape(title)}'
f"<small>{html.escape(description)}</small></a></li>"
for href, title, description in TILES
)
by_id = {c.id: c for c in concepts}
cards = []
for region in regions:
concept = by_id.get(region.concept_id)
description = region.budget or (
str(concept.frontmatter.get("description") or "") if concept else ""
)
style = ""
hero = region_hero(concepts, region)
if hero:
style = (
' class="hero" style="background-image:linear-gradient('
"rgba(15,23,42,.62),rgba(15,23,42,.72)),url('"
f"/pages/{html.escape(hero, quote=True)}')\""
)
label = html.escape(region.label)
budget = (
f"<small>Budżet grupy: {html.escape(region.budget)}</small>"
if region.budget
else f"<small>{html.escape(description)}</small>"
)
cards.append(
f'<li><a href="/pages/{html.escape(region.concept_id, quote=True)}.html"'
f"{style}>{label}{budget}</a></li>"
)
content = (
f"<h1>{html.escape(BUNDLE_NAME)}</h1>\n"
f'<p class="lead"><strong>{html.escape(TERM_LABEL)}</strong> '
f"({html.escape(TERM_NOTE)}), 4 rodziny / 14 osób, wyjazd z Warszawy. "
"Od czego zacząć:</p>\n"
f'<ul class="tiles">{tiles}</ul>\n'
"<h2>Regiony</h2>\n"
'<p class="lead">Karta regionu = skipassy, dojazd, terminy, koszty '
"życia i budżet całkowity.</p>\n"
f'<ul class="regions">{"".join(cards)}</ul>'
)
nav = (
'<a href="/pages/index.html">Wszystkie karty</a> · '
'<a href="/pages/porownanie.html">Porównanie ofert</a> · '
'<a href="/pages/linki.html">Linki</a> · '
'<a href="/viz.html">Graf</a>'
)
return _page_shell("Start", 0, content, nav=nav)
def region_hero(concepts: list[Concept], region: Region) -> str:
"""Pierwsze zdjęcie najlepszej oferty regionu (priorytet PLAN A)."""
offers = [
c
for c in concepts
if is_offer(c) and posixpath.dirname(posixpath.dirname(c.id)) == region.dirname
]
def rank(concept: Concept) -> tuple[int, str]:
priority = str(concept.frontmatter.get("priorytet") or "").upper()
for n, marker in enumerate(("PLAN A", "PLAN B", "PLAN C")):
if priority.startswith(marker):
return (n, concept.id)
return (9, concept.id)
for concept in sorted(offers, key=rank):
images = offer_image_paths(concept)
if images:
return images[0]
return ""
# --- spis linków do wysłania ------------------------------------------
@dataclass
class Link:
url: str
label: str
description: str = ""
def site_url(path: str) -> str:
"""Ścieżka w bundle'u → pełny adres publiczny."""
return f"{BASE_URL}/{path.lstrip('/')}"
def _describe(concept: Concept | None, limit: int = 110) -> str:
"""Opis konceptu przycięty do jednej linijki — bez skracania po separatorze,
bo w opisach nawias i myślnik niosą treść (inaczej niż w polach tabeli)."""
if not concept:
return ""
text = str(concept.frontmatter.get("description") or "")
if len(text) > limit:
text = text[: limit - 1].rsplit(" ", 1)[0] + ""
return text
def link_sections(
concepts: list[Concept], regions: list[Region]
) -> list[tuple[str, list[Link]]]:
"""Spis wszystkich stron wystawki — z tych samych danych co reszta stron.
Nic tu nie jest wypisane ręcznie: regiony biorą się z `wyjazd.md`, oferty
z katalogów i frontmatterów, a „Pozostałe” to po prostu koncepty, których
nie złapała żadna wcześniejsza sekcja. Nowa oferta czy piąty wariant
pojawiają się na liście przy najbliższej regeneracji, same z siebie.
"""
by_id = {c.id: c for c in concepts}
used: set[str] = set()
entry = [
Link(site_url(""), "Start", "Landing decyzyjny — od tego zaczyna grupa."),
Link(
site_url("pages/porownanie.html"),
"Porównanie ofert",
"Wszystkie noclegi w jednej sortowalnej tabeli, ze zdjęciami.",
),
Link(
site_url("pages/wyjazd.html"),
"Analiza wariantów",
_describe(by_id.get("wyjazd")) or "Cztery regiony obok siebie.",
),
Link(
site_url("pages/index.html"),
"Spis wszystkich kart",
"Każdy dokument bazy wiedzy w jednym miejscu.",
),
Link(site_url("viz.html"), "Graf powiązań", "Struktura bazy — załącznik."),
Link(site_url("pages/linki.html"), "Ten spis linków", "Strona, którą czytasz."),
]
used.add("wyjazd")
sections: list[tuple[str, list[Link]]] = [("Wejście", entry)]
region_links = []
for region in regions:
concept = by_id.get(region.concept_id)
if not concept:
continue
used.add(region.concept_id)
budget = f"Budżet grupy {region.budget}. " if region.budget else ""
region_links.append(
Link(
site_url(f"{PAGES_DIRNAME}/{concept.page}"),
region.label,
budget + _describe(concept, 90),
)
)
if region_links:
sections.append(("Regiony", region_links))
for region in regions:
offers = [
c
for c in concepts
if is_offer(c)
and posixpath.dirname(posixpath.dirname(c.id)) == region.dirname
]
if not offers:
continue
links = []
for concept in sorted(offers, key=_priority_rank):
used.add(concept.id)
priority = _shorten(str(concept.frontmatter.get("priorytet") or ""))
place = str(concept.frontmatter.get("miejscowosc") or "")
links.append(
Link(
site_url(f"{PAGES_DIRNAME}/{concept.page}"),
str(concept.frontmatter.get("nazwa") or concept.title),
" · ".join(part for part in (priority, place) if part),
)
)
sections.append((f"Oferty — {region.label}", links))
rest = [
Link(
site_url(f"{PAGES_DIRNAME}/{c.page}"),
c.title,
_describe(c),
)
for c in concepts
if c.id not in used
]
rest.append(
Link(
site_url(f"{PAGES_DIRNAME}/start.html"),
"Start pod własną ścieżką",
"Ten sam landing co adres główny — przydaje się w zakładkach.",
)
)
sections.append(("Pozostałe", rest))
return sections
def _priority_rank(concept: Concept) -> tuple[int, str]:
priority = str(concept.frontmatter.get("priorytet") or "").upper()
for n, marker in enumerate(("PLAN A", "PLAN B", "PLAN C")):
if priority.startswith(marker):
return (n, concept.title)
return (9, concept.title)
LINKS_JS = """
function collectAll() {
var lines = [];
document.querySelectorAll('section[data-section]').forEach(function (section) {
lines.push(section.dataset.section + ':');
section.querySelectorAll('.url').forEach(function (code) {
lines.push(code.textContent.trim());
});
lines.push('');
});
return lines.join('\\n').trim() + '\\n';
}
function say(message) {
var status = document.querySelector('.copy-status');
status.textContent = message;
window.setTimeout(function () {
if (status.textContent === message) status.textContent = '';
}, 4000);
}
// navigator.clipboard istnieje tylko w bezpiecznym kontekście (https albo
// localhost) — po http z adresu IP trzeba starą drogą przez zaznaczenie.
function copyLegacy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.top = '-1000px';
document.body.appendChild(area);
area.select();
var copied = false;
try { copied = document.execCommand('copy'); } catch (error) { copied = false; }
document.body.removeChild(area);
return copied;
}
function copyText(text, what) {
var ok = function () { say('Skopiowane: ' + what); };
var no = function () { say('Nie udało się skopiować — zaznacz adres ręcznie.'); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(ok, function () {
if (copyLegacy(text)) { ok(); } else { no(); }
});
} else if (copyLegacy(text)) { ok(); } else { no(); }
}
document.getElementById('copy-all').addEventListener('click', function () {
copyText(collectAll(), 'cała lista');
});
document.getElementById('copy-start').addEventListener('click', function () {
copyText(this.dataset.url, 'link startowy');
});
"""
def render_links(concepts: list[Concept], regions: list[Region]) -> str:
sections = link_sections(concepts, regions)
blocks = []
total = 0
for title, links in sections:
if not links:
continue
items = []
for link in links:
total += 1
escaped = html.escape(link.url, quote=True)
description = (
f"<p>{html.escape(link.description)}</p>" if link.description else ""
)
items.append(
f'<li><a href="{escaped}">{html.escape(link.label)}</a>'
f'<code class="url">{html.escape(link.url)}</code>{description}</li>'
)
blocks.append(
f'<section data-section="{html.escape(title, quote=True)}">'
f"<h2>{html.escape(title)}</h2>"
f'<ul class="links">{"".join(items)}</ul></section>'
)
content = (
"<h1>Linki do wysłania</h1>\n"
f'<p class="lead">Wszystkie {total} stron wystawki jako pełne adresy — '
"do skopiowania i wrzucenia grupie. Lista powstaje z tych samych danych "
"co reszta stron, więc nowa oferta pojawi się tu sama.</p>\n"
'<div class="copybar">'
'<button type="button" id="copy-all">Kopiuj wszystkie</button>'
f'<button type="button" id="copy-start" data-url="{html.escape(site_url(""), quote=True)}">'
"Kopiuj link startowy</button>"
'<span class="copy-status" role="status" aria-live="polite"></span>'
"</div>\n" + "\n".join(blocks)
)
return _page_shell("Linki", 0, content, script=LINKS_JS)
# --- 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 is_offer(concept) or is_archived(concept):
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})")
# Dostępność czytamy z pola frontmattera, nie z nagłówka w tekście —
# werdykt ✅/❌/🔍 jest danymi, a nie tym, czy ktoś napisał sekcję.
value = availability(concept)
if not value:
gaps.append(f"brak pola `{AVAILABILITY_FIELD}`")
elif value.startswith("🔍"):
gaps.append(f"dostępność {TERM_LABEL} do weryfikacji ({_shorten(value)})")
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 -------------------------------------------------------------
DEPLOY_COMMAND = """ssh piha 'rm -rf /tmp/narty27-deploy && mkdir -p /tmp/narty27-deploy' \\
&& scp -r viz.html pages piha:/tmp/narty27-deploy/ \\
&& ssh piha 'docker run --rm -v narty27_narty27_content:/content \\
-v /tmp/narty27-deploy:/src:ro alpine sh -c \\
"rm -rf /content/pages && cp -r /src/viz.html /src/pages /content/ \\
&& cp /src/pages/start.html /content/index.html && ls -la /content"' \\
&& ssh piha 'rm -rf /tmp/narty27-deploy'"""
def main() -> int:
concepts = load_concepts()
pages = {c.id: c.page for c in concepts}
regions = parse_regions()
# Katalog img/ jest wejściem, nie wyjściem tego skryptu (wypełnia go
# gen_images.py) — czyścimy wszystko poza nim.
PAGES_DIR.mkdir(parents=True, exist_ok=True)
for entry in PAGES_DIR.iterdir():
if entry.name == IMG_DIRNAME:
continue
shutil.rmtree(entry) if entry.is_dir() else entry.unlink()
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)
for name, markup in (
("index.html", render_index(concepts)),
("porownanie.html", render_comparison(concepts, regions)),
("linki.html", render_links(concepts, regions)),
("start.html", render_start(concepts, regions)),
):
path = PAGES_DIR / name
path.write_text(markup, encoding="utf-8")
written.append(path)
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)")
print()
offers = [c for c in concepts if is_offer(c)]
with_photos = [c for c in offers if offer_image_paths(c)]
print(
f"Regiony z wyjazd.md: {len(regions)} "
f"({', '.join(r.label for r in regions) or ''})"
)
active = [c for c in offers if not is_archived(c)]
print(
f"Oferty w tabeli porównawczej: {len(active)} "
f"(+{len(offers) - len(active)} archiwalnych w osobnej sekcji), "
f"ze zdjęciami: {len(with_photos)} (zdjęcia dokłada gen_images.py)"
)
marks = [availability_mark(availability(c)) for c in active]
print(
f"Dostępność {TERM_LABEL}: "
+ " · ".join(
f"{label}: {sum(1 for m in marks if m and m[1] == label)}"
for _, label, _ in AVAILABILITY_MARKS
)
+ f" · bez pola: {sum(1 for m in marks if m is None)}"
)
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.")
print()
print("Deploy na PIHA (korzeń serwisu = start.html, graf pod /viz.html):")
print(DEPLOY_COMMAND)
return 0
if __name__ == "__main__":
raise SystemExit(main())