narty-2027/gen_pages.py
oskar 0d0acdb3a4 pages/linki.html: spis wszystkich adresów wystawki do wysłania
Nowa strona z pełnymi URL-ami wszystkich 24 stron na https://narty27.kapala.org
(BASE_URL — jedyne miejsce z domeną w kodzie), w sekcjach: Wejście, Regiony,
Oferty per region z priorytetem PLAN A/B z frontmattera, Pozostałe. Każda
pozycja to klikalny link, widoczny adres tekstem do skopiowania i krótki opis.

Lista powstaje z tych samych danych co porownanie.html i start.html —
regiony z wyjazd.md, oferty z katalogów i frontmatterów, "Pozostałe" to
koncepty, których nie złapała żadna wcześniejsza sekcja. Nowa oferta wpada
na listę sama przy regeneracji.

Przyciski "Kopiuj wszystkie" (plain text: nazwa sekcji + adresy) i "Kopiuj
link startowy"; navigator.clipboard z odwrotem na zaznaczenie, bo po http
z adresu IP nie ma bezpiecznego kontekstu. Do nawigacji każdej strony
i na start.html doszła pozycja "Linki".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:37:14 +02:00

1484 lines
56 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 ofert (nie regionów),
sortowalna klikiem, złożona z samych frontmatterów;
na górze podsumowanie per region z `wyjazd.md`
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 — Skicircus Saalbach"
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(13rem, 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); }
.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); }
/* 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.</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),
("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")
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 render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
"""Jedna tabela wszystkich ofert bundle'a, prosto z frontmatterów."""
by_dir = {r.dirname: r for r in regions}
offers = [c for c in concepts if is_offer(c)]
# Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie,
# które grupa zadaje. Resztę porządków daje sortowanie klikiem.
offers.sort(
key=lambda c: (
sort_number(str(c.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9,
c.title,
)
)
rows = []
for concept in offers:
frontmatter = concept.frontmatter
status = str(frontmatter.get("status_ceny") or "")
upper = status.upper()
css_row = (
"verified" if "ZWERYFIKOWANE" in upper
else "estimate" if "SZACUNEK" in upper
else ""
)
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 == "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)
)
klass = f' class="{css_row}"' if css_row else ""
rows.append(f"<tr{klass}>{''.join(cells)}</tr>")
head = '<th data-key="">Zdjęcie</th>' + "".join(
f'<th scope="col" aria-sort="none">{html.escape(title)}</th>'
for title, _, _ in COLUMNS
)
table = (
'<div class="table-wrap"><table class="sortable"><thead><tr>'
f"{head}</tr></thead><tbody>{''.join(rows)}</tbody></table></div>"
)
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(offers)} rozważanych noclegów 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</p>'
)
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: 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", "Linki do wysłania",
"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"
'<p class="lead">1623.01.2027, 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 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 -------------------------------------------------------------
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 ''})"
)
print(
f"Oferty w tabeli porównawczej: {len(offers)}, "
f"ze zdjęciami: {len(with_photos)} (zdjęcia dokłada gen_images.py)"
)
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())