550 lines
20 KiB
Python
550 lines
20 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""Renderuje karty HTML konceptów bundle'a do katalogu pages/.
|
|||
|
|
|
|||
|
|
Graf (viz.html) pokazuje strukturę; karty pokazują treść — ceny, dostępność,
|
|||
|
|
plusy/minusy, listy do zrobienia. Węzeł w grafie linkuje do karty przez pole
|
|||
|
|
`resource` (patrz gen_viz.py).
|
|||
|
|
|
|||
|
|
Renderer markdown: używa `python-markdown`, jeśli jest zainstalowany
|
|||
|
|
(rozszerzenia tables + fenced_code + sane_lists); w przeciwnym razie własnego
|
|||
|
|
renderera na samej bibliotece standardowej — obsługuje nagłówki, akapity,
|
|||
|
|
tabele GFM, listy (w tym `- [ ]`), cytaty, bloki kodu, linie poziome oraz
|
|||
|
|
inline: **bold**, *kursywa*, `kod`, [linki](...). Który tor zadziałał, skrypt
|
|||
|
|
wypisuje przy uruchomieniu.
|
|||
|
|
|
|||
|
|
Pliki .md NIE są modyfikowane — to czysty generator, tak samo jak gen_viz.py.
|
|||
|
|
Pliki zarezerwowane (index.md, log.md) nie są konceptami (§3.1) i nie
|
|||
|
|
dostają kart; linki do nich prowadzą do pages/index.html.
|
|||
|
|
|
|||
|
|
Uruchomienie: python3 gen_pages.py
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import html
|
|||
|
|
import posixpath
|
|||
|
|
import re
|
|||
|
|
import shutil
|
|||
|
|
import sys
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import yaml
|
|||
|
|
|
|||
|
|
BUNDLE_ROOT = Path(__file__).resolve().parent
|
|||
|
|
PAGES_DIRNAME = "pages"
|
|||
|
|
PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME
|
|||
|
|
BUNDLE_NAME = "Ski 2027 — Skicircus Saalbach"
|
|||
|
|
RESERVED = {"index.md", "log.md"}
|
|||
|
|
|
|||
|
|
try: # renderer preferowany, jeśli dostępny w systemie
|
|||
|
|
import markdown as _markdown_lib
|
|||
|
|
except ModuleNotFoundError:
|
|||
|
|
_markdown_lib = None
|
|||
|
|
|
|||
|
|
RENDERER = "python-markdown" if _markdown_lib else "fallback (biblioteka standardowa)"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- model ------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Concept:
|
|||
|
|
id: str # np. "oferty/glemm-lodge"
|
|||
|
|
path: Path
|
|||
|
|
frontmatter: dict
|
|||
|
|
body: str
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def page(self) -> str:
|
|||
|
|
"""Ścieżka karty względem katalogu pages/."""
|
|||
|
|
return f"{self.id}.html"
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def title(self) -> str:
|
|||
|
|
return str(self.frontmatter.get("title") or self.id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_concepts() -> list[Concept]:
|
|||
|
|
concepts: list[Concept] = []
|
|||
|
|
for path in sorted(BUNDLE_ROOT.rglob("*.md")):
|
|||
|
|
if ".git" in path.parts or path.name in RESERVED:
|
|||
|
|
continue
|
|||
|
|
text = path.read_text(encoding="utf-8")
|
|||
|
|
frontmatter: dict = {}
|
|||
|
|
body = text
|
|||
|
|
if text.startswith("---\n"):
|
|||
|
|
end = text.find("\n---", 3)
|
|||
|
|
if end != -1:
|
|||
|
|
frontmatter = yaml.safe_load(text[4:end]) or {}
|
|||
|
|
body = text[end + 4 :]
|
|||
|
|
concepts.append(
|
|||
|
|
Concept(
|
|||
|
|
id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(),
|
|||
|
|
path=path,
|
|||
|
|
frontmatter=frontmatter,
|
|||
|
|
body=body.lstrip("\n"),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
return concepts
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- linki ------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+\.md)((?:#[^)\s]*)?)\)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def rewrite_links(body: str, source: Concept, pages: dict[str, str]) -> str:
|
|||
|
|
"""Linki do plików .md → względne ścieżki do kart. External bez zmian."""
|
|||
|
|
here = posixpath.dirname(source.page)
|
|||
|
|
|
|||
|
|
def repl(match: re.Match) -> str:
|
|||
|
|
target, fragment = match.group(1), match.group(2)
|
|||
|
|
if "://" in target:
|
|||
|
|
return match.group(0)
|
|||
|
|
if target.startswith("/"):
|
|||
|
|
rel = target[1:]
|
|||
|
|
else:
|
|||
|
|
rel = posixpath.normpath(posixpath.join(posixpath.dirname(source.id), target))
|
|||
|
|
concept_id = rel[:-3] if rel.endswith(".md") else rel
|
|||
|
|
if concept_id in pages:
|
|||
|
|
dest = pages[concept_id]
|
|||
|
|
elif posixpath.basename(rel) in RESERVED:
|
|||
|
|
dest = "index.html" # index.md / log.md nie mają kart
|
|||
|
|
else:
|
|||
|
|
return match.group(0)
|
|||
|
|
return "](" + posixpath.relpath(dest, here or ".") + fragment + ")"
|
|||
|
|
|
|||
|
|
return _LINK_TARGET_RE.sub(repl, body)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- renderer markdown: fallback na stdlib -----------------------------
|
|||
|
|
|
|||
|
|
_CODE_RE = re.compile(r"`([^`]+)`")
|
|||
|
|
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
|
|||
|
|
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
|
|||
|
|
_ITALIC_STAR_RE = re.compile(r"(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)")
|
|||
|
|
_ITALIC_UNDER_RE = re.compile(r"(?<![\w\\])_(?!\s)([^_]+?)(?<!\s)_(?![\w])")
|
|||
|
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|||
|
|
_HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})$")
|
|||
|
|
_LIST_RE = re.compile(r"^\s*([-*+]|\d+[.)])\s+(.*)$")
|
|||
|
|
_TASK_RE = re.compile(r"^\[([ xX])\]\s+(.*)$")
|
|||
|
|
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_inline(text: str) -> str:
|
|||
|
|
codes: list[str] = []
|
|||
|
|
|
|||
|
|
def stash(match: re.Match) -> str:
|
|||
|
|
codes.append(match.group(1))
|
|||
|
|
return f"\x00c{len(codes) - 1}\x00"
|
|||
|
|
|
|||
|
|
text = _CODE_RE.sub(stash, text)
|
|||
|
|
text = html.escape(text, quote=False)
|
|||
|
|
|
|||
|
|
def link(match: re.Match) -> str:
|
|||
|
|
label, href = match.group(1), match.group(2)
|
|||
|
|
attrs = ""
|
|||
|
|
if "://" in href or href.startswith("mailto:"):
|
|||
|
|
attrs = ' target="_blank" rel="noopener" class="ext"'
|
|||
|
|
return f'<a href="{html.escape(href, quote=True)}"{attrs}>{label}</a>'
|
|||
|
|
|
|||
|
|
text = _LINK_RE.sub(link, text)
|
|||
|
|
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
|
|||
|
|
text = _ITALIC_STAR_RE.sub(r"<em>\1</em>", text)
|
|||
|
|
text = _ITALIC_UNDER_RE.sub(r"<em>\1</em>", text)
|
|||
|
|
|
|||
|
|
for i, code in enumerate(codes):
|
|||
|
|
text = text.replace(f"\x00c{i}\x00", f"<code>{html.escape(code, quote=False)}</code>")
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _alignments(sep: str) -> list[str]:
|
|||
|
|
out = []
|
|||
|
|
for cell in _cells(sep):
|
|||
|
|
left, right = cell.startswith(":"), cell.endswith(":")
|
|||
|
|
out.append("center" if left and right else "right" if right else "left")
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _cells(row: str) -> list[str]:
|
|||
|
|
return [c.strip() for c in row.strip().strip("|").split("|")]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _starts_block(line: str) -> bool:
|
|||
|
|
s = line.strip()
|
|||
|
|
return (
|
|||
|
|
not s
|
|||
|
|
or bool(_HEADING_RE.match(s))
|
|||
|
|
or bool(_HR_RE.match(s))
|
|||
|
|
or s.startswith(("```", ">"))
|
|||
|
|
or bool(_LIST_RE.match(line))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_blocks(text: str) -> str:
|
|||
|
|
lines = text.split("\n")
|
|||
|
|
out: list[str] = []
|
|||
|
|
i = 0
|
|||
|
|
while i < len(lines):
|
|||
|
|
line = lines[i]
|
|||
|
|
stripped = line.strip()
|
|||
|
|
|
|||
|
|
if not stripped:
|
|||
|
|
i += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if stripped.startswith("```"):
|
|||
|
|
i += 1
|
|||
|
|
code: list[str] = []
|
|||
|
|
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|||
|
|
code.append(lines[i])
|
|||
|
|
i += 1
|
|||
|
|
i += 1
|
|||
|
|
out.append(f"<pre><code>{html.escape(chr(10).join(code), quote=False)}</code></pre>")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
heading = _HEADING_RE.match(stripped)
|
|||
|
|
if heading:
|
|||
|
|
level = len(heading.group(1))
|
|||
|
|
out.append(f"<h{level}>{render_inline(heading.group(2))}</h{level}>")
|
|||
|
|
i += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if _HR_RE.match(stripped):
|
|||
|
|
out.append("<hr>")
|
|||
|
|
i += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
|
|||
|
|
header = _cells(stripped)
|
|||
|
|
align = _alignments(lines[i + 1])
|
|||
|
|
i += 2
|
|||
|
|
rows = []
|
|||
|
|
while i < len(lines) and "|" in lines[i] and lines[i].strip():
|
|||
|
|
rows.append(_cells(lines[i]))
|
|||
|
|
i += 1
|
|||
|
|
head = "".join(
|
|||
|
|
f'<th style="text-align:{align[n] if n < len(align) else "left"}">'
|
|||
|
|
f"{render_inline(c)}</th>"
|
|||
|
|
for n, c in enumerate(header)
|
|||
|
|
)
|
|||
|
|
body = "".join(
|
|||
|
|
"<tr>"
|
|||
|
|
+ "".join(
|
|||
|
|
f'<td style="text-align:{align[n] if n < len(align) else "left"}">'
|
|||
|
|
f"{render_inline(c)}</td>"
|
|||
|
|
for n, c in enumerate(row)
|
|||
|
|
)
|
|||
|
|
+ "</tr>"
|
|||
|
|
for row in rows
|
|||
|
|
)
|
|||
|
|
out.append(
|
|||
|
|
f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
|
|||
|
|
f"<tbody>{body}</tbody></table></div>"
|
|||
|
|
)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if stripped.startswith(">"):
|
|||
|
|
quoted = []
|
|||
|
|
while i < len(lines) and lines[i].strip().startswith(">"):
|
|||
|
|
quoted.append(lines[i].strip()[1:].lstrip())
|
|||
|
|
i += 1
|
|||
|
|
out.append(f"<blockquote>{render_blocks(chr(10).join(quoted))}</blockquote>")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
item = _LIST_RE.match(line)
|
|||
|
|
if item:
|
|||
|
|
ordered = item.group(1)[0].isdigit()
|
|||
|
|
items: list[str] = []
|
|||
|
|
while i < len(lines):
|
|||
|
|
current = lines[i]
|
|||
|
|
match = _LIST_RE.match(current)
|
|||
|
|
if match:
|
|||
|
|
items.append(match.group(2))
|
|||
|
|
i += 1
|
|||
|
|
elif current.startswith((" ", "\t")) and current.strip() and items:
|
|||
|
|
items[-1] += " " + current.strip()
|
|||
|
|
i += 1
|
|||
|
|
else:
|
|||
|
|
break
|
|||
|
|
rendered = []
|
|||
|
|
for raw in items:
|
|||
|
|
task = _TASK_RE.match(raw)
|
|||
|
|
if task:
|
|||
|
|
checked = " checked" if task.group(1).lower() == "x" else ""
|
|||
|
|
rendered.append(
|
|||
|
|
f'<li class="task"><input type="checkbox" disabled{checked}> '
|
|||
|
|
f"{render_inline(task.group(2))}</li>"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
rendered.append(f"<li>{render_inline(raw)}</li>")
|
|||
|
|
tag = "ol" if ordered else "ul"
|
|||
|
|
out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
paragraph = [stripped]
|
|||
|
|
i += 1
|
|||
|
|
while i < len(lines) and not _starts_block(lines[i]):
|
|||
|
|
if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
|
|||
|
|
break
|
|||
|
|
paragraph.append(lines[i].strip())
|
|||
|
|
i += 1
|
|||
|
|
out.append(f"<p>{render_inline(' '.join(paragraph))}</p>")
|
|||
|
|
|
|||
|
|
return "\n".join(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def md_to_html(text: str) -> str:
|
|||
|
|
if _markdown_lib:
|
|||
|
|
return _markdown_lib.markdown(
|
|||
|
|
text, extensions=["tables", "fenced_code", "sane_lists"]
|
|||
|
|
)
|
|||
|
|
return render_blocks(text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- szablon ----------------------------------------------------------
|
|||
|
|
|
|||
|
|
CSS = """
|
|||
|
|
:root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff;
|
|||
|
|
--line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; }
|
|||
|
|
@media (prefers-color-scheme: dark) {
|
|||
|
|
:root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155;
|
|||
|
|
--accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; }
|
|||
|
|
}
|
|||
|
|
* { box-sizing: border-box; }
|
|||
|
|
body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem;
|
|||
|
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|||
|
|
font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg);
|
|||
|
|
-webkit-text-size-adjust: 100%; }
|
|||
|
|
nav { font-size: .85rem; margin-bottom: 1.25rem; }
|
|||
|
|
nav a { color: var(--muted); }
|
|||
|
|
h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; }
|
|||
|
|
h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem;
|
|||
|
|
border-top: 1px solid var(--line); }
|
|||
|
|
h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; }
|
|||
|
|
p, ul, ol { margin: .6rem 0; }
|
|||
|
|
ul, ol { padding-left: 1.3rem; }
|
|||
|
|
li { margin: .25rem 0; }
|
|||
|
|
li.task { list-style: none; margin-left: -1.3rem; }
|
|||
|
|
li.task input { margin-right: .45rem; }
|
|||
|
|
a { color: var(--accent); }
|
|||
|
|
a.ext::after { content: " ↗"; font-size: .8em; color: var(--muted); }
|
|||
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em;
|
|||
|
|
background: var(--chip); padding: .1em .35em; border-radius: 4px; }
|
|||
|
|
pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; }
|
|||
|
|
pre code { background: none; padding: 0; }
|
|||
|
|
blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote);
|
|||
|
|
border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; }
|
|||
|
|
blockquote p { margin: .2rem 0; }
|
|||
|
|
hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; }
|
|||
|
|
.table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; }
|
|||
|
|
table { border-collapse: collapse; width: 100%; font-size: .92rem; }
|
|||
|
|
th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; }
|
|||
|
|
th { background: var(--chip); font-weight: 600; }
|
|||
|
|
.chip { display: inline-block; background: var(--chip); color: var(--muted);
|
|||
|
|
font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
|
|||
|
|
padding: .15rem .5rem; border-radius: 999px; }
|
|||
|
|
.lead { color: var(--muted); margin: .35rem 0 1.25rem; }
|
|||
|
|
.meta { margin: 0 0 1.5rem; }
|
|||
|
|
.meta summary { cursor: pointer; font-size: .85rem; color: var(--muted);
|
|||
|
|
padding: .3rem 0; }
|
|||
|
|
.meta table { font-size: .85rem; }
|
|||
|
|
.meta th { width: 38%; text-align: left; font-weight: 500; word-break: break-word; }
|
|||
|
|
.cards { list-style: none; padding: 0; margin: 0; }
|
|||
|
|
.cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem;
|
|||
|
|
margin: .6rem 0; }
|
|||
|
|
.cards a { font-weight: 600; text-decoration: none; }
|
|||
|
|
.cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); }
|
|||
|
|
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
|
|||
|
|
font-size: .78rem; color: var(--muted); }
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _page_shell(title: str, depth: int, content: str) -> str:
|
|||
|
|
up = "../" * depth
|
|||
|
|
return f"""<!doctype html>
|
|||
|
|
<html lang="pl">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>{html.escape(title)} — {html.escape(BUNDLE_NAME)}</title>
|
|||
|
|
<style>{CSS}</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<nav><a href="{up}index.html">← Wszystkie karty</a> · <a href="{up}../viz.html">Graf</a></nav>
|
|||
|
|
{content}
|
|||
|
|
<footer>Wygenerowane z bundle'a OKF przez <code>gen_pages.py</code> — nie edytuj
|
|||
|
|
tych plików, edytuj <code>.md</code> i przegeneruj.</footer>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _meta_table(frontmatter: dict) -> str:
|
|||
|
|
rows = []
|
|||
|
|
for key, value in frontmatter.items():
|
|||
|
|
if isinstance(value, list):
|
|||
|
|
shown = ", ".join(_link_value(str(v)) for v in value)
|
|||
|
|
else:
|
|||
|
|
shown = _link_value(str(value))
|
|||
|
|
rows.append(
|
|||
|
|
f"<tr><th>{html.escape(str(key))}</th><td>{shown}</td></tr>"
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
'<details class="meta" open><summary>Metadane (frontmatter)</summary>'
|
|||
|
|
f'<div class="table-wrap"><table><tbody>{"".join(rows)}</tbody></table></div>'
|
|||
|
|
"</details>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
_URLISH_RE = re.compile(
|
|||
|
|
r"^(https?://\S+|(?:[a-z0-9][a-z0-9-]*\.)+(?:at|com|pl|de|eu|net|org|io|info))$"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _link_value(value: str) -> str:
|
|||
|
|
"""Wartość frontmattera; adresy robi klikalnymi, resztę tylko escape'uje."""
|
|||
|
|
escaped = html.escape(value, quote=False)
|
|||
|
|
if _URLISH_RE.match(value.strip()):
|
|||
|
|
href = value.strip()
|
|||
|
|
if "://" not in href:
|
|||
|
|
href = "https://" + href
|
|||
|
|
return f'<a href="{html.escape(href, quote=True)}" target="_blank" rel="noopener" class="ext">{escaped}</a>'
|
|||
|
|
if value.strip().startswith("mailto:") or "@" in value and " " not in value.strip():
|
|||
|
|
return f'<a href="mailto:{html.escape(value.strip(), quote=True)}">{escaped}</a>'
|
|||
|
|
return escaped
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_page(concept: Concept, pages: dict[str, str]) -> str:
|
|||
|
|
body = rewrite_links(concept.body, concept, pages)
|
|||
|
|
rendered = md_to_html(body)
|
|||
|
|
|
|||
|
|
# Pierwszy nagłówek H1 body dubluje tytuł karty — awansujemy go na <h1>
|
|||
|
|
# strony zamiast dokładać drugi. Sam `title` i tak jest w tabelce metadanych.
|
|||
|
|
match = re.match(r"\s*<h1>(.*?)</h1>", rendered, re.S)
|
|||
|
|
if match:
|
|||
|
|
headline = match.group(1)
|
|||
|
|
rendered = rendered[match.end() :]
|
|||
|
|
else:
|
|||
|
|
headline = html.escape(concept.title)
|
|||
|
|
|
|||
|
|
frontmatter = concept.frontmatter
|
|||
|
|
chip = html.escape(str(frontmatter.get("type") or "—"))
|
|||
|
|
description = str(frontmatter.get("description") or "")
|
|||
|
|
lead = f'<p class="lead">{html.escape(description)}</p>' if description else ""
|
|||
|
|
|
|||
|
|
content = (
|
|||
|
|
f'<span class="chip">{chip}</span>\n'
|
|||
|
|
f"<h1>{headline}</h1>\n{lead}\n"
|
|||
|
|
f"{_meta_table(frontmatter)}\n{rendered}"
|
|||
|
|
)
|
|||
|
|
return _page_shell(concept.title, concept.id.count("/"), content)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_index(concepts: list[Concept]) -> str:
|
|||
|
|
groups: list[tuple[str, list[Concept]]] = [
|
|||
|
|
("Wyjazd i wiedza ogólna", [c for c in concepts if "/" not in c.id]),
|
|||
|
|
("Oferty noclegowe", [c for c in concepts if c.id.startswith("oferty/")]),
|
|||
|
|
]
|
|||
|
|
sections = []
|
|||
|
|
for heading, items in groups:
|
|||
|
|
if not items:
|
|||
|
|
continue
|
|||
|
|
cards = []
|
|||
|
|
for concept in items:
|
|||
|
|
description = str(concept.frontmatter.get("description") or "")
|
|||
|
|
status = concept.frontmatter.get("status_ceny")
|
|||
|
|
badge = (
|
|||
|
|
f'<span class="chip">{html.escape(str(status).split("—")[0].strip())}</span> '
|
|||
|
|
if status
|
|||
|
|
else ""
|
|||
|
|
)
|
|||
|
|
cards.append(
|
|||
|
|
f'<li><a href="{html.escape(concept.page, quote=True)}">'
|
|||
|
|
f"{html.escape(concept.title)}</a> "
|
|||
|
|
f'{badge}<span class="chip">{html.escape(str(concept.frontmatter.get("type") or "—"))}</span>'
|
|||
|
|
f"<p>{html.escape(description)}</p></li>"
|
|||
|
|
)
|
|||
|
|
sections.append(f"<h2>{heading}</h2>\n<ul class=\"cards\">{''.join(cards)}</ul>")
|
|||
|
|
|
|||
|
|
content = (
|
|||
|
|
f"<h1>{html.escape(BUNDLE_NAME)}</h1>\n"
|
|||
|
|
'<p class="lead">Karty konceptów bundle\'a — pełna treść każdego dokumentu '
|
|||
|
|
"(ceny, dostępność, plusy/minusy). Struktura powiązań: "
|
|||
|
|
'<a href="../viz.html">graf</a>.</p>\n' + "\n".join(sections)
|
|||
|
|
)
|
|||
|
|
return _page_shell("Karty", 0, content)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- raport braków ----------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]:
|
|||
|
|
"""Czego brakuje w ofertach — bez zgadywania, tylko z tego, co jest."""
|
|||
|
|
report = []
|
|||
|
|
for concept in concepts:
|
|||
|
|
if not str(concept.frontmatter.get("type", "")).startswith("oferta"):
|
|||
|
|
continue
|
|||
|
|
gaps = []
|
|||
|
|
status = str(concept.frontmatter.get("status_ceny") or "")
|
|||
|
|
if not status:
|
|||
|
|
gaps.append("brak pola `status_ceny`")
|
|||
|
|
elif "SZACUNEK" in status.upper():
|
|||
|
|
gaps.append(f"cena niepotwierdzona ({status})")
|
|||
|
|
# Danymi o dostępności jest osobna sekcja (jak w glemm-lodge), a nie
|
|||
|
|
# samo słowo w tekście — "duża dostępność" w opisie się nie liczy.
|
|||
|
|
if not re.search(r"^#+\s*.*dostępnoś", concept.body, re.IGNORECASE | re.M):
|
|||
|
|
gaps.append("brak danych o dostępności terminu 16–23.01.2027")
|
|||
|
|
open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M))
|
|||
|
|
if open_todos:
|
|||
|
|
gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}")
|
|||
|
|
if gaps:
|
|||
|
|
report.append((concept.id, gaps))
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- main -------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
concepts = load_concepts()
|
|||
|
|
pages = {c.id: c.page for c in concepts}
|
|||
|
|
|
|||
|
|
if PAGES_DIR.exists():
|
|||
|
|
shutil.rmtree(PAGES_DIR)
|
|||
|
|
PAGES_DIR.mkdir(parents=True)
|
|||
|
|
|
|||
|
|
written = []
|
|||
|
|
for concept in concepts:
|
|||
|
|
out = PAGES_DIR / concept.page
|
|||
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
out.write_text(render_page(concept, pages), encoding="utf-8")
|
|||
|
|
written.append(out)
|
|||
|
|
|
|||
|
|
index = PAGES_DIR / "index.html"
|
|||
|
|
index.write_text(render_index(concepts), encoding="utf-8")
|
|||
|
|
written.append(index)
|
|||
|
|
|
|||
|
|
print(f"Renderer markdown: {RENDERER}")
|
|||
|
|
print(f"Zapisano {len(written)} plików w {PAGES_DIR}:")
|
|||
|
|
for path in written:
|
|||
|
|
size = path.stat().st_size / 1024
|
|||
|
|
print(f" {path.relative_to(BUNDLE_ROOT)} ({size:.1f} KiB)")
|
|||
|
|
|
|||
|
|
gaps = missing_data(concepts)
|
|||
|
|
print()
|
|||
|
|
if gaps:
|
|||
|
|
print(f"Oferty z brakami w danych ({len(gaps)}) — do uzupełnienia ręcznie:")
|
|||
|
|
for concept_id, items in gaps:
|
|||
|
|
print(f" ! {concept_id}")
|
|||
|
|
for item in items:
|
|||
|
|
print(f" – {item}")
|
|||
|
|
else:
|
|||
|
|
print("Braków w danych ofert nie wykryto.")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|