diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e261a9c..eef883f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,3 +31,6 @@ jobs: run: python3 tools/seo_check.py dist - name: Validate image budget run: python3 tools/check_images.py + + - name: Newsletter assembly unit tests + run: python3 tools/test_newsletter_draft.py diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index d7fe39b..ce4ea4b 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -34,6 +34,7 @@ jobs: --collect.url=http://localhost:8299/speak.html --collect.url=http://localhost:8299/about.html --collect.url=http://localhost:8299/contact.html + --collect.url=http://localhost:8299/privacy.html steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/newsletter.yml b/.github/workflows/newsletter.yml new file mode 100644 index 0000000..e5cb25e --- /dev/null +++ b/.github/workflows/newsletter.yml @@ -0,0 +1,89 @@ +name: Monthly newsletter draft + +# Assembles the monthly newsletter and creates it as a DRAFT in Loops, then +# opens a GitHub issue so the owner knows it is ready to read and send. It never +# sends anything: a human opens Loops and hits send. If a month has no news, the +# tool writes nothing and no issue is opened. + +on: + schedule: + # 14:00 UTC on the 1st of each month. Covers the previous calendar month. + - cron: '0 14 1 * *' + workflow_dispatch: + inputs: + month: + description: 'Target month YYYY-MM (blank = previous calendar month)' + required: false + default: '' + dry_run: + description: 'Render only, do not create a Loops draft' + type: boolean + required: false + default: false + +permissions: + contents: read + issues: write + +concurrency: + group: newsletter + cancel-in-progress: false + +jobs: + draft: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Read enough history for the git-log "what's new" scan. + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install PyYAML + run: pip install pyyaml + + - name: Assemble the draft + id: draft + env: + LOOPS_API_KEY: ${{ secrets.LOOPS_API_KEY }} + run: | + python3 tools/newsletter_draft.py \ + ${{ github.event.inputs.month && format('--month {0}', github.event.inputs.month) || '' }} \ + ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }} + + - name: Notify the owner + if: steps.draft.outputs.drafted == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const subject = process.env.SUBJECT || 'Newsletter draft ready'; + const previewPath = process.env.PREVIEW || ''; + let preview = ''; + try { preview = fs.readFileSync(previewPath, 'utf8'); } catch (e) {} + const body = [ + 'Your monthly newsletter draft is ready in Loops. Nothing has been sent.', + '', + 'To send it: open Loops, go to Campaigns, open this draft, read it, and', + 'press Send when you are happy. Silence sends nothing.', + '', + '
Preview of the draft', + '', + '```', + preview, + '```', + '', + '
', + ].join('\n'); + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Newsletter draft ready: ${subject}`, + body, + }); + env: + SUBJECT: ${{ steps.draft.outputs.subject }} + PREVIEW: ${{ steps.draft.outputs.preview }} diff --git a/.gitignore b/.gitignore index dc9f9cd..6c87bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ public/assets/img/.orig/ # Lighthouse CI output .lighthouseci/ + +# Newsletter draft preview (rendered by tools/newsletter_draft.py at run time) +newsletter-preview.txt diff --git a/CLAUDE.md b/CLAUDE.md index 3867889..2b3d1a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,6 +218,59 @@ contained. - Pages still render and stay legible if the GA request is blocked (no dependency), so the offline-friendly experience holds. +## Newsletter + +Email capture and a monthly draft, on **Loops** (free plan). The signup box and the +privacy note ship together: never one without the other. + +- **Signup box = a plain HTML form, no third-party JS.** This is why Loops was + chosen over every provider that wants a JS embed: it accepts a plain form POST to a + "custom form" endpoint, so it keeps the no-external-scripts rule (GA stays the one + exception). [Newsletter.astro](src/components/Newsletter.astro) posts to + `settings.newsletter.formEndpoint`; with JS off it is a normal full-page POST, and + [main.js](public/assets/js/main.js) enhances it to an inline AJAX submit (mirrors + the contact form, same `_honey` honeypot). Every string, and the endpoint, live in + the `newsletter` object of [content/settings.yaml](content/settings.yaml) (schema in + [keystatic.config.ts](keystatic.config.ts)), editable from `/keystatic`. Do **not** + hardcode the endpoint or paste a Loops JS snippet. +- **Placement is one setting.** `settings.newsletter.placement` is `footer` (the band + above the footer, every page) or `ideas` (one block on the Ideas page). The Footer + and Ideas page each render `Newsletter` only for their mode. +- **Double opt-in is on in Loops** (Settings → Sending). It applies only to the form + endpoint, so subscribers land unconfirmed until they click the email. Never add + contacts through the API and assume they are confirmed. +- **Privacy + consent are load-bearing, not decoration.** [privacy.astro](src/pages/privacy.astro) + (`content/privacy.yaml`) is the plain-English note; the consent line next to every + email field links to it, and so does the contact form. `tools/validate_site.py` + fails CI if a built newsletter form's action drifts from `settings.yaml`, if the + honeypot is dropped, or if any page with an email input stops linking the privacy + note. That triad is what stops a redesign from quietly breaking subscriptions or + dropping consent. Keep it. +- **CAN-SPAM address:** the physical mailing address required in the email footer is + set in **Loops account settings**, never in this repo. Do not commit a home address. + +### The monthly draft (never a send) + +[tools/newsletter_draft.py](tools/newsletter_draft.py), run monthly by +[.github/workflows/newsletter.yml](.github/workflows/newsletter.yml), assembles a +draft and creates it in Loops via `POST /v1/campaigns`. **It never sends.** A human +opens Loops and hits send; silence is not approval. + +- The email leads with one styling idea (drawn from `content/ideas.yaml` notes, his + own words, rotated by month), then a short "what's new" built from + `instagram-ledger.json` and the git history of `content/*.yaml` (adds and + replacements; trims are not announced). Window is the previous calendar month. +- **No news, no draft.** `build_draft` returns `None` when the month has nothing to + report, and the workflow opens no issue. A padded newsletter is worse than none. +- The Loops API key is the `LOOPS_API_KEY` GitHub Actions secret. It never enters the + repo, the build, client code, a log, or a PR. `--dry-run` renders to + `newsletter-preview.txt` (gitignored) without touching the API. +- Tests: `python3 tools/test_newsletter_draft.py` (in CI). The pure assembly logic is + dependency-free; only the CLI edges read YAML, shell out to git, and call Loops. +- One caveat verified on first run: no Loops page states the Campaign API is on by + default for a new free team, so the first real call is the test. `create_loops_draft` + is the single place to adjust the request body if the live API differs from the docs. + ### When you add a new page 1. Add a singleton to `keystatic.config.ts` (reuse the `seo()` helper) and a diff --git a/content/privacy.yaml b/content/privacy.yaml new file mode 100644 index 0000000..bcb103a --- /dev/null +++ b/content/privacy.yaml @@ -0,0 +1,24 @@ +seo: + title: "Privacy note | Quentin Fears" + description: "How Quentin Fears handles the email you share via the newsletter or contact form: what is collected, why, and how to unsubscribe or delete it." + ogTitle: "Privacy note | Quentin Fears" + ogDescription: "What I collect, why, and how to unsubscribe or ask for deletion." + ogType: "website" +hero: + eyebrow: "Privacy" + heading: "A short, plain privacy note." + intro: "This covers the email address you give me through the newsletter signup or the contact form. It is written to be read, not to bury anything." +updated: "July 2026" +sections: + - heading: "What I collect" + body: "If you subscribe to the newsletter, I collect your email address. If you send a message through the contact form, I collect your email address and whatever you choose to type: your name, company, and message. That is all. No tracking profile, no selling of data." + - heading: "Why I collect it" + body: "The newsletter email address is used only to send you the newsletter, roughly once a month. A contact-form message is used only to reply to you about your inquiry. I do not use either to send you anything you did not ask for." + - heading: "Who processes it" + body: "The newsletter runs on Loops, an email service that stores your address and sends the emails on my behalf. Subscribing uses double opt-in: after you sign up, Loops sends a confirmation email, and you are added only once you click to confirm. Contact-form messages are delivered to my inbox through FormSubmit. Both are third parties acting as processors, and your address is shared with them only for these purposes." + - heading: "How to unsubscribe" + body: "Every newsletter has an unsubscribe link in the footer. Click it and you are removed straight away. You can also email me and I will remove you." + - heading: "How to ask for deletion" + body: "Email me at hello@quentinfears.com and ask me to delete your details, and I will remove your address from Loops and delete the related messages. No reason needed." + - heading: "Questions" + body: "Anything unclear, or you want to know exactly what I hold about you, email hello@quentinfears.com and I will answer." diff --git a/content/settings.yaml b/content/settings.yaml index 5fc002f..d08d136 100644 --- a/content/settings.yaml +++ b/content/settings.yaml @@ -6,3 +6,16 @@ footerBlurb: "I help brands and people use style to say who they are, and who th tagline: "Don't just wear clothes, wear confidence." copyrightName: "Quentin Fears" copyrightYear: "2026" +newsletter: + formEndpoint: "https://app.loops.so/api/newsletter-form/REPLACE_WITH_FORM_ID" + placement: "footer" + eyebrow: "The newsletter" + heading: "Style notes, once a month." + blurb: "One styling idea to use this week, then a short note on what is new in the work. No noise, about once a month." + emailLabel: "Email address" + emailPlaceholder: "you@example.com" + buttonLabel: "Subscribe" + consentLead: "You are giving me your email so I can send you the newsletter. Unsubscribe anytime." + privacyLinkLabel: "How I use your email" + successMessage: "Almost there. Check your inbox and confirm the subscription." + errorMessage: "Something went wrong. Please try again, or email hello@quentinfears.com." diff --git a/keystatic.config.ts b/keystatic.config.ts index ddba942..a5afee4 100644 --- a/keystatic.config.ts +++ b/keystatic.config.ts @@ -89,7 +89,7 @@ export default config({ ui: { brand: { name: 'Quentin Fears site content' }, navigation: { - Pages: ['home', 'work', 'ideas', 'speak', 'about', 'contact'], + Pages: ['home', 'work', 'ideas', 'speak', 'about', 'contact', 'privacy'], Shared: ['settings', 'galleries'], }, }, @@ -146,6 +146,48 @@ export default config({ tagline: fields.text({ label: 'Footer tagline' }), copyrightName: fields.text({ label: 'Copyright name' }), copyrightYear: fields.text({ label: 'Copyright year (fallback)' }), + newsletter: fields.object( + { + // The signup box posts straight to this Loops "custom form" endpoint + // (a plain HTML form, no third-party JavaScript). Get the URL from + // Loops: Forms, then your form, then the custom-form embed. It looks + // like https://app.loops.so/api/newsletter-form/. + formEndpoint: fields.url({ + label: 'Loops form endpoint', + description: + 'The Loops custom-form URL, e.g. https://app.loops.so/api/newsletter-form/. The signup box posts here.', + }), + placement: fields.select({ + label: 'Where the signup box appears', + description: + 'Footer = on every page above the footer. Ideas page = one block on the Ideas page only.', + options: [ + { label: 'Footer (every page)', value: 'footer' }, + { label: 'Ideas page only', value: 'ideas' }, + ], + defaultValue: 'footer', + }), + eyebrow: fields.text({ label: 'Eyebrow (small all-caps label)' }), + heading: fields.text({ label: 'Heading' }), + blurb: fields.text({ label: 'Blurb', multiline: true }), + emailLabel: fields.text({ label: 'Email field label' }), + emailPlaceholder: fields.text({ label: 'Email field placeholder' }), + buttonLabel: fields.text({ label: 'Button label' }), + consentLead: fields.text({ + label: 'Consent line', + description: + 'Shown next to the field. The privacy-note link is added after it automatically.', + multiline: true, + }), + privacyLinkLabel: fields.text({ + label: 'Privacy-note link text', + description: 'The clickable words that link to the privacy note.', + }), + successMessage: fields.text({ label: 'Success message', multiline: true }), + errorMessage: fields.text({ label: 'Error message', multiline: true }), + }, + { label: 'Newsletter signup' } + ), }, }), @@ -559,5 +601,26 @@ export default config({ }), }, }), + + privacy: singleton({ + label: 'Privacy note', + path: 'content/privacy', + format: { data: 'yaml' }, + schema: { + seo: seo(), + hero: eyebrowHeadingIntro(), + updated: fields.text({ + label: 'Last updated', + description: 'Shown under the heading, e.g. July 2026.', + }), + sections: fields.array( + fields.object({ + heading: fields.text({ label: 'Section heading' }), + body: fields.text({ label: 'Section body (HTML allowed)', multiline: true }), + }), + { label: 'Sections', itemLabel: (p) => p.fields.heading.value } + ), + }, + }), }, }); diff --git a/lighthouserc.json b/lighthouserc.json index 1d4771a..6f83856 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -9,7 +9,8 @@ "http://localhost:8299/ideas.html", "http://localhost:8299/speak.html", "http://localhost:8299/about.html", - "http://localhost:8299/contact.html" + "http://localhost:8299/contact.html", + "http://localhost:8299/privacy.html" ], "numberOfRuns": 3, "settings": { diff --git a/public/assets/css/style.css b/public/assets/css/style.css index b79a7c4..c4fa423 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -567,6 +567,38 @@ button { font: inherit; cursor: pointer; } .form-status--ok { color: var(--accent); } .form-status--err { color: #ff6a4d; } +/* ============================================================ + Newsletter signup + ============================================================ */ +.newsletter { border-top: 1px solid var(--line); padding-block: clamp(2.75rem, 6vw, 4.25rem); } +.newsletter--section { background: var(--bg-2); } +.newsletter__inner { display: grid; gap: clamp(1.5rem, 4vw, 3rem); align-items: start; } +@media (min-width: 860px) { + .newsletter--footer .newsletter__inner { grid-template-columns: 1fr 1.1fr; align-items: center; } + .newsletter--section .newsletter__inner { grid-template-columns: 0.9fr 1.1fr; } +} +.newsletter__lead h2 { font-family: var(--serif); font-size: clamp(1.7rem, 3.4vw, 2.5rem); line-height: 1.05; letter-spacing: -0.01em; margin-bottom: 0.9rem; } +.newsletter__blurb { color: var(--ink-soft); max-width: 42ch; } +.newsletter__form { max-width: 34rem; } +.newsletter__row { display: flex; flex-wrap: wrap; align-items: end; gap: 0.9rem; } +.newsletter__field { flex: 1 1 16rem; margin-bottom: 0; } +.newsletter__btn { flex: 0 0 auto; white-space: nowrap; } +.newsletter__field input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.newsletter__consent { margin: 1rem 0 0; font-size: 0.82rem; line-height: 1.5; color: var(--ink-faint); } +.newsletter__consent a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.newsletter__status { min-height: 0; } +.newsletter__status:empty { margin-top: 0; } + +/* ============================================================ + Privacy note + ============================================================ */ +.privacy-updated { color: var(--ink-faint); font-size: 0.9rem; margin-top: 1rem; } +.prose-narrow { max-width: 62ch; } +.privacy-block { margin-bottom: 2rem; } +.privacy-block h2 { font-family: var(--serif); font-size: clamp(1.3rem, 2.4vw, 1.7rem); margin-bottom: 0.6rem; } +.privacy-block p { color: var(--ink-soft); line-height: 1.7; } +.privacy-block a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } + /* ============================================================ Lightbox gallery ============================================================ */ diff --git a/public/assets/js/main.js b/public/assets/js/main.js index acc1160..a89e5ab 100644 --- a/public/assets/js/main.js +++ b/public/assets/js/main.js @@ -76,6 +76,44 @@ }); } + /* ---- Newsletter form: post to the Loops custom-form endpoint via AJAX, + inline status. No-JS visitors get a normal POST to the same endpoint. + Double opt-in is on in Loops, so a confirmation email is what "success" + promises here. ---- */ + var nl = document.getElementById("newsletter-form"); + if (nl) { + var nlStatus = nl.querySelector(".form-status"); + var nlAction = nl.getAttribute("action") || ""; + nl.addEventListener("submit", function (e) { + // Only enhance real Loops endpoints; anything else submits normally. + if (nlAction.indexOf("loops.so") === -1) return; + var honey = nl.querySelector('[name="_honey"]'); + if (honey && honey.value) { e.preventDefault(); return; } // bot: drop silently + // Let the browser show its own message for an empty/invalid email. + if (typeof nl.checkValidity === "function" && !nl.checkValidity()) return; + e.preventDefault(); + var btn = nl.querySelector('button[type="submit"]'); + var label = btn ? btn.innerHTML : ""; + var data = new FormData(nl); + var okMsg = nl.getAttribute("data-ok") || "Thanks, please check your inbox to confirm."; + var errMsg = nl.getAttribute("data-err") || "Something went wrong. Please try again."; + if (btn) { btn.disabled = true; btn.textContent = "Subscribing…"; } + if (nlStatus) { nlStatus.textContent = ""; nlStatus.className = "form-status newsletter__status"; } + fetch(nlAction, { method: "POST", body: data, headers: { Accept: "application/json" } }) + .then(function (r) { return r.json().catch(function () { return { success: r.ok }; }); }) + .then(function (res) { + if (res && (res.success === true || res.success === "true")) { + nl.reset(); + if (nlStatus) { nlStatus.textContent = okMsg; nlStatus.className = "form-status newsletter__status form-status--ok"; } + } else { throw new Error((res && res.message) || "failed"); } + }) + .catch(function () { + if (nlStatus) { nlStatus.textContent = errMsg; nlStatus.className = "form-status newsletter__status form-status--err"; } + }) + .finally(function () { if (btn) { btn.disabled = false; btn.innerHTML = label; } }); + }); + } + /* ---- Reel: swap poster for the YouTube embed on click ---- */ var reels = document.querySelectorAll(".reel[data-yt]"); reels.forEach(function (r) { diff --git a/public/sitemap.xml b/public/sitemap.xml index 65bd6da..2322270 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -36,4 +36,10 @@ yearly 0.6 + + https://quentinfears.com/privacy + 2026-07-22 + yearly + 0.3 + diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 0f5ca7e..60d898a 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,7 +1,10 @@ --- import { reader } from '../lib/content'; +import Newsletter from './Newsletter.astro'; const s = await reader.singletons.settings.readOrThrow(); +const showNewsletter = s.newsletter.placement === 'footer'; --- +{showNewsletter && }
@@ -27,7 +30,7 @@ const s = await reader.singletons.settings.readOrThrow();

{s.tagline}

-

© {s.copyrightYear} {s.copyrightName}. All rights reserved.

+

© {s.copyrightYear} {s.copyrightName}. All rights reserved. Privacy

diff --git a/src/components/Newsletter.astro b/src/components/Newsletter.astro new file mode 100644 index 0000000..70dd603 --- /dev/null +++ b/src/components/Newsletter.astro @@ -0,0 +1,52 @@ +--- +/** + * Newsletter signup box. A plain HTML form that posts to the Loops "custom form" + * endpoint (settings.newsletter.formEndpoint), so it adds no third-party + * JavaScript to the site: it works with JS disabled as a normal full-page POST, + * and public/assets/js/main.js progressively enhances it to an inline AJAX + * submit, mirroring the contact form. Every string is content-editable via the + * `newsletter` object in content/settings.yaml (Keystatic). + * + * `variant` only changes the wrapper styling: + * - 'footer' a full-width band shown above the site footer + * - 'section' a standalone block on the Ideas page + */ +import { reader } from '../lib/content'; + +interface Props { + variant?: 'footer' | 'section'; +} +const { variant = 'footer' } = Astro.props; + +const s = await reader.singletons.settings.readOrThrow(); +const n = s.newsletter; +--- +
+ +
diff --git a/src/pages/contact.astro b/src/pages/contact.astro index a022f12..d12c983 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -101,6 +101,7 @@ const jsonLd = { +

diff --git a/src/pages/ideas.astro b/src/pages/ideas.astro index 632a013..b9344f6 100644 --- a/src/pages/ideas.astro +++ b/src/pages/ideas.astro @@ -2,10 +2,13 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import Cta from '../components/Cta.astro'; import Picture from '../components/Picture.astro'; +import Newsletter from '../components/Newsletter.astro'; import { reader, SITE_ORIGIN } from '../lib/content'; import { personMinimal, breadcrumb } from '../lib/jsonld'; const ideas = await reader.singletons.ideas.readOrThrow(); +const settings = await reader.singletons.settings.readOrThrow(); +const showNewsletter = settings.newsletter.placement === 'ideas'; const O = SITE_ORIGIN; const jsonLd = { @@ -156,6 +159,8 @@ const jsonLd = { + {showNewsletter && } + + +
+
+

{privacy.hero.eyebrow}

+

{privacy.hero.heading}

+

{privacy.hero.intro}

+ {privacy.updated &&

Last updated {privacy.updated}.

} +
+
+ +
+
+
+ {privacy.sections.map((sec) => ( +
+

{sec.heading}

+

+
+ ))} +
+
+
+ + diff --git a/tools/newsletter_draft.py b/tools/newsletter_draft.py new file mode 100644 index 0000000..324953b --- /dev/null +++ b/tools/newsletter_draft.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +"""Assemble the monthly newsletter and create it as a DRAFT in Loops. + +The house rule is absolute: this tool never sends anything. It creates a draft +via the Loops Campaign API (POST /v1/campaigns) and stops. A human opens Loops, +reads the draft, and hits send. Silence is not approval. + +Shape of the email (kept deliberately short): + 1. A lead: one styling idea, drawn from Quentin's own notes in + content/ideas.yaml. His words, not invented opinions. + 2. "What's new": two or three items from the month, assembled from + instagram-ledger.json (what the weekly curation routine decided) and the + git history of content/*.yaml. A link back to the site. + +The window is a calendar month. By default the tool looks at the previous +calendar month relative to today; pass --month YYYY-MM to target another. Using +whole-month windows means each run covers a distinct period, so no state file is +needed and news is never double-reported across months. + +If a month has nothing new to report, the tool writes NOTHING. No draft is the +correct outcome (a padded newsletter is worse than no newsletter). That is an +explicit branch: `build_draft` returns None when there is no news, and the CLI +creates no campaign. + +Design split so the logic is unit-testable without network, git, or a YAML +dependency: the pure functions (assemble_news, select_tip, render_email, +build_draft, find_problems) take plain Python data. Only the CLI edges read the +real files, shell out to git, and call the Loops API. + +Secrets: the Loops API key is read from the LOOPS_API_KEY environment variable +(a GitHub Actions secret). It is never written to a file, a log, or the draft. + +Usage: + python3 tools/newsletter_draft.py --dry-run # render to a local file, no API + python3 tools/newsletter_draft.py --month 2026-06 # target a specific month + python3 tools/newsletter_draft.py # create the draft in Loops +""" + +from __future__ import annotations + +import argparse +import calendar +import datetime as dt +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass + +# ---- Config ----------------------------------------------------------------- +SITE_URL = "https://quentinfears.com" +LOOPS_API_BASE = "https://app.loops.so/api/v1" +MAX_NEWS = 3 # keep it short: two or three items + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Markers that must never survive into a finished draft. If any appears, the +# body was assembled from an unfilled template and must not go out. +PLACEHOLDER_MARKERS = ( + "replace_with", "{{", "}}", "todo", "tktk", "tbd", + "lorem ipsum", " tuple[dt.date, dt.date, str]: + """Return (first_day, last_day, label) for the target month. + + `month` is "YYYY-MM"; when None, the previous calendar month of `today`. + """ + today = today or dt.date.today() + if month: + year, mon = (int(p) for p in month.split("-")) + else: + first_this = today.replace(day=1) + prev_last = first_this - dt.timedelta(days=1) + year, mon = prev_last.year, prev_last.month + last = calendar.monthrange(year, mon)[1] + start = dt.date(year, mon, 1) + end = dt.date(year, mon, last) + label = start.strftime("%B %Y") + return start, end, label + + +def _parse_date(value: str) -> dt.date | None: + value = (value or "").strip()[:10] + try: + return dt.datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return None + + +# ---- News assembly ---------------------------------------------------------- +_PAGE_KEYWORDS = ( + ("ideas", "ideas"), + ("galler", "work"), + ("work", "work"), + ("speak", "speak"), + ("host", "speak"), + ("about", "about"), +) + + +def _href_for(target: str, site_url: str) -> str: + """Best-guess a link back to the page a change landed on.""" + low = (target or "").lower() + for needle, page in _PAGE_KEYWORDS: + if needle in low: + return f"{site_url}/{page}" + return f"{site_url}/" + + +def _normalize(text: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip() + + +def _clean_subject(subject: str) -> str: + # Drop a trailing PR number like " (#25)". + return re.sub(r"\s*\(#\d+\)\s*$", "", subject).strip() + + +def assemble_news( + ledger: dict, + commits: list[dict], + start: dt.date, + end: dt.date, + site_url: str = SITE_URL, + max_news: int = MAX_NEWS, +) -> list[NewsItem]: + """Build the "what's new" list from the ledger and content commits. + + Only additions and replacements count as news: an `add` or `replace` in the + ledger put new work on the site. Trims (pure removals) are editorial and are + never announced, so a month of only trims yields no news. + """ + news: list[NewsItem] = [] + seen: list[str] = [] + + def add(text: str, href: str) -> None: + text = text.strip().rstrip(".") + if not text: + return + norm = _normalize(text) + for prior in seen: + if norm == prior or norm in prior or prior in norm: + return + seen.append(norm) + news.append(NewsItem(text=text, href=href)) + + # 1) Structured decisions from the weekly curation routine. + incorporated = ledger.get("incorporated") or [] + dated = [] + for entry in incorporated: + if (entry.get("decision") or "").lower() not in ("add", "replace"): + continue + d = _parse_date(entry.get("date", "")) + if d is None or not (start <= d <= end): + continue + dated.append((d, entry)) + for _, entry in sorted(dated, key=lambda t: t[0], reverse=True): + headline = (entry.get("reason") or entry.get("target") or "").strip() + add(headline, _href_for(entry.get("target", ""), site_url)) + + # 2) Supplement from content/*.yaml commits, newest first, skipping trims. + dated_commits = [] + for commit in commits: + d = _parse_date(commit.get("date", "")) + if d is None or not (start <= d <= end): + continue + dated_commits.append((d, commit)) + for _, commit in sorted(dated_commits, key=lambda t: t[0], reverse=True): + subject = _clean_subject(commit.get("subject", "")) + if not subject: + continue + first = _normalize(subject).split(" ")[:1] + if first and first[0] in _TRIM_PREFIXES: + continue + add(subject, _href_for(subject, site_url)) + + return news[:max_news] + + +# ---- Styling tip ------------------------------------------------------------ +def select_tip(notes: list[dict], seed: int) -> tuple[str, str]: + """Pick one styling note deterministically, rotating by month. + + `notes` is the list of {title, body} from content/ideas.yaml. The rotation + means a different note leads each month; the words are always Quentin's. + """ + usable = [n for n in notes if (n.get("title") or "").strip() and (n.get("body") or "").strip()] + if not usable: + raise ValueError("no usable styling notes found in ideas.yaml notes.items") + note = usable[seed % len(usable)] + return note["title"].strip(), note["body"].strip() + + +# ---- Render ----------------------------------------------------------------- +def _esc(text: str) -> str: + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def render_email( + tip_title: str, + tip_body: str, + news: list[NewsItem], + site_url: str, + month_label: str, +) -> Draft: + """Render the subject + HTML + plain-text bodies. Kept short and on-voice.""" + subject = f"Style notes: {tip_title}" + + lead = "Here is one idea to use this week, and a quick note on what is new." + + # HTML: semantic, inline-friendly, no external assets. Loops wraps this with + # the sender template and appends the unsubscribe link + address footer. + html_parts = [ + f'

{_esc(lead)}

', + '

' + "This month's styling note

", + f"

{_esc(tip_title)}

", + 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("
    ") + text_parts += ["", "WHAT IS NEW"] + for item in news: + html_parts.append( + f'
  • {_esc(item.text)}
  • ' + ) + text_parts.append(f"- {item.text} ({item.href})") + html_parts.append("
") + + html_parts.append( + f'

See more on the site

' + ) + 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'(]*id=["\']newsletter-form["\'][^>]*>)(.*?)', + 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
)" + ) + + def check_em_dashes() -> None: """No em dashes in any shipped text file: restructure the sentence instead.""" for dirpath, dirnames, filenames in os.walk(ROOT): @@ -170,6 +243,7 @@ def main() -> int: for name in pages: check_page(name, pages) + check_newsletter_and_consent(pages) check_em_dashes() for line in info: