Skip to content

feat(seed): fried rice around the world (12 recipes, 7 countries) - #17

Open
thunpisit wants to merge 2 commits into
mainfrom
feat/fried-rice-around-world-seed
Open

feat(seed): fried rice around the world (12 recipes, 7 countries)#17
thunpisit wants to merge 2 commits into
mainfrom
feat/fried-rice-around-world-seed

Conversation

@thunpisit

Copy link
Copy Markdown

Expands the example seed from 5 Thai fried rice recipes → 12 recipes across 7 countries so the demo shows Khao Pad handling international content, not just Thailand-specific.

What changed

  • scripts/seed-example.ts — now seeds 12 articles (was 5) with EN + TH bodies, all under a new Fried Rice category:

    • 🇹🇭 Classic Khao Pad, Khao Pad Goong (shrimp), Pineapple, Jay (vegetarian), Krapao
    • 🇨🇳 Yangzhou Fried Rice, Egg Fried Rice (Dan Chao Fan)
    • 🇮🇩 Nasi Goreng
    • 🇰🇷 Kimchi Bokkeumbap
    • 🇯🇵 Chahan
    • 🇵🇪 Arroz Chaufa (Chinese-Peruvian chifa)
    • 🇺🇸 Cajun Dirty Rice
  • 7 new region tags (Thailand, China, Indonesia, Korea, Japan, Peru, USA) alongside the existing 5 descriptor tags (rice, street-food, quick, vegetarian, spicy) → 12 tags total. Storefront can filter by geography — good long-tail SEO for "authentic X fried rice" queries.

  • README updated to reflect the international angle.

Why

Sets up the story arc for v3.1 (Fried Rice around the World + shop): the same website will grow a cookbook, sauce line, tote/wok merch once codustry/khaopad#56 ships. Doing the content expansion now means v3.1 lands into a site that already has readers/traffic patterns worth analyzing.

Re-runnable

Every INSERT uses OR IGNORE on deterministic IDs. Safe to apply on top of an existing seeded database — new rows land, old rows stay put. The category slug changed (thai-cuisinefried-rice, new ID seed_cat_friedrice), so both categories will co-exist after a re-seed. If you want a clean cut, drop the old seed_cat_thai* rows first.

Test plan

  • pnpm db:seed:example (local D1) — verify 12 articles + 12 tags land
  • Visit `/blog` on local — 12 recipes render, each with correct region tag
  • Language switcher — Thai bodies present on every recipe
  • Deploy preview branch to Cloudflare — verify remote seed works end-to-end
  • JSON-LD + RSS pick up the new articles cleanly

Related

  • Phase 0 of Khao Pad v3 plan
  • Follow-up in v3.1: codustry/khaopad#56 will add `seed-shop-example.ts` (cookbook + sauce line + merch) so this site becomes a working fried-rice-shop demo

thunpisit added a commit that referenced this pull request Jul 30, 2026
….5) (#20)

* feat(routing): move CMS from cms.* subdomain to /cms path prefix (#11)

Khao Pad now serves both the public site and the admin CMS from a
single host:

  /            → (www)/   public site
  /cms/*       → (cms)/   admin panel
  /api/auth/*  → Better Auth handler

This is one of the standard patterns for SaaS admin panels (Sanity
Studio, Strapi admin, KeystoneJS all live at /admin or /studio) and it
unblocks the obvious deploy paths the subdomain-only design did not:

- Cloudflare workers.dev URLs (no sub-subdomains allowed on free tier)
- Local dev without /etc/hosts editing
- Single-host demos (the example fork's khaopad-example showcase)

It also lines up with Paraglide JS's own recommendation that public
content lives behind URL-prefixed locales (`/en/blog`, `/th/blog`) for
SEO, while private routes (admin) read locale from a cookie. The
existing strategy `["url", "cookie", "baseLocale"]` already does
exactly this — the URL strategy doesn't match `/cms/*` so it falls
through to cookie automatically.

What changed:

- Moved every (cms) route one folder deeper under /cms — file-based
  rename, no logic change
- Replaced subdomainHook with surfaceHook in src/hooks.server.ts.
  Surface is decided by `event.url.pathname.startsWith('/cms')`
- locals.surface is the new property; locals.subdomain kept as a
  deprecated alias so older code reading it doesn't break
- Updated every absolute path in (cms) (server redirects + svelte
  hrefs) to the /cms-prefixed equivalent
- wrangler.toml: simplified routes block to a single host pattern;
  example URLs collapsed from www./cms. to one domain
- README.md: rewrote subdomain-era language, removed /etc/hosts step,
  updated env-var docs and deploy checklist

What did NOT change:

- Better Auth basePath stays at /api/auth — auth is an API, not a
  page; URL prefixes don't add security; matches NextAuth idiom
- Paraglide config in vite.config.ts — same strategy array works
  unchanged
- (www) routes, schema, drizzle migrations, ContentProvider — none
  touched
- Visual design — this PR is structural only; the shadcn reskin lands
  in a follow-up

Verification:

- pnpm install: clean (lockfile regenerated to track the
  @opentelemetry/api optionalDependency)
- pnpm check: 0 errors (16 warnings are pre-existing)
- pnpm lint: clean
- pnpm build: succeeds

Follow-up work (separate PRs):

- Mass-update docs/{ARCHITECTURE,BETTERAUTH,PLATFORM-NOTES,MIGRATING,
  DEPLOYMENT}.md — they still reference subdomain routing in places
- Reskin (cms) layout + login with shadcn Sidebar (workflow-style)
- Apply this change to codustry/khaopad-example to fix /cms/signup
  on the live demo

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(cms): shadcn-style reskin of admin shell + login + signup (#12)

Modernizes the CMS surface with the same design pattern as
codustry/workflow. Stacks on the path-prefix routing landed in #11.

Sidebar:
- Hand-rolled collapsible Sidebar (workflow's pattern, not shadcn's
  Sidebar primitive). localStorage-persisted collapsed state, lucide
  icons, active-route highlighting that survives nested paths
  (/cms/articles/[id] keeps Articles active), role-gated Users +
  Settings items, icon-only mode when collapsed with tooltips.

Layout shell:
- Sticky topbar, mobile sheet drawer behind a burger toggle
- Cookie-based locale toggle (CmsLocaleToggle) that calls Paraglide's
  setLocale. Strategy ["url","cookie","baseLocale"] falls through to
  cookie for /cms/* (no /en or /th URL prefix to match), so admin
  URLs stay clean.
- Logout posts to /api/auth/sign-out then redirects to /cms/login

Login + signup:
- Two-column auth pattern on lg+ (brand panel + form), single column
  on mobile. Soft blurred primary-color shapes, IBM Plex tagline.
- Inputs and buttons use the new shadcn primitives so theme tokens
  flow through automatically.

shadcn primitives in $lib/components/ui/:
- Button (with size + variant via tailwind-variants)
- Input, Label
- Card (+ Header/Title/Description/Content/Footer)
- Separator, Badge, Avatar (with initials fallback when image
  fails — pattern from workflow)

Theme (src/app.css):
- oklch palette matching shadcn-svelte and workflow conventions
- .dark block with inverted lightness — palette ready, toggle UI
  lands in a follow-up
- Larger base radii (0.5rem md), sidebar sub-palette, font tokens
  pointing at IBM Plex Sans Thai with system fallbacks

Out of scope (intentional):
- CRUD pages (articles list/edit, media, categories, tags) keep their
  existing markup. They use the same theme tokens, so the visual
  baseline lifts automatically. Per-page polish in follow-ups.
- DropdownMenu / Sheet primitives — used a hand-rolled overlay for
  the mobile drawer instead of pulling in another bits-ui surface.
- Dark mode toggle UI.

Verification: pnpm check 0 errors, pnpm lint clean, pnpm build
succeeds (~127kB server entry).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(www): redirect bare / to localized home (/en or /th) (#13)

* fix(www): redirect bare / to localized home (/en or /th)

* fix(www): remove stale root +page.svelte (now redirected)

* fix(auth): wrap D1 to coerce Date → ISO at bind time (#14)

* fix(auth): pass request headers + harden getSession against bad cookies (#15)

* fix(hooks): wrap auth.getSession in try/catch so malformed cookies don't 500 every page

* fix(signup): pass request.headers to auth.api.signUpEmail for cookie ctx

* fix(cms): drop unimplemented /cms/users and /cms/settings from sidebar (#16)

* chore: remove GitHub-backed content storage (Mode B) (#17)

The "Mode B" GitHub-as-content-database concept has been a stub since
M1 — the GitHubContentProvider throws "not yet implemented". It was
draft scope, not shipped scope. This removes it entirely so the
shipped product is sharper.

Why it goes:

- Two storage backends double the bug surface (categories, tags,
  search, drafts all need parallel implementations).
- The pitch breaks the moment you have media: articles are
  git-versioned but R2 images aren't, so the diff shows an opaque
  cover_media_id change.
- GitHub's API is the wrong shape — rate-limited, no transactions,
  200-500ms latency vs D1's sub-10ms.
- The audience that wants git-versioned content reaches for Astro +
  static generators, not a CMS. Khao Pad serves a different niche.
- Documentation tax: half the docs explain trade-offs between two
  modes most users will never compare.

What changes:

- Delete src/lib/server/content/providers/github.ts (the stub).
- Collapse createContentProvider() to direct D1 instantiation; drop
  the ContentMode type.
- Remove CONTENT_MODE + GITHUB_OWNER/REPO/BRANCH/TOKEN from
  wrangler.toml, src/app.d.ts, README env table.
- Delete .github/workflows/content-sync.yml (only invalidated KV
  on `content/**` pushes — irrelevant for D1 mode).
- Rewrite docs:
  - README "Storage Modes" → "Storage" (D1 + R2 description)
  - ARCHITECTURE.md drops the GitHub provider line + "stub" row
  - CONTENT-MODEL.md keeps the abstraction discussion but reframes
    it as "kept for tests + future flexibility, not a multi-backend
    switch"
  - MIGRATING.md drops the "swap CMS to file-backed when available"
    option from the migration choices
  - CLAUDE.md updates to D1-only language
- README roadmap updates: v1.1 list now reflects what actually
  shipped (path prefix, shadcn reskin, Date-binding fix) and what's
  next (user management UI, settings UI, OAuth).

What stays:

- The ContentProvider interface itself. Cheap to maintain (one extra
  method call per query), earns its keep for testing seams.

Verification: pnpm check 0 errors, pnpm lint clean, pnpm build OK.
Net diff: 12 files, +44 / -240.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs(milestones): add v1.1 release notes, restructure pending list (#18)

The milestones doc had drifted: M3 still mentioned /register (now
/cms/signup), pending list still mentioned the GitHub provider that
got deleted in PR #17, and the actual v1.1 work — six PRs landing
between #11 and #17 — was not documented anywhere.

This commit:

- Adds a "v1.1 — Path-prefix routing, shadcn reskin, scope
  tightening" section under Shipped, grouping the PRs into routing /
  admin reskin / auth resilience / scope tightening with a
  one-paragraph framing.
- Updates M1's bullet from "D1 + stub GitHub implementations" to just
  "D1 implementation" (no more lying about the provider that got
  deleted).
- Updates M3's reference from /register → /cms/signup.
- Updates M4 + M5 references to use the new /cms/* paths.
- Replaces "Pending v1.1+" with concrete next milestones: v1.2 (user
  management UI, settings UI) and v1.3+ (OAuth, audit trail,
  versioning, scheduled publishing, full-text search).

No code changes.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cms): keep URL clean when toggling locale in admin (#19)

Reported: clicking the EN/TH toggle in the CMS topbar at /cms/articles
rewrote the URL to /th/cms/articles. Expected: cookie-only locale
switch, URL unchanged.

Cause: the toggle called Paraglide's setLocale(), which iterates all
configured strategies in ["url", "cookie", "baseLocale"] and the URL
strategy fires unconditionally — it doesn't check whether the path
actually has a locale prefix to swap, it just rewrites
window.location.href via localizeUrl(). There's no per-strategy
opt-out on setLocale().

Fix: write the PARAGLIDE_LOCALE cookie directly + reload. The (www)
public-site toggle is unchanged and continues to use URL-based
switching (/en/blog ↔ /th/blog) for SEO.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.2): user management + site settings admin pages (#20)

Closes the two sidebar 404s left by v1.1.

## /cms/users

- List all users with avatar, name, email, role badge, joined date.
- Inline "Change role" dropdown per row. Last-super-admin demotion is
  blocked with a clear error; plain admins can manage editors and
  authors but not other admins.
- Hard-delete with confirm; sessions and accounts cascade via existing
  FK rules; articles authored by the user block the delete with a
  surfaced "reassign first" message instead of a 500.
- Cannot change your own role or delete yourself (defensive — locks
  the route owner out otherwise).
- Every role change and deletion writes an audit_log row (best-effort,
  swallowed if the table isn't available so it never fails the action).
- Invite-link card surfaces the existing /cms/signup flow as the MVP;
  a real token-based invite system lands later.

## /cms/settings

- Reads from + writes to the existing site_settings KV-shape table via
  the ContentProvider's getSettings/updateSettings methods.
- Form covers siteName, defaultLocale, supportedLocales, cdnBaseUrl.
- Validates: defaultLocale must be in supportedLocales; site name
  required; at least one locale required.

## Permission helper

- New canManageUser(actor, target) helper centralizes the three rules
  (no self-management, super_admin protection, admin-can't-touch-admin).
  Both the server actions and the UI use it so the buttons that
  appear match the actions that succeed.

## Sidebar

- Re-adds /cms/users + /cms/settings to the "Admin" group, role-gated
  to super_admin and admin only.

## i18n

- 38 new Paraglide keys (EN + TH): role labels, field labels, help
  text, error strings, invite-card copy.

Verification: pnpm check 0 errors, pnpm lint clean, pnpm build OK.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs(milestones): mark v1.2 (user management + settings) as shipped (#21)

PR #20 added /cms/users and /cms/settings, completing the work that
was in the Pending section as v1.2. Promotes that section into
Shipped, refines the v1.3+ wishlist (replace generic 'invite-by-email'
bullet with the more accurate 'token-based invitations replace the
v1.2 share-signup placeholder').

* fix(cms): redirect bare /cms to /cms/dashboard or /cms/login (#22)

There was no +page.svelte at the /cms root, so visiting it returned
404. Authenticated users should land on the dashboard, everyone else
on the login page. Add a tiny load function that does that based on
locals.user (already resolved by the (cms) layout).

* fix(settings): treat undefined/null values as delete instead of NULL bind (#23)

Reported: changing the site name in /cms/settings crashed with
"D1_ERROR: NOT NULL constraint failed: site_settings.value".

Cause: D1ContentProvider.updateSettings iterates every key in the
patch payload and stringifies. For a key whose value is `undefined`
(e.g. an optional form field left blank — `cdnBaseUrl` in our case),
`JSON.stringify(undefined)` returns the JS value `undefined`, not
the string "undefined". Drizzle binds that as SQL NULL, which the
NOT NULL constraint on `value` rejects. Note: the failure happened
on the `cdnBaseUrl` row, but the error surfaced during the same
batch as the site-name change, so it looked like the site name was
the problem.

Fix: treat undefined and null as a delete instead of an upsert. The
row goes away, the caller can re-create it later by sending a real
value, and the NOT NULL column never sees a null bind.

* feat(v1.3): invitations + audit log viewer + scheduled publishing (#24)

Three v1.3 milestones land in one PR — they share the audit_log
infrastructure, the admin shell, and the (cms) layout gate, and
shipping them separately would create churn.

## Token-based invitations (replaces v1.2 placeholder)

- New `invitations` table (Drizzle migration 0001) with email, role,
  token, expiresAt, acceptedAt. Token is a random base64url string
  (~128 bits of entropy); the URL itself is the bearer credential —
  presence + unconsumed + not-expired = valid.
- Helpers in `$lib/server/invitations/`: createInvitation,
  findInvitationByToken, consumeInvitation (atomic via a where-clause
  guard on `acceptedAt IS NULL`, so a race between two browsers leaves
  exactly one winner), revokeInvitation, listInvitations.
- /cms/users page replaces the "Invite a teammate" placeholder card
  with a real form: pick role, generate link, copy with one click,
  see all outstanding invites, revoke any of them.
- /cms/invite/[token] is a full-bleed accept page (no sidebar). GETs
  show the email + role + signup form; POST runs Better Auth
  signUpEmail, sets the role from the invitation, marks the invitation
  consumed, redirects to /cms/login?invited=1. Granular error states
  for invalid / consumed / expired tokens.
- (cms) layout gate updated to allow /cms/invite/* without auth.
- Default TTL: 7 days, tunable per-call.

## Audit-log viewer page

- Extracted the v1.2 logAudit helper to `$lib/server/audit/` with a
  typed `AuditAction` union covering every entity action it logs
  (article.create, article.publish, category.update, etc.). Best-
  effort writes — wrapped in try/catch so a missing table or
  transient D1 error never breaks the primary action.
- Audit hooks added throughout:
  - articles: create, update, publish, unpublish, delete (toggle &
    save both fire the right event)
  - categories: create, update, delete
  - tags: create, update, delete
  - media: delete
  - settings: update
  - invitations: create, accept, revoke
- /cms/audit page (admin+ only): paginated 50/page list with actor
  avatar + name + email, action badge (color-coded by verb:
  create/accept = primary, delete/revoke = destructive, else
  secondary), entity reference, timestamp, expandable JSON metadata.
  Left-join on users so deleted-user rows still render gracefully.
- Sidebar gets a new "Audit log" link in the Admin group.

## Scheduled publishing

- ArticleFilter: new `onlyPublished?: boolean` flag. False by
  default (CMS sees everything); public reads opt in by passing
  `true`.
- D1 provider: when `onlyPublished` is set, `listArticles` adds
  `(publishedAt IS NULL OR publishedAt <= now)` to the WHERE clause.
  Rows with no publishedAt slip through (treated as "publish
  immediately when status is 'published'").
- Public blog index passes `onlyPublished: true`.
- Public blog [slug] page adds the same date check after the status
  check — a published article with a future publishedAt 404s.
- ArticleForm.svelte: new `<input type="datetime-local">` next to
  the status select. Pre-fills from existing.publishedAt; shows a
  "⏱ Scheduled for {when}" notice when status is published AND the
  date is in the future.
- New + edit actions: explicit datetime from form wins; otherwise
  the existing fallback rules apply (published → now, draft → null,
  archived → keep existing).

## i18n

22 new Paraglide keys (EN + TH) covering invite copy, audit page
labels, and the schedule-publishing form.

Verification: pnpm check 0 errors, pnpm lint clean, pnpm build OK.
Schema migration: drizzle/0001_chilly_morph.sql adds the
`invitations` table.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs(milestones): mark v1.3 (invitations + audit + scheduling) shipped, scope v1.4 + v1.5 (#25)

Promotes v1.3 from Pending to Shipped after PR #24 landed. The
remaining wishlist is now scoped into committed milestones:
  - v1.4 = full-text search (FTS5)
  - v1.5 = content versioning (article_versions table + diff UI)
  - v1.6+ = OAuth providers (deferred, real product decision)

* feat(v1.4): full-text search via SQLite FTS5 (#26)

Adds a `?q=` query parameter to the public blog and an FTS5-backed
`searchArticles` method on ContentProvider. Sub-millisecond search
across title + excerpt + body, ranked by BM25.

## Schema migration (drizzle/0002_fts5_articles.sql)

Hand-written SQL — Drizzle doesn't model FTS5 virtual tables.

- `articles_fts` virtual table mirrors article_localizations with
  three indexed columns (title, excerpt, body) and two unindexed
  columns (locale, article_id) for JOIN-back without a second query.
- `tokenize='unicode61 remove_diacritics 2'` handles Thai + Latin
  reasonably (FTS5's default 'simple' tokenizer is ASCII-only).
- Three triggers (AFTER INSERT/UPDATE/DELETE on
  article_localizations) keep the index in sync. Application code
  stays ignorant of the index — every existing CMS write path picks
  up indexing for free.
- Backfill at the end of the migration populates the index for
  articles that existed before this PR.

## ContentProvider.searchArticles

New typed surface in src/lib/server/content/types.ts:
  searchArticles(query: string, opts?: SearchOptions): Promise<SearchHit[]>

Options: `locale`, `onlyPublished`, `onlyPublishedStatus`, `limit`.
Returns hits with HTML-formatted `<mark>`-wrapped snippets.

The query string passes through to FTS5's MATCH operator (so phrase
quotes, AND/OR, NEAR, prefix-* all work), with one safety pass:
plain strings are wrapped in double quotes so unbalanced punctuation
doesn't crash the parser. Power users can opt into raw FTS5 syntax by
including a quote, paren, or asterisk.

Visibility filters apply via JOIN on `articles` so we never leak
draft or scheduled content via search.

## Public blog ?q=

`(www)/[locale]/blog/+page.server.ts` reads `?q=`, runs
`searchArticles` with `onlyPublished: true` + `onlyPublishedStatus:
true`, dedupes hits across locales, and hydrates to ArticleRecord
shape so the existing list template renders unchanged.

The blog page svelte gets a small search form (plain GET so URLs are
shareable, search engines can't accidentally index a "no results"
state through a POST). Shows a "N result(s) for X" line above the
list when a query is active.

## i18n

4 new Paraglide keys (EN + TH): blog_search_placeholder, _submit,
_clear, _results.

Verification: pnpm check 0 errors, pnpm lint clean, pnpm build OK.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.5): per-article revision history with diff + restore (#27)

Adds a new article_versions table that snapshots each article
localization's content at the moment of every save. Editors can
browse the full timeline per article, see a 2-pane diff against the
live row, and restore any prior version with one click.

## Schema (drizzle/0003_spotty_cerise.sql)

New `article_versions` table:
  id, articleId, locale, version (monotonic per articleId+locale),
  title, excerpt, body, seoTitle, seoDescription,
  createdBy (FK → users, ON DELETE SET NULL),
  createdAt
ON DELETE CASCADE from articles, so deleting an article cleans up
its history.

## Provider hooks

- `D1ContentProvider.snapshotVersion()` — best-effort writer that
  computes the next monotonic version per (articleId, locale) and
  inserts a row. Wrapped in try/catch — a failed snapshot must not
  break the primary save.
- `createArticle` calls it for each localization on insert (v1).
- `updateArticle` calls it for each touched localization (locales
  that aren't in the patch don't get a phantom snapshot).
- New `listArticleVersions(articleId)` returns all versions newest
  first; `getArticleVersion(versionId)` returns one row.

## actorId pass-through

`ArticleCreateInput` and `ArticleUpdateInput` gain an optional
`actorId` field. Stored on `article_versions.created_by` so the
history view can attribute each save. Wired through the existing
new + edit actions.

## UI

- `/cms/articles/[id]/history` — timeline list with avatar + version
  number badge + locale badge + actor + timestamp. Loads all actor
  rows in one batched query (avoids N+1).
- `/cms/articles/[id]/history/[versionId]` — diff view comparing
  the snapshot against what's live. Title and excerpt show as 2-pane
  before/after. Body uses a hand-rolled line-level diff (LCS via DP)
  rendered as a unified `+/-` patch with red/green backgrounds. No
  external dep — saves ~30 KB; line-level granularity is plenty for
  short markdown bodies.
  Restore action: writes the snapshot's content into the live row
  via the existing updateArticle path (which then snapshots a new
  vN+1, so restores are themselves part of the history). Audit log
  entry includes `restoredFrom` + `restoredVersion` metadata.
- "History" link added to the article edit page header.

## i18n

11 new Paraglide keys (EN + TH).

Verification: pnpm check 0 errors, pnpm lint clean, pnpm build OK.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs: mark v1.4 (FTS5) and v1.5 (versioning) as shipped (#28)

Promotes both releases from Pending to Shipped with detail blocks
matching the v1.3 entry style.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(media): drop stale surface gate that 404'd every upload/delete (#29)

`POST /api/media` and `DELETE /api/media/[id]` both checked
`locals.subdomain !== "cms"` and threw 404. That check made sense in
the original subdomain layout (the CMS ran on its own host, so a
non-cms request hitting an upload endpoint was definitionally wrong).

After v1.1 moved the CMS to path-prefix routing, `surfaceHook`
classifies surface from `event.url.pathname` via `isCmsPath()`, which
only matches `/cms` or `/cms/*`. Every `/api/*` request now classifies
as `www`, so `locals.subdomain === "www"` for media uploads — and the
gate 404'd them all. The CMS media library could list and preview but
not upload or delete.

Fix: remove the surface check from both endpoints. The auth gate
(`hasRole(locals.user, "author"|"admin")`) is the real check; there's
no security value in branching on path-prefix when the API route
itself is at `/api/media`. Comment added so this doesn't regress.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(dashboard): rich v2 dashboard + roadmap from website-driver vision (#30)

Dashboard
---------
The v1.0 dashboard was three plain tiles. v2 adds the surfaces a CMS
editor actually uses on landing:

- Six stat tiles: Articles, Published, Drafts, Scheduled, Media, Users.
- "New this week" trend badge sourced from articles.created_at over
  the last 7 days.
- Quick-actions row (4 cards): New article, Upload media, Manage
  taxonomy, Manage users / Browse articles (role-gated swap so
  authors/editors who can't manage users see "Browse articles"
  instead).
- Scheduled-publish queue: any article with status='published' AND
  publishedAt > now, ordered by publishedAt asc, top 5. Empty state
  explains how to schedule. Real workflow gap closed — there was no
  other surface in the CMS to see "what will go live."
- Recent drafts: status='draft' top 5 by updatedAt, each row links
  back to the editor.
- Translation coverage card: of published articles, what % have an EN
  body and what % have a TH body. Bilingual sites need this nudge.
- Activity feed (admin+ only): last 8 audit_log entries with actor,
  verb-colored badge, entity label (best-effort from metadata.title /
  .slug / .name), relative timestamp. Linked "View all" goes to
  /cms/audit.

Server load fans out 8 reads in parallel (Promise.all) so render time
stays roughly equal to the slowest single query. Activity-feed query
is skipped entirely for non-admin users.

i18n: 27 new Paraglide keys (EN + TH).

Roadmap
-------
docs/MILESTONES.md replaces the v1.6 stub with a structured plan for
"Khao Pad as the driver of a website," not just a CMS. Five pillars
(discoverability, insight, IA, performance/trust, engagement/growth)
mapped to v1.6 → v2.0. Highlights:

- v1.6 SEO foundations: per-page meta, sitemap, robots, JSON-LD,
  RSS/Atom, slug redirects, SEO scoring. Closes the existing bug
  where seoTitle/seoDescription are stored but never rendered.
- v1.7 IA: pages (separate from articles), navigation manager, media
  folders (answers a direct user question), reusable content blocks.
- v1.8 Analytics: privacy-friendly page views via D1, top articles,
  search-term insights, per-article sparkline.
- v1.9 Performance/trust: Cloudflare Images responsive srcsets,
  cache-control, custom 404/500, cookie consent, health endpoint.
- v2.0 Engagement: forms, newsletter, comments, webhooks, public
  read-only API.

Plus a backlog (OAuth, block editor, AI-assisted authoring,
multi-site, A/B, member-only) of larger bets that aren't committed.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs(readme): replace stale roadmap with website-driver milestone table (#36)

The Roadmap section was last updated through M7 + scattered v1.x
checkboxes that no longer match shipped reality (v1.1 had unchecked
items that actually shipped, v2.0 listed items that shipped in v1.3
and v1.5, v3.0 mentioned plugin/white-label that's now in the
backlog).

Replaces with a single status table covering v1.0 → v2.0, framed
around the website-driver vision (CMS is the start, not the end).
Each pending row links to its tracking issue (#31–#35).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.6): SEO foundations (closes #31) (#37)

Closes the discoverability gap. seoTitle / seoDescription were stored
but only the slug page rendered them; everything else emitted minimal
head tags. v1.6 makes Khao Pad a real-website SEO baseline — a
deployed blog now scores Lighthouse SEO ≥90 out of the box.

Per-page SEO surface
--------------------
- $lib/seo/index.ts — PageSeo type, articleJsonLd / breadcrumbJsonLd /
  websiteJsonLd builders, resolveOrigin helper.
- $lib/components/seo/Seo.svelte — single sink rendering <title>,
  <meta description>, <link canonical>, robots, full Open Graph
  (og:title/description/type/locale/url/image/site_name/article:*),
  Twitter Card, hreflang alternates with x-default, JSON-LD blocks,
  RSS auto-discovery <link>.
- (www)/+layout.svelte mounts <Seo /> once; reads page.data.seo via
  $app/state, falls back to siteSettings + paraglide site_name /
  site_description for unset fields.
- Wired home, blog index, blog slug page server loads. Home emits
  WebSite JSON-LD with SearchAction; slug page emits Article JSON-LD
  + article:published_time/modified_time.

Sitemap, robots, feed
---------------------
- /sitemap.xml — sitemap index → /sitemap-{locale}.xml per locale.
- /sitemap-[locale].xml — landing + blog index + every published
  article (respecting scheduled publishing), <lastmod> from
  updatedAt, <xhtml:link hreflang> only for locales with content.
- /robots.txt — reads platform.env.WORKERS_ENV; production allows /,
  disallows /cms/ + /api/, links Sitemap; non-production emits
  Disallow: / so staging never gets indexed.
- /feed.xml — 302 to /feed-{defaultLocale}.xml.
- /feed-[locale].xml — RSS 2.0 (with content:encoded namespace) for
  the 50 most-recent articles in that locale, full HTML body in CDATA.
  <atom:link rel="self"> for self-discovery.

Slug redirects
--------------
- Drizzle migration 0004: slug_redirects table (oldSlug UNIQUE,
  newSlug, articleId FK CASCADE from articles, createdAt).
- D1 provider's updateArticle({ slug }) writes a redirect row when
  the slug actually changes, AND re-points any chained redirects so
  a → b → c keeps both a and b targeting c.
- ContentProvider gets resolveSlugRedirect(oldSlug).
- Public /blog/[slug] route: on miss, look up redirect; if found,
  throw redirect(301, …) to the new canonical URL before 404.

CMS form
--------
- ArticleForm.svelte gets two collapsible <details> sections per
  locale with seo_title_* and seo_description_* fields.
- Server actions (new + [id]) parse the fields, pass into
  localizations.{en,th}.seoTitle / seoDescription. Failure-echo
  values blocks updated to round-trip the new fields.
- Scoring hint: real-time soft verdict per field, color-coded
  (green=good, amber=warn, muted=empty). Title sweet spot 30–60,
  description 70–160. Falls back to title/excerpt for the score
  when override is empty so it reflects what visitors actually see.
  Advisory only — never blocks save.

i18n: 12 new cms_seo_* keys (EN + TH).

Closes #31

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.7a): media folders, reusable blocks, cookie consent, legal templates (#38)

First half of v1.7. v1.7b (pages + navigation manager) lands in a
separate PR that depends on the same migration. Closes part of #32.

Schema (Drizzle migration 0005)
-------------------------------
All v1.7 tables in one shot, additive. v1.7a uses media_folders +
content_blocks + content_block_localizations + media.folder_id.
v1.7b will activate the rest (pages, page_localizations,
navigation_menus, navigation_items).

Media folders
-------------
- media_folders (id, name, parentId, createdAt) — self-referencing
  tree. Null parentId = root.
- media gets a nullable folderId. Existing rows live at the root
  with no migration step needed.
- MediaService gains listFolders / createFolder / renameFolder /
  deleteFolder / move; list({ folderId }) accepts undefined (all),
  null (root), or an id. deleteFolder detaches children to root
  before deleting (no silent asset loss).
- /cms/media UI: left tree sidebar with folder CRUD + inline rename,
  drag-to-move on every tile, "uploading into folder X" hint when
  filtered, ?folder= URL state for shareable filtered views.
- /api/media POST accepts folderId so client-side uploads can land
  directly in the active folder.

Reusable content blocks
-----------------------
- content_blocks (id, key, label, timestamps) + per-locale
  content_block_localizations (body).
- ContentProvider: list/get/getByKey/create/update/delete.
- $lib/server/content/blocks.ts: expandBlocks(body, content, locale).
  Scans for {{block:my-key}} shortcodes, batches lookups (one D1
  round-trip per unique key), substitutes per-locale body (falls
  back to en, then to <!-- unknown block --> comment). Cheap
  short-circuit when body has no {{block: substring.
- Wired into the public blog [slug] route before marked.
- /cms/blocks admin page (editor+) with create/edit/delete, copy-
  pasteable shortcode shown next to each block. New "Blocks" entry
  in the sidebar Taxonomy group.

Cookie consent
--------------
- $lib/consent: ConsentRecord type, parseConsent / serializeConsent
  / isUndecided helpers. v=1 schema version forces re-consent if
  categories ever change.
- (www) layout server load reads the cookie once, surfaces as
  data.consent. Banner only renders when undecided.
- <CookieBanner> with three categories: functional (always-on, not
  consentable), analytics (off until accepted, gates v1.8), marketing
  (off, third-party).
- POST /api/consent records the choice with SameSite=Lax, 1y
  max-age, httpOnly=false (the banner needs to read it client-side).

Legal templates
---------------
- static/legal-templates/privacy-policy.md and cookie-policy.md.
- Explicit [bracketed placeholders] for operator entity / contact /
  retention periods. README explains why we don't auto-generate
  text (legal liability) and what pnpm seed:legal will do once
  Pages land in v1.7b.

i18n: 18 new keys (EN + TH) — cms_blocks_*, cms_media_folder_*,
cookie_*.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.7b): static pages + navigation manager + legal seeder (closes #32) (#39)

Second half of v1.7. With v1.7a (media folders + reusable blocks +
cookie consent + legal templates) merged earlier, this completes the
v1.7 milestone.

Pages
-----
- ContentProvider: getPage / getPageBySlug / listPages / createPage /
  updatePage / deletePage. Soft scheduled-publishing semantics
  identical to articles (status='published' AND publishedAt <= now).
- Public: (www)/[locale]/[...slug] catch-all. SvelteKit prefers the
  more-specific /[locale]/blog and /[locale]/blog/[slug] routes so
  the catch-all only triggers for static pages. Three soft templates
  (default, landing, legal) swap the public wrapper.
- Reuses v1.6 <Seo> + v1.7a expandBlocks, so canonical + hreflang +
  Open Graph + Article-style JSON-LD all just work, and {{block:key}}
  shortcodes expand inside pages too.
- CMS: /cms/pages (list), /cms/pages/new, /cms/pages/[id] (edit).
  Shared PageForm.svelte modeled on ArticleForm.
- Sitemap: per-locale sitemap now lists pages in addition to
  articles, with hreflang siblings only for locales with real content.

Navigation manager
------------------
- ContentProvider: listMenus / getMenuByKey / createMenu / deleteMenu
  / createNavigationItem / updateNavigationItem /
  deleteNavigationItem / reorderNavigationItems.
- /cms/navigation auto-bootstraps two stock menus (primary, footer)
  on first load. Per-locale labels stored as JSON. Items target one
  of article / category / tag / page / custom URL. Position-based
  ordering with up/down buttons (true drag-drop trees deferred).
- $lib/server/content/navigation.ts: loadNavigation() pre-fetches
  the menus + the slug lookup tables in one pass; navItemHref()
  resolves each item to a final URL with O(1) lookups.
- (www) layout server load consumes loadNavigation() and surfaces
  data.nav.{primary,footer} as render-ready arrays. Header iterates
  primary; footer iterates footer.

Legal seeder
------------
- $lib/server/content/legal-seed.ts: seedLegalPages(content,
  authorId) embeds the privacy + cookie policy templates, prefills
  [Site Name] from site_settings, and creates draft Pages with the
  'legal' template. Idempotent: skips slugs that already exist.
- /cms/pages empty state shows a "Seed legal templates" button that
  invokes the seeder. New pages start as drafts (never auto-publish)
  with explicit "review before publishing" copy on the success
  banner.

Sidebar
-------
- Two new entries: Pages and Navigation, both gated to editor+.

i18n: 23 new keys (EN + TH). cms_pages_*, cms_navigation_*,
cms_pages_seed_legal, cms_pages_seeded.

Closes #32 (full v1.7 — both halves merged).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.8): privacy-friendly analytics + search insights (closes #33) (#40)

Schema (Drizzle migration 0006)
-------------------------------
- page_views: composite PK on (date, path), kind enum, optional
  refId, integer count. UPSERT path so we get one row per day per
  path with atomic increments.
- search_log: anonymized {term, noResults, date} only. No IP, no UA,
  no fingerprint.

Tracker
-------
- $lib/server/analytics/index.ts:
  - trackView(db, { path, kind, refId }, consent) — gated on
    consent.analytics from the v1.7a cookie record. UPSERT bumps
    the counter atomically. Best-effort: errors never break a public
    page render.
  - logSearch(db, term, noResults) — always written when /blog?q= is
    used. Search is functional, not analytics-gated.
- AnalyticsService: topPaths / topArticles / topSearchTerms /
  topNoResultTerms / sparkline / totalViews. All scoped to a
  trailing N days (default 30). Sparkline densifies missing days
  to 0 so the chart line stays continuous.

Instrumentation
---------------
- Public home, blog index, blog slug, page catch-all all call
  trackView. Blog index also calls logSearch when ?q= is set, with
  noResults flagged when articles.items.length === 0.

Dashboard
---------
- "Top articles (30 days)" tile: resolves refId → article title +
  link to the editor. Falls back to the raw path when refId is
  null (e.g. blog index).
- "Search insights (30 days)" tile: split into Most-searched terms
  (clickable through to /blog?q=…) and Searches with no results
  (content-gap list). Both surface a non-scary "no data yet" empty
  state.

Per-article sparkline
---------------------
- Article edit page loads a 30-day series for /{locale}/blog/{slug}
  across every supported locale, merges by date, renders a tiny SVG
  sparkline + 30-day total. Hidden when total = 0 so a fresh article
  doesn't show a flatline.

Cloudflare Web Analytics (optional)
-----------------------------------
- /cms/settings gets a "Cloudflare Web Analytics token" field.
- When set AND data.consent.analytics, the (www) layout injects the
  official beacon. Off by default. The first-party D1 counter runs
  regardless of this setting.

Also includes a small svelte-check-only typing workaround on
/cms/blocks for the cms_blocks_help paraglide message (build was
already green; the cast just silences the per-message-types noise).

i18n: 9 new keys (EN + TH).

Closes #33

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cms): pin desktop sidebar to viewport so long pages scroll alone (#41)

The CMS layout's desktop sidebar wrapper was a plain
`<div class="hidden shrink-0 lg:block">` with no sticky positioning,
so on any page taller than the viewport (settings, audit log,
navigation manager, history timeline, etc.) the sidebar grew with
the document and scrolled out of reach. The inner <aside> is
already `h-full` so giving the wrapper `lg:sticky lg:top-0
lg:h-screen` clamps it to the viewport without touching the
component itself.

One-line fix; one shared layout; repairs every /cms/* page in
one shot.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v1.9): performance + trust (closes #34) (#42)

Responsive images
-----------------
- $lib/components/media/ResponsiveImage.svelte emits an <img> with a
  3-width srcset (640/1024/1920) using Cloudflare's URL-based image
  transform path: `/cdn-cgi/image/width=W,format=auto,quality=85<src>`.
  When the zone has Cloudflare Images enabled, the edge serves the
  right size + WebP/AVIF. When it doesn't, Cloudflare passes the URL
  through unchanged → raw R2 URL serves. Same component, both
  deployments.
- Article cover image (blog/[slug]) swaps from a bare <img> to
  <ResponsiveImage>. Cherry-picks reapply automatically.

Cache control
-------------
- New cacheHook in src/hooks.server.ts, last in the handle sequence.
  Inspects each response and, if no cache-control is already set,
  applies a path-based default:
  - /cms/* + /api/auth/* + /api/consent → no-store
  - /api/media/* → public, max-age=86400, swr=604800 (immutable blobs)
  - /api/* default → no-store
  - /blog/[slug] → public, max-age=120, s-maxage=600, swr=86400
  - everything else → public, max-age=60, s-maxage=300, swr=86400
- Per-route handlers that already set their header (/sitemap.xml,
  /robots.txt, /feed-{locale}.xml, /api/health) keep their values.
- Browser honors max-age; edge honors s-maxage; SWR keeps things
  snappy while a single revalidation hits the worker.

Custom 404 / 500
----------------
- (www)/+error.svelte with paypers visual language. Big status in
  display font, friendly title/subtitle from i18n, search box (404
  only, posts to /blog?q= → v1.4 FTS), back-home + browse-blog
  buttons. <meta name="robots" content="noindex,nofollow"> so error
  pages don't get indexed.

Health endpoint
---------------
- /api/health. No auth gate. Returns
  { ok, timestamp, bindings: { d1: { ok, latencyMs }, r2: ..., kv: ... },
    environment? }. Always HTTP 200 when the platform shim is present
  (uptime monitors keep working through binding failures); 503 only
  when wrangler is missing entirely. Cache-Control: no-store.
- WORKERS_ENV exposed in the body when set so smoke tests can verify
  they hit the right environment.

i18n: 7 new error_* keys (EN + TH).

Closes #34

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v2.0a): forms — first slice of v2.0 engagement (#43)

This is the first of four v2.0 PRs. Adds public-submittable forms
with a CMS editor and an in-CMS moderation inbox.

Schema (Drizzle migration 0007)
-------------------------------
- forms: id, key (URL-safe, unique), label, fields (JSON array of
  FormField), enabled (bool), success_messages (per-locale JSON),
  created_by (FK users SET NULL), createdAt, updatedAt.
- form_submissions: id, form_id (FK CASCADE), data (JSON), ip_hash
  (truncated SHA-256, 16 chars — never raw IP), status enum
  (new/read/spam/archived), note, submittedAt.

Provider
--------
- ContentProvider gains 11 new methods: list/get/getByKey/create/
  update/delete for forms, list/get/create/update/delete for
  submissions, plus countRecentSubmissions for rate limiting.
- D1 implementation in providers/d1.ts.

Public endpoint (POST /api/forms/[key])
---------------------------------------
- Reads the form by key. Returns 404 if missing, 410 if disabled.
- Parses multipart/url-encoded body.
- Validates against form.fields:
  - honeypot (`_hp` field, expected empty),
  - per-field required + maxLength,
  - email kind: cheap regex `.+@.+\..+`,
  - checkbox: required-checkbox is GDPR consent pattern.
- Rate limit: 3 submissions per minute per (form, ipHash).
  IP is hashed via SHA-256 truncated to 16 hex chars; raw IP is
  never stored.
- Audit: writes a form.submit row with actorId=null (public).

CMS UI
------
- /cms/forms — list table with label, public endpoint URL, field
  count, enabled badge.
- /cms/forms/new + /cms/forms/[id] — shared FormEditor.svelte.
  Field list with add (text/email/textarea/checkbox), reorder
  (up/down), remove, per-field name + label + required toggle.
- /cms/forms/[id] also embeds a submissions inbox underneath the
  editor: collapsible rows with status badges, mark-as
  (new/read/spam/archived), delete. Submissions are scoped to the
  current form via a defense-in-depth check on every action.
- Sidebar gets a new "Forms" entry under the taxonomy group, gated
  to editor+.

Audit
-----
- New AuditAction members: form.create / form.update / form.delete /
  form.submit. Wired into create / update / delete actions and the
  public submission endpoint.

i18n: 25 new cms_forms_* keys (EN + TH).

Migration 0007 already applied to live D1.

This PR doesn't touch the public site rendering — adding a
\<form\> tag to a page or article is the editor's job. Future v2.x
might ship a markdown shortcode that renders a form inline; out of
scope here.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v2.0b): newsletter — fully optional double-opt-in + digest sender (#44)

Second of four v2.0 PRs. Newsletter is wired so it works at three
levels of operator commitment:

  Level 0 (default): no provider configured
    → public subscribe form returns 503 OR (if `allowSingleOptIn` is
      checked, default true) creates rows immediately confirmed.
      Operator can manually export/email later.
  Level 1: Resend API key + sender configured
    → public subscribe sends a real double-opt-in email.
      Subscribers active only after clicking the confirmation link.
  Level 2: cron-trigger pointing at /api/newsletter/send-digest
    → automated weekly digest (operator wires the cron in their
      wrangler.toml; we ship the endpoint).

Schema (Drizzle migration 0008)
-------------------------------
- subscribers: id, email UNIQUE, locale, token UNIQUE (24-char
  nanoid for confirm + unsubscribe links), confirmedAt nullable,
  unsubscribedAt nullable, source, createdAt.

Provider
--------
- ContentProvider gains 8 newsletter methods: list/count/getByEmail/
  getByToken/create (with autoConfirm flag for single-opt-in mode)/
  confirm/unsubscribe/delete. All idempotent where it matters.

Newsletter helper module ($lib/server/newsletter/index.ts)
----------------------------------------------------------
- readNewsletterConfig(settings): pulls newsletter.* keys from
  site_settings.
- isProviderConfigured(cfg): true when both resendKey + senderAddress
  are present.
- sendEmail(cfg, args): Resend POST. Returns { ok: true, id? } or
  { ok: false, reason }. Best-effort — never throws.
- buildConfirmEmail({...}): per-locale subject + html + text.
- buildDigestEmail({...}): per-locale weekly digest with embedded
  unsubscribe link.

Public endpoints
----------------
- POST /api/newsletter/subscribe (honeypot, idempotent on existing
  emails to prevent enumeration, audit-logged)
- GET /api/newsletter/confirm?token=... (idempotent → 302 to
  localized home with ?newsletter=confirmed)
- GET /api/newsletter/unsubscribe?token=... (one-click, no
  interstitial — GDPR/CAN-SPAM)

Admin endpoint
--------------
- POST /api/newsletter/send-digest?days=7&dryRun=1 (admin-only,
  iterates active subscribers, groups by locale, sends via Resend.
  Returns { ok, sent, failed } or { ok, dryRun: true, subscribers,
  articleCounts }. 503 when no provider configured.)

CMS surface
-----------
- /cms/subscribers (admin+): list with status badges (pending /
  active / unsubscribed), provider-status banner, manual "Send
  digest" + dry-run button when provider is on. Sidebar gets a
  "Subscribers" entry under Admin.
- /cms/settings: new "Newsletter (optional)" card with three
  fields: resendKey (masked-ish, font-mono), senderAddress, and
  a checkbox for the allow-single-opt-in fallback behavior.

Audit
-----
- New AuditAction members: newsletter.{subscribe,confirm,
  unsubscribe,delete,digest_sent}.

i18n: 22 new keys (EN + TH) across cms_subscribers_*,
cms_settings_newsletter_*.

Migration 0008 already applied to live D1.

Roadmap state
-------------
v2.0a Forms ✅ (PR #43)
v2.0b Newsletter ✅ (this PR)
v2.0c Comments 🚧
v2.0d Webhooks + Public REST API 🚧

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v2.0c): comments — third slice of v2.0 engagement (#45)

Per-article visitor comments with editor moderation. Dual-toggle
policy so a fresh deploy never accidentally exposes a comment form.

Schema (Drizzle migration 0009)
-------------------------------
- comments: id, articleId FK CASCADE, parentId (forward-compat for
  threading; UI is flat), authorName, authorEmail (collected, never
  displayed publicly), body (plain text — never markdown to keep
  XSS surface minimal), status enum (pending/approved/spam/
  archived), ipHash (16-char SHA-256 truncate; never raw IP),
  submittedAt, moderatedBy + moderatedAt.
- articles.commentsMode: new column. enum (inherit/on/off),
  default 'inherit'.

Dual toggle
-----------
- Site-wide: settings.commentsEnabled defaults to false. New
  Comments card in /cms/settings.
- Per-article: radio (Inherit / On / Off) on the article form.
- $lib/server/comments.commentsAllowedForArticle() is the one-line
  truth table both the public render and the POST endpoint use.

Public surface
--------------
- POST /api/comments. Reuses v2.0a hashIp + rate-limit (3/min per
  ipHash per article). Honeypot _hp via $lib/forms/constants
  (extracted to a non-server module so client + server share the
  field name). 410 when commentsAllowed=false; 429 on rate limit.
- (www)/[locale]/blog/[slug] page-server load now also fetches
  approved comments + computes commentsOpen.
- New CommentSection.svelte renders approved comments + a
  fetch()-posted submission form. Fields: name (required, ≤80),
  email (required, ≤254, server-side regex), body (required,
  ≤4000). Plain-text rendering with whitespace-pre-wrap.
- Page svelte mounts CommentSection when there are approved
  comments OR when commentsOpen=true.

CMS surface
-----------
- /cms/comments moderation queue. Status tabs (pending/approved/
  spam/archived). Each row: status badge, timestamp, author + masked
  email (a***@e***.com via $lib/comments/mask), linked article
  title (batch-resolved per page), body, mark-as buttons (approved/
  spam/archived), reply-via-email mailto, delete. Pagination
  50/page. Pending count exposed for future sidebar badge.
- Sidebar entry under taxonomy group, gated to editor+.

Audit
-----
- New comment.{create,approve,spam,archive,delete} AuditAction
  members.
- Public submission writes comment.create with userId=null.
- Moderation actions write the matching status-change action with
  the editor's userId.

Constants
---------
- HONEYPOT_FIELD + RATE_LIMIT_WINDOW_SECONDS + RATE_LIMIT_MAX_PER_
  WINDOW moved from $lib/server/forms (server-only) to $lib/forms/
  constants so client components can import them. Server module
  re-exports for backwards compat.

i18n: 26 new comment_* / comments_* / cms_settings_comments_* keys
(EN + TH).

Migration 0009 already applied to live D1.

Out of scope (deliberate non-goals for this slice):
- Threaded replies — parentId is forward-compat only; UI is flat.
- Comments on Pages — Pages are typically static.
- Akismet/ML spam filtering — honeypot+rate-limit is the v2.0 floor.
- Email notifications when comment approved.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v2.0d): webhooks + public REST API — final v2.0 slice (#46)

Last of four v2.0 PRs. Closes the engagement-and-growth milestone
and the entire v2.0 roadmap.

Schema (Drizzle migration 0010)
-------------------------------
- webhooks: id, label, url, secret (48-char nanoid), events JSON,
  enabled, audit fields.
- webhook_deliveries: per-attempt log. webhookId CASCADE, event,
  payload, responseStatus, responseExcerpt (256-char cap),
  durationMs, attempt, nextAttemptAt, ok.
- api_keys: id, label, key_hash UNIQUE (SHA-256 hex of raw key),
  prefix (kp_live_xxxx — kept for display only), scopes JSON,
  expiresAt, revokedAt, lastUsedAt, audit fields.

Webhook dispatcher
------------------
- $lib/server/webhooks/index.ts.
- WebhookEvent union: article.{publish,unpublish,delete} /
  comment.approve / form.submit / subscriber.confirm.
- HMAC-SHA256 sign body with webhook secret. Headers:
  X-Khaopad-Signature: sha256=<hex>
  X-Khaopad-Event: <event>
  X-Khaopad-Delivery: <uuid>
- 5s fetch timeout, 3 inline attempts, 250ms / 1500ms backoff.
- Best-effort writes a webhook_deliveries row for every attempt,
  success or fail. Operator debugs from CMS.
- dispatchEvent() is fire-and-forget at every call site so the
  originating action never pays the network round-trip.

Wired into 5 events
-------------------
- article.publish / article.unpublish (article edit save +
  togglePublish)
- article.delete (article delete action)
- comment.approve (only on approve — spam/archive don't fire)
- form.submit (public POST /api/forms/[key])
- subscriber.confirm (the email click target)

Public REST API
---------------
- $lib/server/api-auth/index.ts: parses Authorization: Bearer …,
  delegates to provider.authenticateApiKey(), enforces scopes.
- /api/public/articles (paginated, ?locale=en|th, ?limit, ?page)
- /api/public/articles/[slug]
- /api/public/categories
- /api/public/tags
- /api/public/pages
- All routes require Authorization: Bearer kp_live_…. Per-key
  scopes: articles:read, categories:read, tags:read, pages:read,
  or *:read for the bundle. Drafts and future-dated published
  articles 404 — consumers never see unpublished content.
- lastUsedAt bumped fire-and-forget on every successful auth.

CMS UI
------
- /cms/webhooks: list, create, edit, rotate-secret, delete. Subscribe
  to specific events via checkbox grid. Show signing secret in a
  collapsible details for verification setup.
- /cms/api-keys: list, create with one-time secret display (copy
  button + clear "won't be shown again" warning), revoke (soft —
  rejected at auth), delete (hard).
- Both pages admin+ gated. Sidebar entries under Admin group.

i18n: 39 new cms_webhooks_* / cms_api_keys_* keys (EN + TH).

Migration 0010 already applied to live D1.

Closes #35. Completes v2.0 (engagement-and-growth) and the entire
roadmap. Backlog (OAuth, block editor, AI authoring, multi-site,
A/B, gated content) stays explicitly uncommitted.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 7 ESLint errors blocking CI on fresh install (closes #47) (#49)

The CI gate (.github/workflows/deploy.yml) runs `pnpm run lint`,
which fails on every push because of legitimately-flagged code
patterns. None of these are real bugs — they're cases where the
linter rule is correct in general but doesn't fit our specific
intent. Fixed each at the smallest scope:

- CookieBanner.svelte: privacyHref is operator-supplied (built from
  layout's `localePath()` → /[locale]/privacy-policy), not a
  build-time route — eslint-disable for svelte/no-navigation-without-resolve.
- Seo.svelte: emit JSON-LD via concatenated string + escaped
  </script> sequence so untrusted JSON values can't break out;
  eslint-disable svelte/no-at-html-tags + drop the unnecessary `\/`
  escape.
- webhooks/index.ts: drop the dead initializer on `responseExcerpt`
  (every code path reassigns before reading).
- dashboard/+page.server.ts: drop unused `and` from drizzle-orm
  import.
- media/+page.svelte: lookup map is local to a $derived block,
  rebuilt every invocation, never reactive — plain Map is correct;
  eslint-disable svelte/prefer-svelte-reactivity with rationale.
- (www)/[locale]/[...slug]/+page.svelte: trusted server-rendered
  markdown — same eslint-disable pattern blog/[slug] already uses.

After: `pnpm run lint` reports 0 errors (407 paraglide-generated
prettier warnings remain — those are auto-generated and gitignored
in spirit; separate cleanup).

Build still green; svelte-check unchanged.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix: switch articles_fts to external-content FTS5 (closes #48) (#50)

The contentless FTS5 table from migration 0002 required the
delete-by-insert tuple to **exactly match** what's stored:

  INSERT INTO articles_fts(articles_fts, rowid, title, excerpt, body, locale, article_id)
  VALUES('delete', old.rowid, old.title, ...);

If anything drifted — different normalization, trailing newline,
prior failed REPLACE — the delete trigger threw SQLITE_ERROR. UPDATE
inherited the fault (delete-then-reinsert). End result: editors
couldn't update or delete an article_localizations row once any
drift had occurred.

Migration 0011 fixes it by switching to the **external-content**
FTS5 pattern:

  CREATE VIRTUAL TABLE articles_fts USING fts5(
    ...
    content = 'article_localizations',
    content_rowid = 'rowid',
    ...
  );

This means:

- Triggers reference rowid alone — no column values to match against,
  so drift can't break delete/update:
    INSERT INTO articles_fts(articles_fts, rowid) VALUES('delete', old.rowid);
- The source-of-truth values come from article_localizations itself
  via the `content =` link.
- `INSERT INTO articles_fts(articles_fts) VALUES('rebuild')` recovers
  from any inconsistency without a full DROP/CREATE cycle.

Migration 0011 already applied to live D1. Reproduced + verified the
fix end-to-end:
  INSERT → UPDATE → DELETE on article_localizations now all succeed
  where they previously threw SQLITE_ERROR; FTS search still returns
  ranked snippets correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs(readme): refresh top — website-platform vision + complete v2.0 feature inventory (#52)

The intro/Why/Features sections were stale: still framed Khao Pad as
"a CMS for Cloudflare" and listed only the M1–M7 (v1.0) feature set.
After 11 shipped milestones (v1.0 → v2.0) the README undersold the
project to anyone who didn't already know what was in it.

Refresh:

- New header tagline: "The open-source website platform for
  Cloudflare." Mirrors the marketing site's positioning since the
  v1.5 → v1.6 pivot.
- Added live-demo + marketing-site links right under the tagline.
- New "What it is" section explains the CMS-→-platform arc in two
  sentences.
- "Why?" table grew a row (Ghost / Wagtail) and the closing line
  mentions the free-tier point explicitly.
- "What ships" replaces the old four-bullet feature list with a
  five-pillar inventory (content, discoverability, IA, insight,
  performance, engagement) covering every shipped milestone — every
  feature an editor would ask about appears here.
- "Platform fundamentals" closes with the architectural promises
  (one repo, multilingual, Better Auth, pluggable storage, real
  staging/prod).

Mid-section (Architecture, Tech Stack, Setup, Bindings, Deployment,
Roadmap, License) untouched — those were already accurate and
complete.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: unify admin at /admin (closes #64) (#65)

* refactor: unify admin under /admin (closes #64)

Renames the admin surface mount from /cms/* to /admin/* so CMS, the future
shop plugin, and every management feature after it live under a single
navigation shell — matching the /admin convention used by every self-hosted
CMS (WordPress, Ghost, Strapi, Directus, Payload, Sanity). The subdomain
split retired in v1.1 was already gone; this cleans up the final path naming.

Scope
- src/routes/(cms)/cms/* → src/routes/(admin)/admin/*  (git mv, 74 files)
- src/lib/components/cms/ → src/lib/components/admin/
- CmsLocaleToggle.svelte → AdminLocaleToggle.svelte
- isCmsPath() → isAdminPath() in hooks.server.ts
- surface literal "cms" → "admin" in app.d.ts + hooks
- All /cms/* URL refs in comments, sidebar hrefs, and docs → /admin/*
- Docs (CLAUDE.md, README, ARCHITECTURE, BETTERAUTH, MIGRATING, MILESTONES,
  PLATFORM-NOTES) updated to reflect single-host /admin mount
- wrangler.toml route comment updated

Deliberately unchanged
- Paraglide message keys (cms_dashboard, cms_articles, ...) stay as
  internal identifiers — renaming would touch 150+ keys × 2 locales for
  zero user-visible effect. Namespace is historical, not URL-bearing.
- CMS_SITE_URL env var stays (documented backwards-compat name)
- Comments describing pre-v1.1 subdomain design preserved as history

Follow-ups tracked separately
- Cookie hardening (__Host- prefix + CSP) — in #64 spec, next PR
- Better Auth session cookie audit — next PR

Verify
- pnpm run check: 0 new errors (3 pre-existing paraglide errors tracked in #62)
- pnpm run build: passes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: two more /cms → /admin refs caught by post-refactor bug hunt

- docs/ARCHITECTURE.md: isCmsPath → isAdminPath (function was renamed)
- static/legal-templates/README.md: /cms/articles/new → /admin/articles/new

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* security: __Host- session cookie + CSP + hardening headers (#66)

Follow-up to #64 (unified /admin mount). Locks down the same-origin threat
model that comes with public + admin sharing a host: stored-XSS in user
content stealing admin session cookies.

Changes
- src/lib/server/auth/index.ts: force `__Host-khaopad_session` cookie name
  via Better Auth's advanced.cookies override. Browser now enforces
  Secure=true + Path=/ + no Domain — a rogue subdomain (if ever added)
  cannot set a cookie with this name.
- src/hooks.server.ts: new securityHeadersHook at end of sequence
  - Content-Security-Policy per surface (public + admin both strict)
  - script-src 'self' — no inline scripts, no eval (kills primary XSS vector)
  - style-src 'self' 'unsafe-inline' — svelte hydration + tailwind need it
  - img-src / media-src include data: blob: https: for markdown embeds + R2
  - connect-src 'self' — no external fetch
  - frame-ancestors 'none' + X-Frame-Options: DENY (clickjacking)
  - form-action 'self', base-uri 'self', object-src 'none'
  - X-Content-Type-Options: nosniff
  - Referrer-Policy: strict-origin-when-cross-origin
  - Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
- docs/security.md: new — documents the threat model, cookie contract,
  CSP policy, and a curl one-liner to verify in prod

Deliberately unchanged
- SameSite=Lax (Better Auth default) is correct — SameSite=Strict breaks
  the return-from-email-verification flow
- HSTS not set here — Cloudflare handles at the edge
- CSRF token library not added — SameSite=Lax + same-origin form-action
  cover the framework's form actions

Verify
- pnpm run check: 0 new errors (3 pre-existing paraglide errors, tracked in #62)
- pnpm run build: passes (11s)
- Post-deploy: `curl -sI https://example.com/ | grep -iE 'csp|frame|content'`

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v3.0-1a): open AuditAction + WebhookEvent unions for plugin extensibility (#67)

First step in the v3.0 plugin runtime (#55). Loosens two closed
string-literal unions so plugin code can register new audit actions
and webhook events without a core type change, while keeping full
autocomplete + typo detection for core call sites.

Pattern
- `KnownAuditAction` / `KnownWebhookEvent` stay as strict unions
  (autocomplete + catch typos in core code)
- `AuditAction = KnownAuditAction | (string & {})` — the `& {}`
  intersection preserves literal autocomplete while accepting any
  string from plugins (e.g. `shop.order.paid`)
- Same shape for `WebhookEvent`

New: registerWebhookEvent() + listKnownWebhookEvents()
- Runtime registry (Set) so plugins can appear in the admin webhook-
  create form's event picker without modifying the closed literal
- The old `WEBHOOK_EVENTS` export is preserved as a Proxy that reads
  live from the registry each access — old imports (if any land in
  cherry-picks) still see the current set, and any consumer that
  did `WEBHOOK_EVENTS.includes(x)` keeps working

Also refactored
- /admin/webhooks page.server.ts: swap WEBHOOK_EVENTS array read for
  listKnownWebhookEvents() call (so the picker sees plugin-registered
  events after boot registers them)

Deliberately unchanged
- WebhookRecord.events schema (already free-form text column)
- audit_log.action DB column (already free-form)
- The dispatcher — already queries by string match, not enum lookup
- Old WEBHOOK_EVENTS import path (proxy keeps it working, deprecated
  in doc comment)

Verify
- pnpm run check: 0 new errors (only 3 pre-existing paraglide errors)
- pnpm run build: passes

Part of: v3.0 plugin runtime (#55)
Next: 1b — sidebar nav registry + plugin registration API

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v3.0-1b): sidebar nav registry for plugin extensibility (#69)

Second sub-PR of the v3.0 plugin runtime (#55). Turns the hardcoded
`navGroups` literal into a runtime registry so plugins can register
new nav groups and items without touching core files.

New API in $lib/components/admin/sidebar-nav.ts
- registerNavGroup(group: NavGroup): void
    - Idempotent on id — second call updates title, merges items
    - New groups appear after core groups (Map insertion order)
- registerNavItem(groupId: string, item: NavItem): void
    - Appends to an existing group (no-ops if group missing)
    - Duplicate hrefs ignored — safe to call at every plugin boot
- listNavGroups(): ReadonlyArray<NavGroup>
    - Fresh snapshot each call; do NOT cache across plugin boot

Refactor pattern
- Core groups (main, taxonomy, admin) now registered via the same
  registerNavGroup() API at module load. Behavior identical: same
  order, same items, same role gates.
- Sidebar.svelte switched from `import { navGroups }` to
  `import { listNavGroups }` + a $derived snapshot.

Backwards-compat
- `navGroups` still exported as a live Proxy — reads from the registry
  each access, so any consumer that did `import { navGroups }` sees
  the current state including plugin registrations. Marked @deprecated
  in JSDoc.

Verify
- pnpm run check: 0 new errors (only 3 pre-existing paraglide errors)
- pnpm run build: passes

Part of: v3.0 plugin runtime (#55)
Next: 1c — plugin schema concatenation + per-plugin migration folders

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v3.0-1c/1d/1e): finish plugin runtime + reference plugin (closes #55) (#70)

* wip(v3.0-1c/1d/1e): plugin runtime + reference plugin (untested)

WIP commit — pausing Phase 1 to work on #68 (custom content types).
Not yet tested end-to-end. Do NOT merge until:
- pnpm run check passes
- pnpm run build passes
- migration 0012 verified with local wrangler d1
- /admin/hello page loads and ping form works
- audit action shows in /admin/aud…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant