first pass at the newspaper builder
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-14 11:57:22 +10:00
commit bec1eaac87
497 changed files with 178953 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
"""generator — the core CLI + layout + emitter unit."""
from .cli import main, run_generate
from .content import load_config, load_content, parse_article
from .emitter import emit_empty_state, emit_newspaper
from .layout import build_layout
from .model import Article, Column, ColumnItem, Issue, MastheadConfig, Page, Photo
__all__ = [
"Article",
"Column",
"ColumnItem",
"Issue",
"MastheadConfig",
"Page",
"Photo",
"build_layout",
"emit_empty_state",
"emit_newspaper",
"load_config",
"load_content",
"main",
"parse_article",
"run_generate",
]
+91
View File
@@ -0,0 +1,91 @@
"""newspaper generate — the one-shot CLI entry point (Q1=A, US5)."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from ..ai_draft import build_brief, draft_newspaper
from ..funnies import FunniesBrief, FunniesBuilder
from .content import load_config, load_content
from .emitter import emit_empty_state, emit_newspaper
from .layout import build_layout
from .model import Article, Issue
def _default_paths(project: Path) -> tuple[Path, Path, Path]:
cfg = project / "config.json"
content = project / "content"
out = project / "newspaper.html"
return cfg, content, out
def run_generate(project: Path, draft: bool = False) -> Path:
"""Read config + content, build the issue layout, write newspaper.html."""
cfg, content, out = _default_paths(project)
has_content = content.exists() and any(
p.suffix.lower() in {".md", ".txt"} for p in content.rglob("*")
)
if not has_content:
# BR5.2 / US10: friendly guided empty-state, never a blank page
masthead = None
if cfg.exists():
try:
masthead = load_config(cfg)
except Exception:
masthead = None
emit_empty_state(out, masthead)
return out
masthead = load_config(cfg) if cfg.exists() else None
if masthead is None:
# Minimal default so the paper still builds
from .model import MastheadConfig
masthead = MastheadConfig("The Wedding Times", "The Happy Couple", "", "1", "1")
articles = load_content(content)
if draft:
# Optional AI copy: ai-draft returns an empty DraftBundle on failure (BR4.1)
brief = build_brief("The couple, the occasion, and these stories")
articles = _apply_draft(articles, brief)
pages = build_layout(articles)
issue = Issue(issue_id="issue-1", masthead=masthead, articles=articles, pages=pages)
emit_newspaper(issue, out)
return out
def _apply_draft(articles: list[Article], brief) -> list[Article]:
"""Fold an optional AI draft in, falling back to the original on failure."""
try:
from newspaper.ai_draft.draft import draft_newspaper
bundle = draft_newspaper(brief, _NoopModel())
if bundle.articles:
return [Article(a.article_id, a.type, a.headline, a.body, a.byline, a.section) for a in bundle.articles]
except Exception:
pass
return articles
class _NoopModel:
def generate(self, brief_text, article_type, article_id):
raise RuntimeError("model transport not wired here")
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="newspaper", description="Generate a local wedding newspaper.")
p.add_argument("--project", type=Path, default=Path.cwd())
p.add_argument("--draft", action="store_true", help="Optionally draft copy via ai-draft")
return p
def main(argv: list[str] | None = None) -> int:
args = _build_arg_parser().parse_args(argv)
out = run_generate(args.project, draft=args.draft)
print(f"Newspaper written to: {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
+80
View File
@@ -0,0 +1,80 @@
"""generator config + content ingestion.
Per BR1.1/BR1.2, US5/US7: reads config.json (MastheadConfig) and the content/
folder, parsing markdown/text into Article entities with fidelity (NFR7).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from .model import Article, MastheadConfig
META_KEYS = {"title", "couple_names", "date_line", "issue_number", "volume"}
def load_config(path: Path) -> MastheadConfig:
"""Read and validate config.json (BR1.1/BR1.2)."""
data = json.loads(path.read_text(encoding="utf-8"))
return MastheadConfig(
title=str(data.get("title", "The Wedding Times")),
couple_names=str(data.get("couple_names", "The Happy Couple")),
date_line=str(data.get("date_line", "")),
issue_number=str(data.get("issue_number", "1")),
volume=str(data.get("volume", "1")),
)
def _read_frontmatter(text: str) -> dict[str, str]:
"""Very light front-matter parse: ---key: value--- lines at the top."""
meta: dict[str, str] = {}
if text.startswith("---"):
m = re.match(r"^---\s*\n(.*?)\n---", text, re.S)
if m:
for line in m.group(1).splitlines():
if ":" in line:
k, _, v = line.partition(":")
if k.strip() in META_KEYS:
meta[k.strip()] = v.strip()
return meta
return meta
def _strip_frontmatter(text: str) -> str:
if text.startswith("---"):
m = re.match(r"^---\s*\n.*?\n---\s*\n?", text, re.S)
if m:
return text[m.end():]
return text
def parse_article(path: Path, article_id: str) -> Article:
"""Parse one .md/.txt content file into an Article (BR1.1, NFR7 fidelity)."""
raw = path.read_text(encoding="utf-8")
meta = _read_frontmatter(raw)
body = _strip_frontmatter(raw)
first_line = next((ln for ln in body.splitlines() if ln.strip()), "")
type_ = meta.get("type", "article")
section = meta.get("section", "News")
return Article(
article_id=article_id,
type=type_,
headline=meta.get("headline", first_line.strip(" #")),
body=body.strip(),
byline=meta.get("byline", ""),
section=section,
)
def load_content(content_dir: Path) -> list[Article]:
"""Read every .md/.txt file in the folder, in sorted order (US5)."""
articles: list[Article] = []
files = sorted(
p for p in content_dir.rglob("*")
if p.is_file() and p.suffix.lower() in {".md", ".txt"}
)
for i, path in enumerate(files, start=1):
articles.append(parse_article(path, f"article-{i}"))
return articles
+86
View File
@@ -0,0 +1,86 @@
"""generator HTML emitter.
Per BR5.1/BR5.3 (US5/US6): writes a self-contained zero-network newspaper.html,
file:// printable to A4 with margins, clean print. BR5.2: friendly guided
empty-state when no content.
"""
from __future__ import annotations
import html
from pathlib import Path
from .model import Article, Issue, MastheadConfig, Page
def _render_masthead(m: MastheadConfig) -> str:
return (
"<header class=\"masthead\">"
f"<h1>{html.escape(m.title)}</h1>"
f"<p class=\"meta\">{html.escape(m.date_line)} · Issue {html.escape(m.issue_number)} · Volume {html.escape(m.volume)}</p>"
"</header>"
)
def _render_article(a: Article) -> str:
by = f"<span class=\"byline\">{html.escape(a.byline)}</span>" if a.byline else ""
return (
f"<article class=\"article\"><h2>{html.escape(a.headline)}</h2>{by}"
f"<p>{html.escape(a.body)}</p></article>"
)
def _render_page(page: Page, articles_by_id: dict[str, Article]) -> str:
cols = []
for col in page.columns:
cells = []
for item in col.items:
art = articles_by_id.get(item.source_ref)
if art:
cells.append(_render_article(art))
else:
cells.append(f"<p class=\"empty-box\">({html.escape(item.kind)})</p>")
cols.append(f"<div class=\"col\">{''.join(cells)}</div>")
return f"<section class=\"page\"><header class=\"running\">{html.escape(page.running_header)}</header>{''.join(cols)}</section>"
def emit_newspaper(issue: Issue, out_path: Path) -> None:
"""Write the self-contained, zero-network newspaper.html (BR5.1)."""
articles_by_id = {a.article_id: a for a in issue.articles}
pages = "".join(_render_page(p, articles_by_id) for p in issue.pages)
css = (
"@page { size: A4; margin: 15mm; }"
"body { font-family: serif; color: #111827; background: #fbf8f1; margin:0; }"
".masthead { border-bottom: 2px solid #000; margin-bottom: 12px; }"
".masthead h1 { margin: 0; font-size: 2.2em; }"
".meta { margin: 4px 0 8px; }"
".page { page-break-after: always; }"
".col { width: 32%; display: inline-block; vertical-align: top; padding-right: 8px; }"
".running { font-size: .8em; border-bottom: 1px solid #999; margin-bottom: 8px; }"
"@media print { .page { break-after: page; } }"
)
doc = (
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<title>Wedding Newspaper</title><style>" + css + "</style></head><body>"
+ _render_masthead(issue.masthead) + pages + "</body></html>"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(doc, encoding="utf-8")
def emit_empty_state(out_path: Path, masthead: MastheadConfig | None = None) -> None:
"""BR5.2/US10: friendly guided empty-state page with a sample-issue pointer."""
title = masthead.title if masthead else "The Wedding Times"
doc = (
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<title>Welcome — Wedding Newspaper</title><style>"
"body{font-family:serif;color:#111827;background:#fbf8f1;max-width:600px;margin:60px auto;padding:20px}"
"</style></head><body>"
"<h1>Welcome to " + html.escape(title) + "</h1>"
"<p>Add a <code>content/</code> folder of markdown or txt files and a "
"<code>config.json</code>, then run <code>newspaper generate</code> to "
"produce your paper. Try the sample issue first to see the layout.</p>"
"</body></html>"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(doc, encoding="utf-8")
+44
View File
@@ -0,0 +1,44 @@
"""generator layout + pagination.
Per BR2.1/BR2.3, US1/NFR2: flows articles into a classic column-based
multi-page A4 layout with page breaks at boundaries, page numbers and running
headers. Deterministic, stdlib-only.
"""
from __future__ import annotations
from .model import Article, Column, ColumnItem, Page
class ColumnFlow:
"""Flows placed items into columns/pages with running headers."""
def __init__(self, columns_per_page: int = 3, items_per_column: int = 4) -> None:
self.columns_per_page = columns_per_page
self.items_per_column = items_per_column
def flow(self, article_items: list[ColumnItem]) -> list[Page]:
pages: list[Page] = []
current: Column | None = None
page: Page | None = None
idx = 0
for item in article_items:
if page is None or current is None or len(current.items) >= self.items_per_column:
current = Column(column_index=0 if current is None else ((current.column_index + 1) % self.columns_per_page))
if page is None or (current.column_index == 0 and len(page.columns) >= self.columns_per_page):
page_number = len(pages) + 1
page = Page(page_number=page_number, columns=[], running_header=f"Page {page_number}")
pages.append(page)
page.columns.append(current)
idx += 1
current.items.append(item)
return pages or [Page(page_number=1, columns=[Column(column_index=0)], running_header="Page 1")]
def build_layout(articles: list[Article]) -> list[Page]:
"""Map articles into column items and flow them (BR2.1)."""
items: list[ColumnItem] = []
for a in articles:
items.append(ColumnItem(item_id=a.article_id, kind="article", source_ref=a.article_id))
flow = ColumnFlow()
return flow.flow(items)
+84
View File
@@ -0,0 +1,84 @@
"""generator data model.
Per functional-design entities (unit: generator): MastheadConfig, Issue,
Article, Page, Column, ColumnItem, Photo. The filesystem is the content store;
the generator resolves and flows them into a classic multi-page broadsheet.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
SectionKind = Literal[
"article", "photo", "pullquote", "factbox", "funnies", "schedule", "wellwish"
]
@dataclass
class MastheadConfig:
"""Dynamic masthead/metadata read from config.json (US7, BR1.2)."""
title: str
couple_names: str
date_line: str
issue_number: str
volume: str
@dataclass
class Article:
"""A parsed content article (US5, NFR7). The filesystem content dir is the store."""
article_id: str
type: str
headline: str
body: str
byline: str = ""
section: str = ""
@dataclass
class Photo:
"""An auto-balanced embedded photo (US4, BR3.1)."""
photo_id: str
src: str # local embedded asset
caption: str = ""
credit: str = ""
@dataclass
class ColumnItem:
"""A placed item in a column (article, photo, pull quote, funnies, ...)."""
item_id: str
kind: SectionKind
source_ref: str
@dataclass
class Column:
"""A filled column box within a page (BR2.1)."""
column_index: int
items: list[ColumnItem] = field(default_factory=list)
@dataclass
class Page:
"""One flowed A4 page with page number + running header (US1, BR2.1)."""
page_number: int
columns: list[Column]
running_header: str = ""
@dataclass
class Issue:
"""The resolved newspaper issue (US1/US2/US7)."""
issue_id: str
masthead: MastheadConfig
articles: list[Article]
pages: list[Page]