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
101 lines
3.2 KiB
Python
Executable File
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())
|