#!/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 '%s' % (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 ('' 'Route: %s%s%s%s%s' % (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
' % (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
' % (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 = '' % 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 '
%sRoute details not supplied
' % esc(trip.get("destination", "Destination")) pace = clean_text(trip.get("pace"), "Not specified") traveler_count = len(trip.get("travelers", [])) if isinstance(trip.get("travelers"), list) else "" traveler_label = "%s traveler(s)" % traveler_count if traveler_count else "Travel party" meters = render_meters(brief) return """

The brief

Why this trip, now?

{thesis}

{route_html}
{meters}

Trip posture

{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("""
Day {number}

{label}

Anchor
{anchor}
Texture
{texture}
Pause
{pause}
Alternative
{alternative}
Practical
{practical}
{sources}
""".format( number=esc(day.get("day", "")), label=esc(clean_text(day.get("label"), "Untitled day")), anchor=esc(clean_text(day.get("anchor"), "Not specified")), texture=esc(clean_text(day.get("texture"), "Not specified")), pause=esc(clean_text(day.get("pause"), "Not specified")), alternative=esc(clean_text(day.get("alternative"), "Not specified")), practical=esc(clean_text(day.get("practical"), "Not specified")), sources=source_tags(day), )) if not cards: cards.append('

No day cards supplied.

') return """

Day architecture

Enough shape to wander.

{cards}
""".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('NotesNo practical notes supplied.') return """

Field notes

Keep the friction small.

{rows}
TopicNoteSources
""".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('
  • %s %s (retrieved %s; supports %s)%s
  • ' % ( esc(clean_text(source.get("id"), "S?")), esc(clean_text(source.get("url"), "#")), esc(clean_text(source.get("title"), "Source")), esc(clean_text(source.get("retrieved"), "date not supplied")), esc(supports or "not specified"), esc(detail), )) if not items: items.append('
  • No source ledger supplied.
  • ') return """

    Evidence and freshness

    Sources.

      {items}
    """.format(items="".join(items)) def render_body(brief, base_dir, warnings, mode): sections = [ ("brief", render_brief(brief)), ("glance", render_glance(brief)), ("anchors", render_anchors(brief, base_dir, warnings)), ("days", render_days(brief)), ("special", render_special(brief)), ("skip", render_skip(brief)), ("practical", render_practical(brief)), ("sources", render_sources(brief)), ] body = [render_cover(brief, base_dir, warnings)] body.extend(inject_footer(html, name, brief) for name, html in sections) nav = "" if mode == "companion": nav = '' return nav + "\n".join(body) def render_document(brief, base_dir, warnings, mode, css): title = clean_text(brief.get("title"), clean_text(brief.get("trip", {}).get("destination"), "Travel guide")) body_class = "companion" if mode == "companion" else "dossier" return """ {title}
    {body}
    """.format( title=esc(title), body_class=body_class, privacy_mode=esc(clean_text(brief.get("privacy_mode"), "private")), css=css, body=render_body(brief, base_dir, warnings, mode), ) def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("brief", type=Path) parser.add_argument("--mode", choices=("dossier", "companion"), default="dossier") parser.add_argument("--output", required=True, type=Path) parser.add_argument("--css", type=Path, help="override the bundled CSS file") parser.add_argument("--json", action="store_true", dest="as_json", help="emit a machine-readable report") args = parser.parse_args(argv) try: brief = json.loads(args.brief.read_text(encoding="utf-8")) except FileNotFoundError: parser.error("input file does not exist: %s" % args.brief) except json.JSONDecodeError as exc: print("invalid JSON: %s" % exc, file=sys.stderr) return 1 if not isinstance(brief, dict): print("input root must be a JSON object", file=sys.stderr) return 1 css_path = args.css or (ROOT / "styles" / "travel-dossier.css") try: css = css_path.read_text(encoding="utf-8") except OSError as exc: print("cannot read CSS: %s" % exc, file=sys.stderr) return 1 warnings = [] document = render_document(brief, args.brief.resolve().parent, warnings, args.mode, css) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(document, encoding="utf-8") report = { "status": "ok" if not warnings else "warning", "mode": args.mode, "input": str(args.brief), "output": str(args.output), "bytes": len(document.encode("utf-8")), "warnings": warnings, } if args.as_json: print(json.dumps(report, indent=2)) else: print("wrote %s (%s)%s" % (args.output, args.mode, ", warnings: " + "; ".join(warnings) if warnings else "")) return 0 if __name__ == "__main__": sys.exit(main())