{privacy.hero.eyebrow}
+{privacy.hero.intro}
+ {privacy.updated &&Last updated {privacy.updated}.
} +{_esc(lead)}
', + '' + "This month's styling note
", + f"{_esc(tip_body)}
", + ] + text_parts = [lead, "", "THIS MONTH'S STYLING NOTE", tip_title, tip_body] + + if news: + html_parts.append( + 'What is new
' + ) + html_parts.append("Quentin
") + text_parts += ["", f"See more on the site: {site_url}", "", "Quentin"] + + return Draft( + subject=subject, + html="\n".join(html_parts), + text="\n".join(text_parts), + tip_title=tip_title, + news_count=len(news), + ) + + +def find_problems(draft: Draft) -> list[str]: + """Guard the finished draft: no em dash, no unfilled placeholder.""" + problems: list[str] = [] + blob = "\n".join([draft.subject, draft.html, draft.text]) + low = blob.lower() + if EM_DASH in blob or any(e in low for e in EM_DASH_ENTITIES): + problems.append("em dash in the email body (restructure the sentence instead)") + for marker in PLACEHOLDER_MARKERS: + if marker in low: + problems.append(f"unfilled placeholder in the email body: {marker!r}") + return problems + + +# ---- Orchestration ---------------------------------------------------------- +def build_draft( + ledger: dict, + commits: list[dict], + notes: list[dict], + month: str | None = None, + today: dt.date | None = None, + site_url: str = SITE_URL, +) -> Draft | None: + """Assemble the draft, or return None when the month has no news. + + None is the "nothing worth saying, write nothing" branch: no news means no + draft, full stop. The styling tip is the lead when we do send, but it never + justifies sending on its own. + """ + start, end, label = month_window(month, today) + news = assemble_news(ledger, commits, start, end, site_url=site_url) + if not news: + return None + seed = start.year * 12 + (start.month - 1) + tip_title, tip_body = select_tip(notes, seed) + draft = render_email(tip_title, tip_body, news, site_url, label) + problems = find_problems(draft) + if problems: + raise ValueError("draft failed content checks: " + "; ".join(problems)) + return draft + + +# ---- Edges: files, git, API ------------------------------------------------- +def load_ledger(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def load_notes(ideas_yaml_path: str) -> list[dict]: + """Read notes.items (title/body) from content/ideas.yaml. + + Uses PyYAML when available; otherwise a minimal fallback that reads just the + notes.items list, so the tool has no hard third-party dependency. + """ + with open(ideas_yaml_path, encoding="utf-8") as fh: + text = fh.read() + try: + import yaml # type: ignore + + data = yaml.safe_load(text) + return (data.get("notes") or {}).get("items") or [] + except ImportError: + return _fallback_parse_notes(text) + + +def _fallback_parse_notes(text: str) -> list[dict]: + """Tiny parser for the notes.items block only (title/body, single-line).""" + lines = text.splitlines() + items: list[dict] = [] + in_notes = in_items = False + cur: dict | None = None + + def unquote(v: str) -> str: + v = v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1] + return v + + for line in lines: + if re.match(r"^notes:\s*$", line): + in_notes, in_items = True, False + continue + if in_notes and re.match(r"^\S", line) and not line.startswith("notes:"): + break # left the notes block + if in_notes and re.match(r"^\s+items:\s*$", line): + in_items = True + continue + if in_items: + m = re.match(r"^\s*-\s*title:\s*(.+)$", line) + if m: + if cur: + items.append(cur) + cur = {"title": unquote(m.group(1))} + continue + m = re.match(r"^\s*body:\s*(.+)$", line) + if m and cur is not None: + cur["body"] = unquote(m.group(1)) + if cur: + items.append(cur) + return items + + +def git_content_commits(start: dt.date, end: dt.date, repo_root: str = _REPO_ROOT) -> list[dict]: + """Commits touching content/*.yaml in [start, end], as {date, subject}.""" + until = end + dt.timedelta(days=1) # git --until is exclusive of the day boundary + try: + out = subprocess.check_output( + [ + "git", "log", + f"--since={start.isoformat()}", + f"--until={until.isoformat()}", + "--date=short", + "--pretty=%ad%x09%s", + "--", "content/", + ], + cwd=repo_root, + text=True, + stderr=subprocess.DEVNULL, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + commits = [] + for line in out.splitlines(): + if "\t" not in line: + continue + date, subject = line.split("\t", 1) + commits.append({"date": date.strip(), "subject": subject.strip()}) + return commits + + +def create_loops_draft(api_key: str, draft: Draft, base: str = LOOPS_API_BASE) -> dict: + """Create a DRAFT campaign in Loops. Never sends. + + POST /v1/campaigns. The exact request body is confirmed against + loops.so/docs/api-reference/create-campaign; because no Loops page states the + Campaign API is on by default for a brand-new free team, the first real call + is the test of that. This keeps the request in one place so it is easy to + adjust after that first call. The API key is only ever sent in the auth + header, never logged. + """ + payload = { + "name": draft.subject, + "subject": draft.subject, + "body": draft.html, + } + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{base}/campaigns", + data=data, + method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + raise RuntimeError( + f"Loops API returned HTTP {exc.code}. If this is 401/403, the " + f"Campaign API may not be enabled for this free team. Response: {detail}" + ) from None + + +def _emit_output(drafted: bool, subject: str = "", preview_path: str = "") -> None: + """Expose the result to a GitHub Actions step, if running in one.""" + out = os.environ.get("GITHUB_OUTPUT") + if not out: + return + with open(out, "a", encoding="utf-8") as fh: + fh.write(f"drafted={'true' if drafted else 'false'}\n") + fh.write(f"subject={subject}\n") + fh.write(f"preview={preview_path}\n") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Assemble the monthly newsletter draft.") + ap.add_argument("--month", help="Target month YYYY-MM (default: previous calendar month).") + ap.add_argument("--dry-run", action="store_true", help="Render to a file; do not call Loops.") + ap.add_argument("--out", default=os.path.join(_REPO_ROOT, "newsletter-preview.txt"), + help="Where to write the preview (dry-run and after a real draft).") + args = ap.parse_args(argv) + + ledger = load_ledger(os.path.join(_REPO_ROOT, "instagram-ledger.json")) + notes = load_notes(os.path.join(_REPO_ROOT, "content", "ideas.yaml")) + start, end, label = month_window(args.month) + commits = git_content_commits(start, end) + + draft = build_draft(ledger, commits, notes, month=args.month) + + if draft is None: + print(f"No news for {label}. Writing nothing (this is the correct outcome).") + _emit_output(drafted=False) + return 0 + + preview = ( + f"SUBJECT: {draft.subject}\n" + f"MONTH: {label}\n" + f"NEWS: {draft.news_count} item(s)\n" + f"{'-' * 60}\n{draft.text}\n{'-' * 60}\n\nHTML:\n{draft.html}\n" + ) + with open(args.out, "w", encoding="utf-8") as fh: + fh.write(preview) + print(f"Draft for {label} rendered to {args.out}") + print(f"Subject: {draft.subject}") + + if args.dry_run: + print("Dry run: no Loops campaign created.") + _emit_output(drafted=False, subject=draft.subject, preview_path=args.out) + return 0 + + api_key = os.environ.get("LOOPS_API_KEY") + if not api_key: + print("LOOPS_API_KEY is not set; cannot create the draft.", file=sys.stderr) + return 1 + result = create_loops_draft(api_key, draft) + print(f"Created Loops draft campaign (id: {result.get('id', 'unknown')}). Nothing was sent.") + _emit_output(drafted=True, subject=draft.subject, preview_path=args.out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_newsletter_draft.py b/tools/test_newsletter_draft.py new file mode 100644 index 0000000..1b4c3ea --- /dev/null +++ b/tools/test_newsletter_draft.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Unit tests for the monthly newsletter assembly. + +Dependency-free (stdlib unittest), so CI runs them with no install step. These +exercise the pure logic with fixtures; they never touch git, the network, or the +Loops API. + +Run: python3 tools/test_newsletter_draft.py +""" + +import datetime as dt +import unittest + +import newsletter_draft as nd + +# A stable set of Quentin's own styling notes (shape of ideas.yaml notes.items). +NOTES = [ + {"title": "Dress with intent", "body": "Does this help me become who I am becoming?"}, + {"title": "Release what no longer fits", "body": "Letting go of old clothes is honest."}, + {"title": "Confidence is a skill", "body": "How you walk into a room can be practiced."}, +] + +# June 2026 is the target month across these tests. +JUNE = "2026-06" + + +def ledger(incorporated=None, trimmed=None): + return { + "profile": "mrqfears", + "incorporated": incorporated or [], + "skipped": [], + "trimmed": trimmed or [], + } + + +class NormalMonth(unittest.TestCase): + """A normal month with new work: a draft is created.""" + + def test_creates_draft_with_news_and_tip(self): + led = ledger( + incorporated=[ + {"shortcode": "A1", "date": "2026-06-08", "decision": "add", + "target": "content/galleries.yaml", "reason": "New editorial shoot with a bold color story"}, + {"shortcode": "A2", "date": "2026-06-20", "decision": "add", + "target": "content/ideas.yaml", "reason": "A note on dressing for the room you want"}, + ], + ) + draft = nd.build_draft(led, commits=[], notes=NOTES, month=JUNE) + self.assertIsNotNone(draft) + self.assertEqual(draft.news_count, 2) + self.assertIn("New editorial shoot", draft.text) + # Leads with a styling tip drawn from Quentin's own notes. + self.assertIn(draft.tip_title, [n["title"] for n in NOTES]) + self.assertIn(draft.tip_title, draft.subject) + + def test_body_has_no_em_dash_or_placeholder(self): + led = ledger( + incorporated=[ + {"date": "2026-06-08", "decision": "add", "target": "content/work.yaml", + "reason": "A new case study on a menswear campaign"}, + ], + ) + draft = nd.build_draft(led, commits=[], notes=NOTES, month=JUNE) + self.assertEqual(nd.find_problems(draft), []) + + +class ZeroChangeMonth(unittest.TestCase): + """A month with nothing new: NO draft is created.""" + + def test_no_draft_when_empty(self): + draft = nd.build_draft(ledger(), commits=[], notes=NOTES, month=JUNE) + self.assertIsNone(draft) + + def test_no_draft_when_only_out_of_window(self): + led = ledger( + incorporated=[ + {"date": "2026-05-30", "decision": "add", "target": "content/work.yaml", + "reason": "Last month's work, not this month's"}, + ], + ) + draft = nd.build_draft(led, commits=[], notes=NOTES, month=JUNE) + self.assertIsNone(draft) + + def test_no_draft_when_only_skips(self): + led = ledger( + incorporated=[ + {"date": "2026-06-10", "decision": "skip", "target": "post", + "reason": "Off brand, personal post"}, + ], + ) + draft = nd.build_draft(led, commits=[], notes=NOTES, month=JUNE) + self.assertIsNone(draft) + + +class TrimsAndReplacementsMonth(unittest.TestCase): + """A month of only trims and replacements: replacements are news, trims are not.""" + + def test_replacements_are_news_trims_are_excluded(self): + led = ledger( + incorporated=[ + {"date": "2026-06-12", "decision": "replace", "target": "content/galleries.yaml", + "reason": "A sharper studio portrait replaced a weaker frame", + "replaced": "old.jpg", "why_out": "softer light"}, + ], + trimmed=[ + {"date": "2026-06-12", "target": "content/ideas.yaml", + "reason": "Removed a redundant note", "decision": "trim"}, + ], + ) + draft = nd.build_draft(led, commits=[], notes=NOTES, month=JUNE) + self.assertIsNotNone(draft) + self.assertEqual(draft.news_count, 1) + self.assertIn("sharper studio portrait", draft.text) + # The trim must not appear as announced news. + self.assertNotIn("Removed a redundant note", draft.text) + + def test_trim_commit_subjects_are_not_news(self): + led = ledger() + commits = [ + {"date": "2026-06-15", "subject": "Trim weak gallery image from editorial (#40)"}, + {"date": "2026-06-16", "subject": "Remove stale upcoming-event copy (#41)"}, + ] + draft = nd.build_draft(led, commits=commits, notes=NOTES, month=JUNE) + # Only trims/removals this month -> nothing to announce -> no draft. + self.assertIsNone(draft) + + +class Assembly(unittest.TestCase): + """Lower-level behaviours of the assembly helpers.""" + + def test_commits_supplement_and_dedupe(self): + led = ledger( + incorporated=[ + {"date": "2026-06-08", "decision": "add", "target": "content/work.yaml", + "reason": "Added a new campaign case study"}, + ], + ) + commits = [ + {"date": "2026-06-09", "subject": "Added a new campaign case study (#42)"}, # dupe + {"date": "2026-06-10", "subject": "Add three new speaking dates (#43)"}, # new + ] + news = nd.assemble_news(led, commits, dt.date(2026, 6, 1), dt.date(2026, 6, 30)) + texts = [n.text for n in news] + self.assertEqual(len(news), 2) + self.assertTrue(any("campaign case study" in t for t in texts)) + self.assertTrue(any("speaking dates" in t for t in texts)) + + def test_news_capped_at_max(self): + led = ledger( + incorporated=[ + {"date": f"2026-06-{d:02d}", "decision": "add", "target": "content/work.yaml", + "reason": f"News item number {d}"} + for d in range(1, 9) + ], + ) + news = nd.assemble_news(led, [], dt.date(2026, 6, 1), dt.date(2026, 6, 30)) + self.assertEqual(len(news), nd.MAX_NEWS) + + def test_href_points_at_relevant_page(self): + led = ledger( + incorporated=[ + {"date": "2026-06-08", "decision": "add", "target": "content/ideas.yaml", + "reason": "A new note on style"}, + ], + ) + news = nd.assemble_news(led, [], dt.date(2026, 6, 1), dt.date(2026, 6, 30)) + self.assertTrue(news[0].href.endswith("/ideas")) + + def test_tip_rotates_by_seed(self): + seen = {nd.select_tip(NOTES, seed)[0] for seed in range(len(NOTES))} + self.assertEqual(len(seen), len(NOTES)) # each note used once per cycle + + +class Window(unittest.TestCase): + def test_previous_month_default(self): + start, end, label = nd.month_window(None, today=dt.date(2026, 7, 3)) + self.assertEqual((start, end), (dt.date(2026, 6, 1), dt.date(2026, 6, 30))) + self.assertEqual(label, "June 2026") + + def test_explicit_month(self): + start, end, _ = nd.month_window("2026-02", today=dt.date(2026, 7, 3)) + self.assertEqual((start, end), (dt.date(2026, 2, 1), dt.date(2026, 2, 28))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_site.py b/tools/validate_site.py index e11e19b..8e939d8 100755 --- a/tools/validate_site.py +++ b/tools/validate_site.py @@ -20,6 +20,13 @@ (comma, colon, period, or a pipe in titles) instead of an em dash. Checked across every text file in the output (HTML, CSS, JS, XML, JSON, MD, SVG, TXT, webmanifest), including the literal character and its HTML entities. + 6. Newsletter + consent integrity. The signup box must stay wired to the + configured provider and stay paired with its consent line, so a future + redesign cannot quietly break subscriptions or drop the privacy link: + a. Every built newsletter form's action equals the formEndpoint set in + content/settings.yaml (the single source of truth for the URL). + b. Every newsletter form keeps its _honey honeypot field. + c. Every page that has an email input also links to the privacy note. What it deliberately does NOT fail on: - Missing content images under assets/img/. The site uses an image-slot system: @@ -58,6 +65,26 @@ EXTERNAL_PREFIXES = ("http://", "https://", "//", "mailto:", "tel:", "data:", "javascript:") JUNK_STRINGS = ("lorem ipsum", "mysite", "cordan") +# Newsletter + consent guards. The signup box is a plain HTML form that posts to +# the Loops endpoint stored in content/settings.yaml; these keep the built form +# in sync with that config and paired with its privacy link. +NEWSLETTER_FORM = re.compile( + r'(', + re.IGNORECASE | re.DOTALL, +) +ACTION_ATTR = re.compile(r'action\s*=\s*["\']([^"\']*)["\']', re.IGNORECASE) +HONEYPOT = re.compile(r'name\s*=\s*["\']_honey["\']', re.IGNORECASE) +EMAIL_INPUT = re.compile(r']*\btype\s*=\s*["\']email["\']', re.IGNORECASE) +# A link to the privacy note: relative "privacy" (optionally ./ or with a +# fragment/query) or the .html form. +PRIVACY_LINK = re.compile( + r'href\s*=\s*["\']\.?/?privacy(?:\.html)?(?:[#?][^"\']*)?["\']', re.IGNORECASE +) +# Pull formEndpoint out of content/settings.yaml without a YAML dependency. +SETTINGS_ENDPOINT = re.compile( + r'^\s*formEndpoint:\s*["\']?([^"\'\n]+?)["\']?\s*$', re.MULTILINE +) + EM_DASH = "—" EM_DASH_ENTITIES = ("—", "—", "—") TEXT_EXTENSIONS = ( @@ -143,6 +170,52 @@ def check_galleries(name: str, text: str) -> None: errors.append(f'{name}: data-gallery={sorted(missing)} has no entry in gallery-data') +def settings_endpoint() -> str | None: + """The formEndpoint from content/settings.yaml (repo source, not the build).""" + path = os.path.join(_REPO_ROOT, "content", "settings.yaml") + try: + text = open(path, encoding="utf-8").read() + except FileNotFoundError: + return None + m = SETTINGS_ENDPOINT.search(text) + return m.group(1).strip() if m else None + + +def check_newsletter_and_consent(pages: dict[str, str]) -> None: + """Signup form stays wired to settings.yaml and paired with the privacy link.""" + endpoint = settings_endpoint() + if endpoint is None: + errors.append("settings.yaml: could not read newsletter.formEndpoint") + + form_count = 0 + for name, text in pages.items(): + # (a) + (b): every newsletter form matches the endpoint and keeps the honeypot. + for open_tag, body in NEWSLETTER_FORM.findall(text): + form_count += 1 + action_m = ACTION_ATTR.search(open_tag) + action = action_m.group(1).strip() if action_m else "" + if endpoint is not None and action != endpoint: + errors.append( + f"{name}: newsletter form action \"{action}\" does not match " + f"settings.yaml formEndpoint \"{endpoint}\"" + ) + if not HONEYPOT.search(open_tag + body): + errors.append(f"{name}: newsletter form is missing its _honey honeypot field") + + # (c): any page collecting an email must link to the privacy note. + if EMAIL_INPUT.search(text) and not PRIVACY_LINK.search(text): + errors.append( + f"{name}: has an email input but does not link to the privacy note " + f"(the consent line may have been dropped)" + ) + + if form_count == 0: + errors.append( + "no newsletter signup form found in the build " + "(expected a