Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Renderuje karty HTML konceptów bundle'a do katalogu pages/.
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
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).
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
Renderer markdown: używa `python-markdown`, jeśli jest zainstalowany
|
|
|
|
|
|
(rozszerzenia tables + fenced_code + sane_lists); w przeciwnym razie własnego
|
|
|
|
|
|
renderera na samej bibliotece standardowej — obsługuje nagłówki, akapity,
|
|
|
|
|
|
tabele GFM, listy (w tym `- [ ]`), cytaty, bloki kodu, linie poziome oraz
|
|
|
|
|
|
inline: **bold**, *kursywa*, `kod`, [linki](...). Który tor zadziałał, skrypt
|
|
|
|
|
|
wypisuje przy uruchomieniu.
|
|
|
|
|
|
|
|
|
|
|
|
Pliki .md NIE są modyfikowane — to czysty generator, tak samo jak gen_viz.py.
|
|
|
|
|
|
Pliki zarezerwowane (index.md, log.md) nie są konceptami (§3.1) i nie
|
|
|
|
|
|
dostają kart; linki do nich prowadzą do pages/index.html.
|
|
|
|
|
|
|
|
|
|
|
|
Uruchomienie: python3 gen_pages.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import html
|
|
|
|
|
|
import posixpath
|
|
|
|
|
|
import re
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
|
|
BUNDLE_ROOT = Path(__file__).resolve().parent
|
|
|
|
|
|
PAGES_DIRNAME = "pages"
|
|
|
|
|
|
PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME
|
|
|
|
|
|
BUNDLE_NAME = "Ski 2027 — Skicircus Saalbach"
|
|
|
|
|
|
RESERVED = {"index.md", "log.md"}
|
|
|
|
|
|
|
|
|
|
|
|
try: # renderer preferowany, jeśli dostępny w systemie
|
|
|
|
|
|
import markdown as _markdown_lib
|
|
|
|
|
|
except ModuleNotFoundError:
|
|
|
|
|
|
_markdown_lib = None
|
|
|
|
|
|
|
|
|
|
|
|
RENDERER = "python-markdown" if _markdown_lib else "fallback (biblioteka standardowa)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- model ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Concept:
|
|
|
|
|
|
id: str # np. "oferty/glemm-lodge"
|
|
|
|
|
|
path: Path
|
|
|
|
|
|
frontmatter: dict
|
|
|
|
|
|
body: str
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def page(self) -> str:
|
|
|
|
|
|
"""Ścieżka karty względem katalogu pages/."""
|
|
|
|
|
|
return f"{self.id}.html"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def title(self) -> str:
|
|
|
|
|
|
return str(self.frontmatter.get("title") or self.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_concepts() -> list[Concept]:
|
|
|
|
|
|
concepts: list[Concept] = []
|
|
|
|
|
|
for path in sorted(BUNDLE_ROOT.rglob("*.md")):
|
|
|
|
|
|
if ".git" in path.parts or path.name in RESERVED:
|
|
|
|
|
|
continue
|
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
|
frontmatter: dict = {}
|
|
|
|
|
|
body = text
|
|
|
|
|
|
if text.startswith("---\n"):
|
|
|
|
|
|
end = text.find("\n---", 3)
|
|
|
|
|
|
if end != -1:
|
|
|
|
|
|
frontmatter = yaml.safe_load(text[4:end]) or {}
|
|
|
|
|
|
body = text[end + 4 :]
|
|
|
|
|
|
concepts.append(
|
|
|
|
|
|
Concept(
|
|
|
|
|
|
id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(),
|
|
|
|
|
|
path=path,
|
|
|
|
|
|
frontmatter=frontmatter,
|
|
|
|
|
|
body=body.lstrip("\n"),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return concepts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- linki ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+\.md)((?:#[^)\s]*)?)\)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rewrite_links(body: str, source: Concept, pages: dict[str, str]) -> str:
|
|
|
|
|
|
"""Linki do plików .md → względne ścieżki do kart. External bez zmian."""
|
|
|
|
|
|
here = posixpath.dirname(source.page)
|
|
|
|
|
|
|
|
|
|
|
|
def repl(match: re.Match) -> str:
|
|
|
|
|
|
target, fragment = match.group(1), match.group(2)
|
|
|
|
|
|
if "://" in target:
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
if target.startswith("/"):
|
|
|
|
|
|
rel = target[1:]
|
|
|
|
|
|
else:
|
|
|
|
|
|
rel = posixpath.normpath(posixpath.join(posixpath.dirname(source.id), target))
|
|
|
|
|
|
concept_id = rel[:-3] if rel.endswith(".md") else rel
|
|
|
|
|
|
if concept_id in pages:
|
|
|
|
|
|
dest = pages[concept_id]
|
|
|
|
|
|
elif posixpath.basename(rel) in RESERVED:
|
|
|
|
|
|
dest = "index.html" # index.md / log.md nie mają kart
|
|
|
|
|
|
else:
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
return "](" + posixpath.relpath(dest, here or ".") + fragment + ")"
|
|
|
|
|
|
|
|
|
|
|
|
return _LINK_TARGET_RE.sub(repl, body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- renderer markdown: fallback na stdlib -----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_CODE_RE = re.compile(r"`([^`]+)`")
|
|
|
|
|
|
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
|
|
|
|
|
|
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
|
|
|
|
|
|
_ITALIC_STAR_RE = re.compile(r"(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)")
|
|
|
|
|
|
_ITALIC_UNDER_RE = re.compile(r"(?<![\w\\])_(?!\s)([^_]+?)(?<!\s)_(?![\w])")
|
|
|
|
|
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
|
|
|
|
_HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})$")
|
|
|
|
|
|
_LIST_RE = re.compile(r"^\s*([-*+]|\d+[.)])\s+(.*)$")
|
|
|
|
|
|
_TASK_RE = re.compile(r"^\[([ xX])\]\s+(.*)$")
|
|
|
|
|
|
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_inline(text: str) -> str:
|
|
|
|
|
|
codes: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
def stash(match: re.Match) -> str:
|
|
|
|
|
|
codes.append(match.group(1))
|
|
|
|
|
|
return f"\x00c{len(codes) - 1}\x00"
|
|
|
|
|
|
|
|
|
|
|
|
text = _CODE_RE.sub(stash, text)
|
|
|
|
|
|
text = html.escape(text, quote=False)
|
|
|
|
|
|
|
|
|
|
|
|
def link(match: re.Match) -> str:
|
|
|
|
|
|
label, href = match.group(1), match.group(2)
|
|
|
|
|
|
attrs = ""
|
|
|
|
|
|
if "://" in href or href.startswith("mailto:"):
|
|
|
|
|
|
attrs = ' target="_blank" rel="noopener" class="ext"'
|
|
|
|
|
|
return f'<a href="{html.escape(href, quote=True)}"{attrs}>{label}</a>'
|
|
|
|
|
|
|
|
|
|
|
|
text = _LINK_RE.sub(link, text)
|
|
|
|
|
|
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
|
|
|
|
|
|
text = _ITALIC_STAR_RE.sub(r"<em>\1</em>", text)
|
|
|
|
|
|
text = _ITALIC_UNDER_RE.sub(r"<em>\1</em>", text)
|
|
|
|
|
|
|
|
|
|
|
|
for i, code in enumerate(codes):
|
|
|
|
|
|
text = text.replace(f"\x00c{i}\x00", f"<code>{html.escape(code, quote=False)}</code>")
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _alignments(sep: str) -> list[str]:
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for cell in _cells(sep):
|
|
|
|
|
|
left, right = cell.startswith(":"), cell.endswith(":")
|
|
|
|
|
|
out.append("center" if left and right else "right" if right else "left")
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cells(row: str) -> list[str]:
|
|
|
|
|
|
return [c.strip() for c in row.strip().strip("|").split("|")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _starts_block(line: str) -> bool:
|
|
|
|
|
|
s = line.strip()
|
|
|
|
|
|
return (
|
|
|
|
|
|
not s
|
|
|
|
|
|
or bool(_HEADING_RE.match(s))
|
|
|
|
|
|
or bool(_HR_RE.match(s))
|
|
|
|
|
|
or s.startswith(("```", ">"))
|
|
|
|
|
|
or bool(_LIST_RE.match(line))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_blocks(text: str) -> str:
|
|
|
|
|
|
lines = text.split("\n")
|
|
|
|
|
|
out: list[str] = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
line = lines[i]
|
|
|
|
|
|
stripped = line.strip()
|
|
|
|
|
|
|
|
|
|
|
|
if not stripped:
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if stripped.startswith("```"):
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
code: list[str] = []
|
|
|
|
|
|
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
|
|
|
|
code.append(lines[i])
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
out.append(f"<pre><code>{html.escape(chr(10).join(code), quote=False)}</code></pre>")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
heading = _HEADING_RE.match(stripped)
|
|
|
|
|
|
if heading:
|
|
|
|
|
|
level = len(heading.group(1))
|
|
|
|
|
|
out.append(f"<h{level}>{render_inline(heading.group(2))}</h{level}>")
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if _HR_RE.match(stripped):
|
|
|
|
|
|
out.append("<hr>")
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
|
|
|
|
|
|
header = _cells(stripped)
|
|
|
|
|
|
align = _alignments(lines[i + 1])
|
|
|
|
|
|
i += 2
|
|
|
|
|
|
rows = []
|
|
|
|
|
|
while i < len(lines) and "|" in lines[i] and lines[i].strip():
|
|
|
|
|
|
rows.append(_cells(lines[i]))
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
head = "".join(
|
|
|
|
|
|
f'<th style="text-align:{align[n] if n < len(align) else "left"}">'
|
|
|
|
|
|
f"{render_inline(c)}</th>"
|
|
|
|
|
|
for n, c in enumerate(header)
|
|
|
|
|
|
)
|
|
|
|
|
|
body = "".join(
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
+ "".join(
|
|
|
|
|
|
f'<td style="text-align:{align[n] if n < len(align) else "left"}">'
|
|
|
|
|
|
f"{render_inline(c)}</td>"
|
|
|
|
|
|
for n, c in enumerate(row)
|
|
|
|
|
|
)
|
|
|
|
|
|
+ "</tr>"
|
|
|
|
|
|
for row in rows
|
|
|
|
|
|
)
|
|
|
|
|
|
out.append(
|
|
|
|
|
|
f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
|
|
|
|
|
|
f"<tbody>{body}</tbody></table></div>"
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if stripped.startswith(">"):
|
|
|
|
|
|
quoted = []
|
|
|
|
|
|
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
|
|
|
|
quoted.append(lines[i].strip()[1:].lstrip())
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
out.append(f"<blockquote>{render_blocks(chr(10).join(quoted))}</blockquote>")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
item = _LIST_RE.match(line)
|
|
|
|
|
|
if item:
|
|
|
|
|
|
ordered = item.group(1)[0].isdigit()
|
|
|
|
|
|
items: list[str] = []
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
current = lines[i]
|
|
|
|
|
|
match = _LIST_RE.match(current)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
items.append(match.group(2))
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
elif current.startswith((" ", "\t")) and current.strip() and items:
|
|
|
|
|
|
items[-1] += " " + current.strip()
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
rendered = []
|
|
|
|
|
|
for raw in items:
|
|
|
|
|
|
task = _TASK_RE.match(raw)
|
|
|
|
|
|
if task:
|
|
|
|
|
|
checked = " checked" if task.group(1).lower() == "x" else ""
|
|
|
|
|
|
rendered.append(
|
|
|
|
|
|
f'<li class="task"><input type="checkbox" disabled{checked}> '
|
|
|
|
|
|
f"{render_inline(task.group(2))}</li>"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
rendered.append(f"<li>{render_inline(raw)}</li>")
|
|
|
|
|
|
tag = "ol" if ordered else "ul"
|
|
|
|
|
|
out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
paragraph = [stripped]
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
while i < len(lines) and not _starts_block(lines[i]):
|
|
|
|
|
|
if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
|
|
|
|
|
|
break
|
|
|
|
|
|
paragraph.append(lines[i].strip())
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
out.append(f"<p>{render_inline(' '.join(paragraph))}</p>")
|
|
|
|
|
|
|
|
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def md_to_html(text: str) -> str:
|
|
|
|
|
|
if _markdown_lib:
|
|
|
|
|
|
return _markdown_lib.markdown(
|
|
|
|
|
|
text, extensions=["tables", "fenced_code", "sane_lists"]
|
|
|
|
|
|
)
|
|
|
|
|
|
return render_blocks(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- szablon ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
CSS = """
|
|
|
|
|
|
:root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff;
|
|
|
|
|
|
--line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; }
|
|
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
|
|
|
|
:root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155;
|
|
|
|
|
|
--accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; }
|
|
|
|
|
|
}
|
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
|
body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem;
|
|
|
|
|
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
|
font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg);
|
|
|
|
|
|
-webkit-text-size-adjust: 100%; }
|
|
|
|
|
|
nav { font-size: .85rem; margin-bottom: 1.25rem; }
|
|
|
|
|
|
nav a { color: var(--muted); }
|
|
|
|
|
|
h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; }
|
|
|
|
|
|
h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem;
|
|
|
|
|
|
border-top: 1px solid var(--line); }
|
|
|
|
|
|
h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; }
|
|
|
|
|
|
p, ul, ol { margin: .6rem 0; }
|
|
|
|
|
|
ul, ol { padding-left: 1.3rem; }
|
|
|
|
|
|
li { margin: .25rem 0; }
|
|
|
|
|
|
li.task { list-style: none; margin-left: -1.3rem; }
|
|
|
|
|
|
li.task input { margin-right: .45rem; }
|
|
|
|
|
|
a { color: var(--accent); }
|
|
|
|
|
|
a.ext::after { content: " ↗"; font-size: .8em; color: var(--muted); }
|
|
|
|
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em;
|
|
|
|
|
|
background: var(--chip); padding: .1em .35em; border-radius: 4px; }
|
|
|
|
|
|
pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; }
|
|
|
|
|
|
pre code { background: none; padding: 0; }
|
|
|
|
|
|
blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote);
|
|
|
|
|
|
border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; }
|
|
|
|
|
|
blockquote p { margin: .2rem 0; }
|
|
|
|
|
|
hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; }
|
|
|
|
|
|
.table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; }
|
|
|
|
|
|
table { border-collapse: collapse; width: 100%; font-size: .92rem; }
|
|
|
|
|
|
th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; }
|
|
|
|
|
|
th { background: var(--chip); font-weight: 600; }
|
|
|
|
|
|
.chip { display: inline-block; background: var(--chip); color: var(--muted);
|
|
|
|
|
|
font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
|
|
|
|
|
|
padding: .15rem .5rem; border-radius: 999px; }
|
|
|
|
|
|
.lead { color: var(--muted); margin: .35rem 0 1.25rem; }
|
|
|
|
|
|
.meta { margin: 0 0 1.5rem; }
|
|
|
|
|
|
.meta summary { cursor: pointer; font-size: .85rem; color: var(--muted);
|
|
|
|
|
|
padding: .3rem 0; }
|
|
|
|
|
|
.meta table { font-size: .85rem; }
|
|
|
|
|
|
.meta th { width: 38%; text-align: left; font-weight: 500; word-break: break-word; }
|
|
|
|
|
|
.cards { list-style: none; padding: 0; margin: 0; }
|
|
|
|
|
|
.cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem;
|
|
|
|
|
|
margin: .6rem 0; }
|
|
|
|
|
|
.cards a { font-weight: 600; text-decoration: none; }
|
|
|
|
|
|
.cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); }
|
|
|
|
|
|
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
|
|
|
|
|
|
font-size: .78rem; color: var(--muted); }
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
|
|
|
|
|
|
/* 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(3, 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; }
|
|
|
|
|
|
|
|
|
|
|
|
/* 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); });
|
|
|
|
|
|
}
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
def _page_shell(
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
depth: int,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
body_class: str = "",
|
|
|
|
|
|
nav: str | None = None,
|
|
|
|
|
|
script: str = "",
|
|
|
|
|
|
) -> str:
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
up = "../" * depth
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
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}../viz.html">Graf</a>'
|
|
|
|
|
|
)
|
|
|
|
|
|
css_class = f' class="{body_class}"' if body_class else ""
|
|
|
|
|
|
script_tag = f"\n<script>{script}</script>" if script else ""
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
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>
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
<body{css_class}>
|
|
|
|
|
|
<nav>{nav}</nav>
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
{content}
|
|
|
|
|
|
<footer>Wygenerowane z bundle'a OKF przez <code>gen_pages.py</code> — nie edytuj
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
tych plików, edytuj <code>.md</code> i przegeneruj.</footer>{script_tag}
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
</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 ""
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
images = offer_images(concept) if is_offer(concept) else []
|
|
|
|
|
|
gallery = render_gallery(images, str(frontmatter.get("nazwa") or concept.title))
|
|
|
|
|
|
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
content = (
|
|
|
|
|
|
f'<span class="chip">{chip}</span>\n'
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
f"<h1>{headline}</h1>\n{lead}\n{gallery}\n"
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
f"{_meta_table(frontmatter)}\n{rendered}"
|
|
|
|
|
|
)
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
return _page_shell(
|
|
|
|
|
|
concept.title,
|
|
|
|
|
|
concept.id.count("/"),
|
|
|
|
|
|
content,
|
|
|
|
|
|
script=GALLERY_JS if len(images) > 1 else "",
|
|
|
|
|
|
)
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 16:23:25 +02:00
|
|
|
|
_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
|
|
|
|
|
|
|
|
|
|
|
|
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
def render_index(concepts: list[Concept]) -> str:
|
2026-08-03 16:23:25 +02:00
|
|
|
|
"""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)
|
|
|
|
|
|
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
groups: list[tuple[str, list[Concept]]] = [
|
2026-08-03 16:23:25 +02:00
|
|
|
|
("Wyjazd i wiedza ogólna", [c for c in concepts if "/" not in c.id])
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
]
|
2026-08-03 16:23:25 +02:00
|
|
|
|
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))
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
# --- 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 '
|
|
|
|
|
|
' <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."),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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">16–23.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="/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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
# --- raport braków ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]:
|
|
|
|
|
|
"""Czego brakuje w ofertach — bez zgadywania, tylko z tego, co jest."""
|
|
|
|
|
|
report = []
|
|
|
|
|
|
for concept in concepts:
|
|
|
|
|
|
if not str(concept.frontmatter.get("type", "")).startswith("oferta"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
gaps = []
|
|
|
|
|
|
status = str(concept.frontmatter.get("status_ceny") or "")
|
|
|
|
|
|
if not status:
|
|
|
|
|
|
gaps.append("brak pola `status_ceny`")
|
|
|
|
|
|
elif "SZACUNEK" in status.upper():
|
|
|
|
|
|
gaps.append(f"cena niepotwierdzona ({status})")
|
|
|
|
|
|
# Danymi o dostępności jest osobna sekcja (jak w glemm-lodge), a nie
|
|
|
|
|
|
# samo słowo w tekście — "duża dostępność" w opisie się nie liczy.
|
|
|
|
|
|
if not re.search(r"^#+\s*.*dostępnoś", concept.body, re.IGNORECASE | re.M):
|
|
|
|
|
|
gaps.append("brak danych o dostępności terminu 16–23.01.2027")
|
|
|
|
|
|
open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M))
|
|
|
|
|
|
if open_todos:
|
|
|
|
|
|
gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}")
|
|
|
|
|
|
if gaps:
|
|
|
|
|
|
report.append((concept.id, gaps))
|
|
|
|
|
|
return report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- main -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
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'"""
|
|
|
|
|
|
|
|
|
|
|
|
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
def main() -> int:
|
|
|
|
|
|
concepts = load_concepts()
|
|
|
|
|
|
pages = {c.id: c.page for c in concepts}
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
regions = parse_regions()
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
# 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()
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
for name, markup in (
|
|
|
|
|
|
("index.html", render_index(concepts)),
|
|
|
|
|
|
("porownanie.html", render_comparison(concepts, regions)),
|
|
|
|
|
|
("start.html", render_start(concepts, regions)),
|
|
|
|
|
|
):
|
|
|
|
|
|
path = PAGES_DIR / name
|
|
|
|
|
|
path.write_text(markup, encoding="utf-8")
|
|
|
|
|
|
written.append(path)
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
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)")
|
|
|
|
|
|
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
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)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
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.")
|
Przeprojektowanie pod decyzję: start.html, porownanie.html, galerie
Wejściem jest teraz landing pages/start.html (trzy kafle: porównanie ofert,
analiza wariantów, graf + kafle czterech regionów ze zdjęciem najlepszej
oferty w tle), a graf schodzi do roli załącznika. start.html linkuje
root-absolutnie, bo ten sam plik jest serwowany jako /index.html i jako
/pages/start.html.
pages/porownanie.html: jedna tabela wszystkich 12 ofert prosto z
frontmatterów (miniatura, priorytet, region, ceny, status, odległość, www),
sortowanie klikiem w nagłówek (czysty JS, bez zależności), wiersze barwione
statusem ceny. Nad tabelą podsumowanie per region — budżet całkowity i jeden
wniosek parsowane z tabeli i „Wniosków przekrojowych” w wyjazd.md, bez
zaszywania nazw regionów w kodzie.
Karty ofert dostały galerię (duże zdjęcie + miniatury, lazy loading),
a nawigacja na każdej stronie prowadzi do startu i porównania.
Komenda deployu (kopiuje start.html jako /content/index.html) jest
wypisywana przez gen_pages.py. Pliki .md bez zmian, check_okf zielony.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:24:25 +02:00
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("Deploy na PIHA (korzeń serwisu = start.html, graf pod /viz.html):")
|
|
|
|
|
|
print(DEPLOY_COMMAND)
|
Karty HTML konceptów: gen_pages.py + katalog pages/
Graf pokazywał strukturę, ale treść (ceny, dostępność, plusy/minusy)
siedziała tylko w panelu szczegółów viewera. gen_pages.py renderuje każdy
koncept do pages/<id>.html: metadane frontmattera jako tabelka (wszystkie
pola, też polskie rozszerzenia), body poniżej, plus pages/index.html
z listą kart.
Renderer: python-markdown, jeśli jest w systemie; tutaj go nie ma, więc
działa fallback na samej bibliotece standardowej (nagłówki, akapity,
tabele GFM, listy z - [ ], cytaty, kod, hr, inline bold/kursywa/kod/linki).
Linki miedzy konceptami przepisane na względne <id>.html, linki
zewnętrzne bez zmian. Pliki zarezerwowane (index.md, log.md) nie są
konceptami (§3.1) i nie mają kart — linki do nich prowadzą do listy kart.
Pliki .md nietknięte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:46:08 +02:00
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|