Volunteer local news site with events and classifieds
Volunteer-written town news with editor approval, weekly email digest, events calendar, paid classifieds and SEO-focused static pages.
nextjstypescriptsanitystripevercel
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add volunteer-local-news-site-with-events-and-classifieds-4bddbfCLAUDE.md# Town news site
## Purpose
Small-town news site run on donations. Volunteers submit stories, an editor approves them, and readers subscribe to a weekly email. The site also has an events calendar and paid classifieds (a few euros each). SEO matters: local people find stories through search.
Full scope is in SPEC.md. The phased build plan is in PLAN.md.
## Stack
- Next.js App Router with TypeScript.
- Sanity v3 Studio with GROQ queries.
- Deploy to Vercel hobby tier or a similar static-first host.
- Stripe Checkout for classifieds.
- Email provider: YOUR_EMAIL_PROVIDER (placeholder, not chosen yet).
## Commands
Scripts are planned as dev, build, start, lint, typecheck and test. After scaffolding, replace these with the real script names and keep this list accurate.
- `npm run dev`: local dev server.
- `npm run build`: production build.
- `npm run start`: serve the production build (used by the verify smoke run).
- `npm run lint`
- `npm run typecheck`
- `npm test`
- `npx sanity schema extract` (only if used)
Before saying any task is done, run the verify skill.
## Content workflow
- Sanity document `story` has a status field: `draft | submitted | approved | published`.
- The public site fetches only `approved` or `published` stories.
- Volunteers get an Editor-limited Sanity role. Only the editor role can publish.
## Rendering and cost rules
- Prefer static generation with on-demand revalidation through a Sanity webhook.
- Avoid per-request server rendering on public pages.
- Use the Sanity CDN and `next/image` with sized images.
- No heavy client JS on article pages.
- Do not add paid services without asking.
## SEO rules
- Every page has `generateMetadata`, a canonical URL and an Open Graph image.
- JSON-LD: `NewsArticle` for stories, `Event` for events.
- Generate `sitemap.xml` and `robots.txt`.
- Use semantic HTML.
## Safety
- Never commit secrets.
- Env var names: SANITY_API_READ_TOKEN, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, EMAIL_API_KEY.
- Real values go in `.env.local`. `.env.example` lists the names with placeholders only.
- Do not read `.env` or `.env.local` in a session.
## Privacy
- Subscriber emails are personal data under GDPR.
- Double opt-in for subscriptions.
- Unsubscribe link in every email.
- Never log email addresses.
## Classifieds
- A Stripe webhook must confirm payment before an ad is published.
- Ads expire automatically after a set number of days.
- Editors can remove any ad.
## Gotchas
- Sanity drafts have IDs prefixed `drafts.`. Public queries must filter them out.
- The webhook revalidation endpoint needs a shared secret.
- Stripe: test mode only until the editor signs off.
## Workflow
- Explore, plan, code, then verify.
- Use plan mode before each phase in PLAN.md.
- Use `/clear` between unrelated tasks and between phases.
## Review
@REVIEW.md
.claude/agents/test-runner.md---
name: test-runner
description: Use for running lint, typecheck, tests and build on this site and reporting only the failures. Use it instead of running these checks in the main session.
model: haiku
tools: Bash, Read, Grep, Glob
maxTurns: 12
---
You run the project's checks and report results. You do not fix code.
Run these commands from the repository root, in this order, and stop at the first failure:
1. `npm run lint`
2. `npm run typecheck`
3. `npm test`
4. `npm run build`
If a script name differs from these, check the scripts section of `package.json` and use the real name. Say so in your report.
For each command, report one line: `PASS <command>` or `FAIL <command>`.
For a failing command, include only the relevant error lines, trimmed to about 30 lines. Include the file path, line number and the first error message. Leave out repeated errors and progress output.
Do not edit, create or delete files. Do not read `.env` or `.env.local`. Do not run `npm install`, `git push` or any deploy command.
End with one line: `Overall: PASS` if all four commands passed, otherwise `Overall: FAIL` followed by the name of the first failing command.
.claude/settings.json{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"model": "sonnet",
"fallbackModel": ["haiku"],
"permissions": {
"allow": [
"Bash(npm run dev)",
"Bash(npm run build)",
"Bash(npm run lint)",
"Bash(npm run typecheck)",
"Bash(npm test)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(npx sanity --help)"
],
"ask": [
"Bash(git push:*)",
"Bash(npm install:*)",
"Bash(npx sanity deploy:*)",
"Bash(stripe:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.local)",
"Bash(rm -rf:*)",
"Bash(git push --force:*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); if [ -n \"$f\" ] && [ -x node_modules/.bin/prettier ]; then node_modules/.bin/prettier --write \"$f\" >/dev/null 2>&1; fi; exit 0"
}
]
}
]
}
}
.claude/skills/sanity-content-model/SKILL.md---
name: sanity-content-model
description: Use when changing Sanity schemas, GROQ queries or Studio roles for the town news site, so drafts and unapproved stories stay private and existing content keeps working.
---
# Sanity content model
Use this skill for any change to schemas, queries or roles. Read `SPEC.md` for the content models first.
## Rules
- **Add fields, do not rename.** Renaming breaks existing documents. If a rename is unavoidable, follow the migration checklist below.
- **Add validation** for required fields, slug uniqueness and length limits where relevant.
- **Alt text is required** on every image field. Keep `alt` as a required field on `mainImage`.
- **Status values are fixed**: `draft`, `submitted`, `approved`, `published`. Do not add values without updating the editor workflow in `CLAUDE.md` and `SPEC.md`.
- **Only the editor role publishes.** Volunteers get a role that can create and submit but not publish. Check the role config after any role change.
- **Public queries exclude drafts.** Every public query must filter out `drafts.` ids.
## GROQ patterns
Public stories (approved or published, no draft ids):
```groq
*[_type == "story"
&& !(_id in path("drafts.**"))
&& status in ["approved", "published"]]
| order(publishedAt desc) {
_id, title, "slug": slug.current, summary, publishedAt,
"category": category->title,
"image": mainImage { asset->{url, metadata{dimensions}}, alt }
}
```
Single story by slug (same filters, plus a slug match):
```groq
*[_type == "story"
&& slug.current == $slug
&& !(_id in path("drafts.**"))
&& status in ["approved", "published"]][0]
```
Upcoming events:
```groq
*[_type == "event" && !(_id in path("drafts.**")) && start >= $now]
| order(start asc)
```
Add a test for each new public query that checks drafts and unapproved statuses are excluded.
## Adding a schema
1. Create the schema file in the Sanity schema folder and register it in the schema index.
2. Add validation and a required `alt` on any image.
3. Regenerate types with the type generation script named in `CLAUDE.md` (for example `npx sanity typegen generate`). Commit the generated types.
4. If the document should trigger revalidation, add its `_type` to the revalidation webhook handler and its tests.
5. Run the verify skill, including step 7 (`npx sanity build`).
## Migration checklist
Use this before changing existing documents or field shapes.
- [ ] Write down the affected `_type` and field names.
- [ ] Take a dataset export: `npx sanity dataset export`. Store it outside the repo.
- [ ] Write the migration as a dry run first. Log counts only, never email addresses or tokens.
- [ ] Run the migration on a development dataset and check a sample of documents in the Studio.
- [ ] Check that public queries still return approved stories after the change.
- [ ] Ask the editor to confirm before running on production. Do not run production migrations without asking.
- [ ] Note the change in the PR description.
.claude/skills/seo-check/SKILL.md---
name: seo-check
description: Use after adding or changing any public page on the town news site, to audit metadata, canonical links, structured data, sitemap entries and heading structure.
---
# SEO check
Run this for every new or changed public page. Use a real approved story or event slug. Start the app with `npm run dev` or `npm run start`.
## Checks
Fetch the page HTML once, then check each item:
```bash
curl -s "http://localhost:3000/PATH" > /tmp/page.html
```
1. **Metadata**: `<title>` and `<meta name="description">` exist and are unique to the page.
2. **Canonical**: `<link rel="canonical">` points to the full public URL of this page, not a draft or preview URL.
3. **Open Graph image**: `<meta property="og:image">` exists and returns HTTP 200 when fetched. Check it with `curl -sI`.
4. **JSON-LD type fits the page**:
- Story pages use `NewsArticle`.
- Event pages use `Event`.
- Other pages may use `WebPage` or `CollectionPage`. Do not use `NewsArticle` for list pages.
5. **Sitemap entry**: `/sitemap.xml` includes the page URL, and only public, published content is listed.
6. **Heading structure**: exactly one `<h1>`, and headings go down one level at a time (no jump from `h2` to `h4`).
7. **Image alt text**: every `<img>` has an `alt` attribute. Decorative images use `alt=""`.
## Validate JSON-LD
Extract each JSON-LD block and parse it. A parse error or missing `@type` is a fail.
```bash
node -e '
const html = require("fs").readFileSync("/tmp/page.html", "utf8");
const re = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g;
let m, n = 0, ok = true;
while ((m = re.exec(html))) {
n++;
try {
const data = JSON.parse(m[1]);
const items = Array.isArray(data) ? data : [data];
items.forEach(d => console.log("type:", d["@type"] || "MISSING"));
} catch (e) { ok = false; console.log("parse error:", e.message); }
}
console.log("blocks:", n, ok ? "valid" : "INVALID");
'
```
## Optional Lighthouse check
Run against a running server. The target is an SEO score of at least 95 on story pages.
```bash
npx lighthouse "http://localhost:3000/PATH" --only-categories=seo --output=json --quiet --chrome-flags="--headless" > /tmp/lh.json
node -e 'const r=require("/tmp/lh.json"); console.log("SEO score:", Math.round(r.categories.seo.score*100))'
```
If Lighthouse is not installed, say so in the report. Do not install a new paid tool.
## Output format
Report one line per check:
```
PASS metadata
PASS canonical
FAIL og:image (returns 404)
PASS json-ld NewsArticle (1 block, valid)
...
```
End with the overall result and the list of failing checks. Fix failures, then rerun the check on the same page.
.claude/skills/verify/SKILL.md---
name: verify
description: Use before claiming any task is done. Runs the lint, typecheck, test, build, smoke and SEO checks for the Next.js and Sanity site and reports real output.
---
# Verify
Run these steps in order. Stop at the first failing step, fix it, then rerun from step 1.
## Steps
1. **Lint**: `npm run lint`
2. **Typecheck**: `npm run typecheck`
3. **Tests**: `npm test`
4. **Build**: `npm run build`
5. **Smoke run**: start the built app and check responses.
- Run `npm run start` in the background and wait until it listens on port 3000.
- Check each URL returns HTTP 200:
```bash
for path in / /sitemap.xml /robots.txt; do
curl -s -o /dev/null -w "%{http_code} $path\n" "https://example.com"
done
```
- Check one approved story page. Replace `SLUG` with the slug of a story set to `approved` or `published` in the Studio:
```bash
curl -s -o /dev/null -w "%{http_code} /stories/SLUG\n" "http://localhost:3000/stories/SLUG"
```
- Stop the server when done.
6. **SEO spot check**: fetch the built HTML of one story page and grep it.
```bash
curl -s "http://localhost:3000/stories/SLUG" > /tmp/story.html
grep -o '<link rel="canonical"[^>]*>' /tmp/story.html
grep -o '<meta property="og:title"[^>]*>' /tmp/story.html
grep -o '<script type="application/ld+json">' /tmp/story.html
```
Each grep must print a match. A missing match is a failure.
7. **Sanity Studio**: only if files under the Sanity schema folder or query code changed. Run `npx sanity build` and confirm it finishes without errors.
## Reporting
- For each step, write `PASS` or `FAIL` with a one-line summary of the command output (for example, "42 tests passed").
- On failure, quote only the first error, not the full log. Fix it, then rerun from step 1.
- Never say a task is done without showing this summary for every step that applies.
- If you use the `test-runner` subagent for steps 1 to 4, still check its output yourself before reporting.
INSTALL.md# Install guide
This guide is for someone with some development experience. It sets up the town news site with Next.js, Sanity and Claude Code.
## Prerequisites
- Node 20 or newer
- git
- A Sanity account
- A Stripe test account (test mode only until the editor signs off)
- Claude Code 2.1.296 or newer
## 1. Update Claude Code
```
claude update
claude --version
```
The version should be 2.1.296 or newer.
## 2. Place the project files
Copy these into the root of your repo:
- `CLAUDE.md`, `SPEC.md`, `PLAN.md`, `REVIEW.md`, `INSTALL.md`
- The `.claude/` folder (settings, skills and agents)
## 3. Scaffold the app if the repo is empty
```
npx create-next-app@latest --typescript
npm create sanity@latest
```
Then open `package.json` and match the script names in CLAUDE.md. Claude will fill in the real names during Phase 1.
## 4. Create your local environment file
Copy the template and fill in your own values:
```
cp .env.example .env.local
```
Replace placeholders such as `YOUR_SANITY_PROJECT_ID` and `YOUR_API_KEY` with your real values. Never commit `.env.local`.
## 5. Install plugins
Add the design skills marketplace:
```
claude plugin marketplace add anthropics/skills
```
Install the plugins:
```
claude plugin install feature-dev@claude-plugins-official
claude plugin install code-review@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
claude plugin install frontend-design@anthropic-agent-skills
```
Then start Claude Code and run `/plugin` to check that everything installed.
## 6. Start Claude Code
From the repo folder:
```
claude
```
Run `/init` only if CLAUDE.md is missing. Check that `/skills` lists `verify`, `sanity-content-model` and `seo-check`.
## 7. Paste the kickoff prompt
Paste this prompt to start Phase 1:
```
/goal Phase 1 scaffold for the town news site. Read CLAUDE.md, SPEC.md and PLAN.md first.
End state: Next.js (App Router, TypeScript) with a Sanity Studio and the story, event and
classified schemas from SPEC.md. Volunteer role cannot publish; editor role can. Home page
lists approved stories via a GROQ query that excludes drafts. .env.example has placeholder
names only. npm scripts dev, build, start, lint, typecheck and test all exist and work.
Fill in the real script names in CLAUDE.md and make the verify skill match them.
Check: npm run lint && npm run typecheck && npm test && npm run build. Show the output
summary. Add a test for the approved-only GROQ filter. Constraints: no paid services, no
real secrets, do not read .env files, no Stripe, email or SEO yet, keep public pages static,
edit only this repo. Use the test-runner subagent. Stop after 40 turns and report what remains.
```
Review the result with `/code-review` before you merge.
## 8. Use the later phases
Run `/clear` before each phase in PLAN.md. Use plan mode first, then the phase's `/goal` prompt.
## Hosting cost tips
- Vercel Hobby or Cloudflare Pages are good starting points for static-first sites.
- Check the Sanity free plan limits for documents, assets and API usage.
- Check each provider's terms before you run a donation-funded site on a personal or hobby tier.
## Daily habits
- Run `/clear` between unrelated tasks.
- Run `/compact` at natural breaks.
- Run `/usage` to see where tokens went.
- Use Sonnet by default. Switch to Opus for planning with `/model`.
- Let Claude explore, plan, code and verify, and give it a way to check its work.
## Stripe
Use Stripe test mode only until the editor signs off on classifieds payments. Use test card numbers and the Stripe CLI only in test mode.
PLAN.md# Build plan
Five phases. Each phase ends with a `/goal` prompt. Run them in order.
## Before every phase
- Run `/clear` so the previous phase's context does not carry over.
- Enter plan mode (Shift+Tab) and ask Claude to explore and propose the steps.
- Approve the plan, then paste the phase's `/goal` prompt.
- If Claude stops at the turn bound, read its report and decide whether to continue.
## Check command
Every phase must pass this before it is done:
```
npm run lint && npm run typecheck && npm test && npm run build
```
## Phase 1: Scaffold
Next.js with TypeScript, an embedded Sanity Studio, schemas for story, event and classified, roles, `.env.example`, and the verify script wired.
Done when the check passes and a placeholder home page lists approved stories.
```
/goal Scaffold the town news site per SPEC.md and CLAUDE.md. End state: Next.js App Router with TypeScript; embedded Sanity Studio; schemas for story (status draft/submitted/approved/published), event and classified as in SPEC.md; a volunteer role that cannot publish and an editor role that can; a home page that lists approved stories via a GROQ query that excludes drafts; .env.example with placeholder names only; npm scripts dev, build, start, lint, typecheck and test that all exist. Update CLAUDE.md with the real script names and make the verify skill match them. Check: npm run lint && npm run typecheck && npm test && npm run build must all pass; show the output summary. Add at least one test for the approved-only GROQ filter. Constraints: no paid services; no real secrets; do not read .env files; do not implement Stripe, email or SEO yet; do not touch files outside this repo. Use the test-runner subagent for checks. Stop after 40 turns and report what remains.
```
## Phase 2: Stories and editor workflow
Submission statuses, GROQ queries that filter drafts, home, story and category pages, and webhook revalidation.
Done when a story moves from submitted to published in Studio and its pages update.
```
/goal Implement stories and the editor workflow per SPEC.md section "Submission and approval flow". End state: story status transitions work in Studio; public GROQ queries return only approved or published documents and exclude drafts. prefixed ids; home, story and category pages render with static generation; a Sanity webhook endpoint revalidates the affected pages and checks a shared secret from env var SANITY_REVALIDATE_SECRET (placeholder in .env.example only). Check: npm run lint && npm run typecheck && npm test && npm run build must all pass; show the output summary. Add tests for the GROQ filter and for the webhook rejecting a wrong secret. Constraints: no paid services; no real secrets; do not read .env files; no per-request server rendering on public pages; do not implement Stripe, email or SEO metadata yet. Stop after 40 turns and report what remains.
```
## Phase 3: SEO
Metadata, JSON-LD, sitemap, robots, image handling, and a Lighthouse check.
Done when story pages score at least 95 on Lighthouse SEO.
```
/goal Add SEO per SPEC.md "Acceptance criteria" for SEO. End state: every public page has generateMetadata with a canonical URL and Open Graph image; story pages emit valid NewsArticle JSON-LD and event pages emit Event JSON-LD; sitemap.xml lists all published stories and upcoming events; robots.txt is generated; images use next/image with alt text and sized dimensions. Check: npm run lint && npm run typecheck && npm test && npm run build must all pass, and a Lighthouse SEO run on one built story page scores at least 95 (use the seo-check skill). Show the output summary. Add a test that parses the JSON-LD. Constraints: no paid services; no real secrets; do not read .env files; keep story pages static; do not implement Stripe or email yet. Stop after 40 turns and report what remains.
```
## Phase 4: Events and classifieds
Events list and calendar view, Stripe Checkout, the Stripe webhook, and ad expiry.
Done when a test payment publishes an ad and the ad expires after 30 days.
```
/goal Implement events and classifieds per SPEC.md. End state: events list and a keyboard-accessible calendar month view using Sanity event documents; classifieds list and submit form that starts Stripe Checkout at a fixed euro price in test mode; a Stripe webhook that verifies the signature, is idempotent, and sets paymentStatus to paid before an ad is published; ads expire 30 days after publishing and are hidden after that; the editor can remove any ad. Env var names only in .env.example: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Check: npm run lint && npm run typecheck && npm test && npm run build must all pass; show the output summary. Add tests for signature rejection, duplicate webhook events and expiry. Constraints: Stripe test mode only; no real keys; do not read .env files; no paid services beyond Stripe test mode; do not implement email yet. Stop after 40 turns and report what remains.
```
## Phase 5: Newsletter and launch
Subscribe flow with double opt-in, weekly digest job with dry run, privacy page, and a deploy checklist.
Done when the digest dry run writes correct HTML and the deploy checklist is complete.
```
/goal Implement the newsletter and launch prep per SPEC.md. End state: subscribe form with double opt-in through YOUR_EMAIL_PROVIDER using placeholder env var EMAIL_API_KEY (name only in .env.example); every email has a working unsubscribe link; a weekly digest job gathers stories approved or published in the last 7 days and upcoming events; its dry-run mode writes the HTML to a file and sends nothing; no email addresses are logged; privacy page describes storage, use and removal of subscriber data; DEPLOY.md lists the launch checklist (env vars, Sanity webhook, Stripe live-mode switch, cron schedule, backups). Check: npm run lint && npm run typecheck && npm test && npm run build must all pass; show the output summary. Add tests for digest generation and unsubscribe. Constraints: no real secrets; do not read .env files; no paid services without a note; do not switch Stripe to live mode. Stop after 40 turns and report what remains.
```
REVIEW.md# Review checklist
Use this list for every change. Claude Code reads it through the import in CLAUDE.md, and /code-review applies it.
## Data and queries
- Public queries never return drafts or unapproved stories.
- Every public GROQ query filters out `drafts.` ids and only returns `approved` or `published` stories.
- GROQ filters have tests.
## Secrets and config
- No secrets or tokens appear in client code or committed files.
- Only `NEXT_PUBLIC_` variables are exposed to the browser.
- `.env.local` is never committed and no `.env` file is read.
## Pages and SEO
- Every new page has metadata, a canonical URL and structured data where relevant (NewsArticle for stories, Event for events).
- Images have alt text and sized dimensions.
- No change forces per-request server rendering on public pages.
## Payments and classifieds
- The Stripe webhook verifies the signature and is idempotent.
- Ads publish only after confirmed payment.
- Expired ads drop out of listings.
## Subscribers and privacy
- Double opt-in is required before any email is sent.
- Every email has a working unsubscribe link.
- Subscriber emails never appear in logs.
## Accessibility
- Headings follow a logical order.
- Link text describes the destination.
- Colour contrast meets WCAG AA.
- The events calendar works with the keyboard alone.
## Cost
- No change adds a paid service without a note in the PR.
- No heavy client JavaScript on article pages.
SPEC.md# Town news site: spec
## Purpose
A volunteer-written local news site for a small town. Stories are written by volunteers, approved by an editor, and published. Readers subscribe to a weekly email. The site also has a events calendar and paid classifieds. It is funded by donations and must stay cheap to host. SEO matters.
## Users
- **Reader:** reads stories, events and classifieds. No account.
- **Subscriber:** a reader who has confirmed a double opt-in for the weekly email.
- **Volunteer writer:** writes stories in Sanity Studio and sets status to `submitted`. Cannot publish.
- **Editor:** reviews submissions, approves or publishes, and can remove any ad.
- **Classified advertiser:** pays a fixed fee to place an ad that expires automatically.
## Content models
**story** (Sanity document)
- title, slug, summary, body (portable text), author, category
- mainImage with required alt text
- status: `draft` | `submitted` | `approved` | `published` (fixed values)
- publishedAt
**event** (Sanity document)
- title, slug, start, end, location, description, link
**classified** (Sanity document)
- title, body, price (optional), contact method, category, expiresAt
- paymentStatus: `unpaid` | `paid` | `refunded`
**subscriber** is not a Sanity document. It lives in the email provider (`YOUR_EMAIL_PROVIDER`).
## Submission and approval flow
1. A volunteer logs in to Sanity Studio, writes a story, and sets status to `submitted`.
2. The editor reviews the story and sets it to `approved` or sends it back.
3. Publishing sets status to `published` and sets publishedAt. Only the editor role can do this.
4. The publish triggers a Sanity webhook. The webhook revalidates the affected pages using a shared secret.
5. Public queries return only `approved` or `published` stories and never `drafts.` documents.
## Pages
- Home: latest published stories and upcoming events
- Story page: article view with JSON-LD (NewsArticle)
- Category page: stories in one category
- Events: list view and calendar month view (keyboard accessible)
- Classifieds: list, and a submit form that starts Stripe Checkout
- Subscribe: email form with double opt-in
- About
- Privacy: how subscriber data is stored, used and removed
## Weekly email
- A scheduled job (cron on the host or a GitHub Action) runs once a week.
- It gathers stories approved or published in the last 7 days and upcoming events.
- It sends the digest through `YOUR_EMAIL_PROVIDER`.
- Every email has an unsubscribe link.
- A dry-run mode writes the HTML to a file and sends nothing.
## Classifieds
- Stripe Checkout at a fixed price in euros.
- A Stripe webhook marks the ad as `paid`. The ad is not published before that.
- Ads expire 30 days after publishing.
- The editor can remove any ad at any time.
## Acceptance criteria
**SEO**
- Lighthouse SEO score of at least 95 on story pages.
- Every page has a canonical URL, Open Graph image and valid JSON-LD (NewsArticle for stories, Event for events).
- sitemap.xml lists all published stories and upcoming events.
**Performance and cost**
- Story pages are statically generated.
- The JavaScript bundle on story pages stays small.
- No always-on server.
**Privacy**
- Subscribers confirm by double opt-in.
- Unsubscribe works from every email.
- Email addresses are not written to logs.
## Out of scope for v1
- Comments
- Reader accounts
- Ad networks
- Native app
## Open questions for the editor
- Local currency and VAT handling for classifieds
- Moderation rules for classifieds
- Email provider choice