This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user