#!/usr/bin/env python3
"""Render a travel-guide JSON model as self-contained dossier or companion HTML."""
from __future__ import annotations
import argparse
import base64
import html
import json
import mimetypes
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def esc(value):
if value is None:
return ""
return html.escape(str(value), quote=True)
def clean_text(value, fallback=""):
if value is None:
return fallback
return str(value).strip() or fallback
def source_tags(item):
ids = item.get("source_ids", []) if isinstance(item, dict) else []
if not ids:
return ""
return '
Sources: %s
' % ", ".join(esc(source_id) for source_id in ids)
def image_url(src, base_dir, warnings):
if not src:
return ""
src = str(src)
if src.startswith(("data:", "https://", "http://")):
return src
candidate = Path(src).expanduser()
if not candidate.is_absolute():
candidate = base_dir / candidate
candidate = candidate.resolve()
if not candidate.is_file():
warnings.append("image not found: %s" % src)
return ""
mime, _ = mimetypes.guess_type(str(candidate))
mime = mime or "application/octet-stream"
encoded = base64.b64encode(candidate.read_bytes()).decode("ascii")
return "data:%s;base64,%s" % (mime, encoded)
def render_media(image, base_dir, warnings, class_name="anchor-image"):
if not isinstance(image, dict):
return ""
src = image_url(image.get("src"), base_dir, warnings)
if not src:
return ""
alt = clean_text(image.get("alt"), "Travel image")
return '' % (class_name, esc(src), esc(alt))
def render_mark():
mark = ROOT / "assets" / "route-mark.svg"
try:
return mark.read_text(encoding="utf-8")
except OSError:
return ""
def render_journey(trip):
"""Cover route line: one dot per stop, dashed connector, night counts."""
route = [r for r in trip.get("route", []) if isinstance(r, dict) and r.get("place")]
if len(route) < 2:
return ""
pad, cy, top_y, label_y = 46.0, 40.0, 26.0, 64.0
xs = [pad + (740.0 - 2 * pad) * i / (len(route) - 1) for i in range(len(route))]
line = '' % " L".join(
"%.1f %.1f" % (x, cy) for x in xs
)
dots, labels, nights = [], [], []
for i, (x, stop) in enumerate(zip(xs, route)):
dots.append(''
% (x, cy, "#d8a929" if i else "#b51f39"))
labels.append('%s'
% (x, label_y, esc(stop.get("place"))))
nights.append('%s'
% (x, top_y, ("%s nights" % stop["nights"]) if stop.get("nights") else "day trip"))
places = ", ".join(str(s.get("place", "")) for s in route)
return (''
% (esc(places), esc(places), line, "".join(dots), "".join(labels), "".join(nights)))
KIND_LABEL = {"arrive": "Arrival", "city": "City", "excursion": "Excursion", "coast": "Coast"}
KIND_CLASS = {"arrive": "kind-arrive", "city": "kind-city", "excursion": "kind-excursion", "coast": "kind-coast"}
def render_glance(brief):
"""Day strip: one color-coded card per day, rendered right after the brief."""
cards = []
for day in brief.get("days", []):
if not isinstance(day, dict):
continue
kind = str(day.get("kind", "")).strip().lower()
cls = KIND_CLASS.get(kind, "kind-default")
kind_label = KIND_LABEL.get(kind, "Day")
label = esc(clean_text(day.get("label"), "Untitled day"))
anchor = esc(clean_text(day.get("anchor"), "No anchor named"))
cards.append('
Day %s · %s%s%s
'
% (cls, esc(day.get("day", "")), kind_label, label, anchor))
if not cards:
return ""
has_kinds = any(isinstance(day, dict) and str(day.get("kind", "")).strip() for day in brief.get("days", []))
legend = ('
Color marks the day\'s kind: arrival, city, excursion, coast. '
'Days without a kind fall back to gold.
') if has_kinds else ""
return ('\n'
'
Trip at a glance
\n'
'
The whole trip, one glance.
\n'
'
%s
\n%s\n' % ("".join(cards), legend))
PACE_LEVELS = {"slow": 2, "slow to moderate": 3, "moderate": 4, "moderate to high": 4, "high": 5, "fast": 5}
def render_meters(brief):
"""Segmented pace/budget meters in the brief; absent values render as text only."""
trip = brief.get("trip", {})
parts = []
if isinstance(trip, dict):
pace = clean_text(trip.get("pace"), "").lower()
pace_level = PACE_LEVELS.get(pace)
if pace_level is not None:
cells = "".join('' % ("on" if i < pace_level else "off") for i in range(5))
parts.append('
Pace
%s
%s
'
% (cells, esc(trip.get("pace", ""))))
budget = trip.get("budget", {})
amount = clean_text(budget.get("amount_range") or budget.get("label"), "") if isinstance(budget, dict) else ""
euro_count = amount.count("€")
if 1 <= euro_count <= 5:
cells = "".join('' % ("on" if i < euro_count else "off") for i in range(5))
parts.append('
Budget
%s
%s
'
% (cells, esc(amount)))
if not parts:
return ""
return '
%s
' % "".join(parts)
SECTION_ORDER = ["brief", "glance", "anchors", "days", "special", "skip", "practical", "sources"]
SECTION_HEADINGS = {
"brief": ("The brief", "Why this trip, now?"),
"glance": ("Trip at a glance", "The whole trip, one glance."),
"anchors": ("The anchors", "Protect the good parts."),
"days": ("Day architecture", "Enough shape to wander."),
"special": ("Make it special", "Make it special."),
"skip": ("A useful no", "Skip this."),
"practical": ("Field notes", "Keep the friction small."),
"sources": ("Evidence and freshness", "Sources."),
}
def field_note(section, brief):
"""One content-derived line for the section footer; None when nothing fits."""
if section == "anchors":
first = next((a for a in brief.get("anchors", []) if isinstance(a, dict)), None)
if first and first.get("failure_mode"):
return ("If it goes wrong", first["failure_mode"])
if section == "days":
first = next((d for d in brief.get("days", []) if isinstance(d, dict)), None)
if first and first.get("alternative"):
return ("Plan B", first["alternative"])
if section == "practical":
for item in brief.get("practical", []):
if isinstance(item, dict) and "recheck" in str(item.get("label", "")).lower() and item.get("value"):
return ("Recheck before departure", item["value"])
if section == "skip":
first = next((s for s in brief.get("skip", []) if isinstance(s, dict)), None)
if first and first.get("reason"):
return ("Why we skip it", first["reason"])
return None
def section_footer(section, brief):
"""Bottom-of-page footer: field note, next-section line, and a ghost mark."""
note = field_note(section, brief)
note_html = ""
if note:
note_html = '%s%s' % (
esc(note[0]), esc(note[1]))
index = SECTION_ORDER.index(section)
next_html = ""
if index < len(SECTION_ORDER) - 1:
next_name = SECTION_ORDER[index + 1]
kicker, heading = SECTION_HEADINGS[next_name]
num = "%02d" % (index + 2)
next_html = ('Next: %s — %s'
'%s') % (esc(kicker), esc(heading), num)
else:
next_html = 'End of dossier'
mark = render_mark()
watermark = '
%s
' % mark if mark else ""
return '\n%s' % (note_html, next_html, watermark)
def inject_footer(section_html, section, brief):
footer_html = section_footer(section, brief)
index = section_html.rfind("")
if index == -1:
return section_html + "\n" + footer_html
return section_html[:index] + "\n" + footer_html + "\n" + section_html[index:]
def render_cover(brief, base_dir, warnings):
trip = brief.get("trip", {})
cover = brief.get("cover", {})
image = render_media(cover.get("image", {}), base_dir, warnings, "cover-image")
destination = clean_text(trip.get("destination"), "Travel dossier")
region = clean_text(trip.get("region"))
title = clean_text(brief.get("title"), destination)
eyebrow = clean_text(cover.get("eyebrow"), "A personal travel dossier")
subtitle = clean_text(cover.get("subtitle"), brief.get("thesis"))
duration = clean_text(trip.get("duration_days"), "")
duration_value = "%s days" % duration if duration else "Flexible length"
route = trip.get("route", [])
route_value = " → ".join(clean_text(item.get("place")) for item in route if isinstance(item, dict) and item.get("place"))
route_value = route_value or destination
budget = trip.get("budget", {})
budget_value = clean_text(budget.get("amount_range") or budget.get("label"), "Not specified") if isinstance(budget, dict) else "Not specified"
credit = clean_text(cover.get("image", {}).get("credit")) if isinstance(cover.get("image", {}), dict) else ""
credit_html = '
%s
' % esc(credit) if credit else ""
region_html = " %s" % esc(region) if region else ""
journey = render_journey(trip)
return """
{image}
{mark}
{eyebrow}
{title}.
{subtitle}
Destination{destination}{region}
Duration{duration_value}
Route{route_value}
Budget{budget_value}
{journey}
{credit_html}
""".format(
image=image,
mark=render_mark(),
eyebrow=esc(eyebrow),
title=esc(title),
subtitle=esc(subtitle),
destination=esc(destination),
region=region_html,
duration_value=esc(duration_value),
route_value=esc(route_value),
budget_value=esc(budget_value),
journey=journey,
credit_html=credit_html,
)
def render_brief(brief):
thesis = clean_text(brief.get("thesis"), "No trip thesis supplied.")
trip = brief.get("trip", {})
route = trip.get("route", [])
route_cards = []
for item in route:
if not isinstance(item, dict):
continue
place = clean_text(item.get("place"), "Unnamed stop")
nights = clean_text(item.get("nights"), "")
route_cards.append('
%s%s
' % (esc(place), esc((nights + " nights") if nights else "Timing not specified")))
route_html = "".join(route_cards) or '
{pace} · {traveler_label} · The plan protects one meaningful experience at a time and leaves room for the place to interrupt it.
""".format(thesis=esc(thesis), route_html=route_html, meters=meters, pace=esc(pace), traveler_label=esc(traveler_label))
def render_anchors(brief, base_dir, warnings):
cards = []
for index, anchor in enumerate(brief.get("anchors", []), start=1):
if not isinstance(anchor, dict):
continue
media = render_media(anchor.get("image", {}), base_dir, warnings)
image_column = media or ""
card_class = "anchor-card has-image" if media else "anchor-card"
cards.append("""
{image_column}
{number}{title}
{place}
{why}
Best window
{best_window}
Cost
{cost}
Booking
{booking}
Could fail if: {failure_mode}
{sources}
""".format(
card_class=card_class,
image_column=image_column,
number=index,
title=esc(clean_text(anchor.get("title"), "Untitled anchor")),
place=esc(clean_text(anchor.get("place"), "Place not specified")),
why=esc(clean_text(anchor.get("why"), "Fit not specified.")),
best_window=esc(clean_text(anchor.get("best_window"), "Not specified")),
cost=esc(clean_text(anchor.get("cost"), "Not specified")),
booking=esc(clean_text(anchor.get("booking"), "Not specified")),
failure_mode=esc(clean_text(anchor.get("failure_mode"), "Not specified")),
sources=source_tags(anchor),
))
if not cards:
cards.append('
No anchor experiences supplied.
')
return """
The anchors
Protect the good parts.
{cards}
""".format(cards="".join(cards))
def render_days(brief):
cards = []
for day in brief.get("days", []):
if not isinstance(day, dict):
continue
cards.append("""
""".format(cards="".join(cards))
def render_special(brief):
cards = []
for item in brief.get("special", []):
if not isinstance(item, dict):
continue
cards.append('
%s
%s
%s
%s
' % (
esc(clean_text(item.get("title"), "A small special thing")),
esc(clean_text(item.get("description"), "Optional detail not supplied.")),
esc(clean_text(item.get("when"), "When it fits")),
source_tags(item),
))
if not cards:
cards.append('
Make it special
Add one or two feasible gestures that belong to these travelers rather than to a generic destination list.
')
return """
The part that belongs to you
Make it special.
{cards}
""".format(cards="".join(cards))
def render_skip(brief):
cards = []
for item in brief.get("skip", []):
if not isinstance(item, dict):
continue
cards.append('
%s%s%s
' % (
esc(clean_text(item.get("title"), "Option to skip")),
esc(clean_text(item.get("reason"), "Reason not supplied.")),
source_tags(item),
))
if not cards:
return ""
return """
A useful no
Skip this.
{cards}
""".format(cards="".join(cards))
def render_practical(brief):
rows = []
for item in brief.get("practical", []):
if not isinstance(item, dict):
continue
rows.append('
%s
%s
%s
' % (
esc(clean_text(item.get("label"), "Note")),
esc(clean_text(item.get("value"), "Not supplied.")),
esc(", ".join(str(x) for x in item.get("source_ids", []))),
))
if not rows:
rows.append('
Notes
No practical notes supplied.
')
return """
Field notes
Keep the friction small.
Topic
Note
Sources
{rows}
""".format(rows="".join(rows))
def render_sources(brief):
items = []
for source in brief.get("sources", []):
if not isinstance(source, dict):
continue
supports = ", ".join(str(value) for value in source.get("supports", []))
notes = clean_text(source.get("notes"))
detail = " — %s" % notes if notes else ""
items.append('