feat(seed): fried rice around the world (12 recipes, 7 countries) - #17
Open
thunpisit wants to merge 2 commits into
Open
feat(seed): fried rice around the world (12 recipes, 7 countries)#17thunpisit wants to merge 2 commits into
thunpisit wants to merge 2 commits into
Conversation
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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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 IGNOREon deterministic IDs. Safe to apply on top of an existing seeded database — new rows land, old rows stay put. The category slug changed (thai-cuisine→fried-rice, new IDseed_cat_friedrice), so both categories will co-exist after a re-seed. If you want a clean cut, drop the oldseed_cat_thai*rows first.Test plan
pnpm db:seed:example(local D1) — verify 12 articles + 12 tags landRelated