Files
roux/scripts/build-issue.py
T
valknarandClaude Sonnet 5 2dc7a0063f Add issue No. 02 "Obsession" + reusable image pipeline
Content
- 100 new plates under content/posts/02/ — one recurring red-haired model
  across 10 editorial registers (Haute Couture, Film Noir, Boudoir,
  Avant-Garde, Minimalism, Baroque, Street Style, Surrealism, Monochrome,
  Nocturne), generated with flux-1.1-pro, face-swapped to a consistent
  identity (FaceFusion), and upscaled (Upscayl + Remacri, 3328x4992).
- content/issues/02/_index.md; issue 01 -> status archived; hugo.toml params
  (issueIds newest-first, issueNumber/Name/Season/Blurb).

Structure
- Plate bundles grouped by issue: content/posts/<issue>/<slug>/, with
  build.render:never stubs so /posts/<issue>/ isn't emitted. nginx 301s the
  legacy flat /posts/<slug>/ URLs to /posts/01/<slug>/. The /posts/ archive
  uses .RegularPagesRecursive.

Image pipeline (scripts/, data/ gitignored)
- generate-images.py  — Replicate flux-1.1-pro, 960x1440, idempotent.
- faceswap-images.py  — FaceFusion batch-run, one consistent face.
- upscale-images.py   — Upscayl + remacri-4x, long edge clamped to 4992.
- build-issue.py      — prompts JSON -> content/posts/<issue>/*/index.md.

Build
- CSS now compiled by Hugo's css.TailwindCSS (partials/css.html +
  templates.Defer); dropped the standalone Tailwind CLI step, concurrently,
  and the gitignored static/css/main.css. Requires Hugo >= 0.161; Dockerfile
  collapsed to a single hugomods/hugo:debian-node build stage.
  pnpm-workspace.yaml: preferSymlinkedExecutables + allowBuilds.

Front end
- Pagination 8 per page.
- Lightbox: brand mark and category link out; robust SPA back/forward
  (fetch before startViewTransition, single-flight guard, swallow the
  transition abort rejection, popstate re-renders / reopens the viewer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZPmxGywFnAhmYJB1eh9fm
2026-08-29 21:11:24 +02:00

101 lines
3.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Assemble a Roux issue's Hugo page bundles from a prompts JSON file.
python3 scripts/build-issue.py --issue 02 --prompts data/prompts/issue-02.json
Reads an array of objects with keys:
slug, title, description, category, tags[], prompt, featured (optional bool)
Writes content/posts/<issue>/<slug>/index.md for each, with plate numbers assigned
by stable sort on slug (matches scripts/import-posts.py). Frontmatter shape mirrors
the existing issue-01 bundles. `featured` comes from the JSON, so re-runs never lose
the homepage flags (unlike issue 01's hand-added ones).
Images are expected at content/posts/<issue>/<slug>/<slug>.png (written there by
scripts/generate-images.py). Pass --images DIR to copy them in from a staging dir
that holds either <slug>.png or <slug>/<slug>.png.
"""
import argparse
import json
import shutil
from pathlib import Path
SITE = Path(__file__).resolve().parent.parent
def norm_desc(desc: str) -> str:
desc = desc.strip()
if desc:
desc = desc[0].upper() + desc[1:]
if not desc.endswith((".", "!", "?")):
desc += "."
return desc.replace('"', '\\"')
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--issue", required=True)
ap.add_argument("--prompts", required=True, type=Path)
ap.add_argument("--images", type=Path, help="staging dir to copy <slug>.png from")
args = ap.parse_args()
content = SITE / "content" / "posts" / args.issue
content.mkdir(parents=True, exist_ok=True)
entries = json.loads(args.prompts.read_text(encoding="utf-8"))
entries.sort(key=lambda e: e["slug"])
missing_img = []
for idx, entry in enumerate(entries, start=1):
slug = entry["slug"]
plate = f"{idx:03d}"
title = entry["title"].strip().replace('"', '\\"')
desc = norm_desc(entry["description"])
cat = (entry.get("category") or "Uncategorised").strip()
tags = [t.strip() for t in entry.get("tags", []) if t.strip()]
bundle = content / slug
bundle.mkdir(exist_ok=True)
img_name = f"{slug}.png"
dst_img = bundle / img_name
if args.images and not dst_img.exists():
for cand in (args.images / img_name, args.images / slug / img_name):
if cand.is_file():
shutil.copy2(cand, dst_img)
break
if not dst_img.exists():
missing_img.append(slug)
cats_yaml = f' - "{cat}"'
tags_yaml = "\n".join(f' - "{t}"' for t in tags) if tags else " []"
featured_yaml = "\nfeatured: true" if entry.get("featured") else ""
md = f"""---
title: "{title}"
description: "{desc}"
plate: "{plate}"
slug: "{slug}"
issues:
- "{args.issue}"
image: "{img_name}"
weight: {idx}{featured_yaml}
categories:
{cats_yaml}
tags:
{tags_yaml}
---
"""
(bundle / "index.md").write_text(md, encoding="utf-8")
print(f"Wrote {len(entries)} bundles to {content}")
if missing_img:
print(f"WARNING: {len(missing_img)} bundles have no image yet: "
+ ", ".join(missing_img[:10]) + ("…" if len(missing_img) > 10 else ""))
return 0
if __name__ == "__main__":
raise SystemExit(main())