diff --git a/.claude/skills/docker-compose-rebuild/SKILL.md b/.claude/skills/docker-compose-rebuild/SKILL.md new file mode 100644 index 000000000..346b8a444 --- /dev/null +++ b/.claude/skills/docker-compose-rebuild/SKILL.md @@ -0,0 +1,61 @@ +--- +name: docker-compose-rebuild +description: >- + Use on the Blog-Chat-app repo whenever a code edit (bug fix or new feature) does not show up in the + running app, or as the FIRST troubleshooting step when the user says a fix "didn't work," they "don't + see the change," or the UI/API still shows old behavior after edits. Docker containers here bake source + at build time and serve stale code, so this skill asks permission and then rebuilds the compose stack so + the edits actually take effect. Trigger it before debugging the code itself when changes seem to have no visible effect. +--- + +# Docker Compose Rebuild + +## Why this exists + +On this repo the dev stack runs in Docker. A container started with `up -d` **freezes the app code at +image-build time** — editing a file afterward and running `restart` keeps serving the code baked into the +image (see the `docker-compose-dev-sync-gotcha` project memory, confirmed during P2 signup debugging). So +when a bug fix or new feature "doesn't work," the most common cause is not the code — it's that the running +container never picked up the edit. + +**Rule of thumb:** if the user just changed code and the change isn't visible, suspect a stale container +*before* you re-read the logic. Rebuilding is the cheap first move; debugging code that's correct but not +running is wasted effort. + +## When to trigger + +- The user reports that a recent bug fix or new feature has no visible effect. +- "I don't see my changes," "the fix didn't work," "still shows the old behavior," "nothing changed." +- Right after you (or the user) edit source while the stack is already running. +- As the **first** step of troubleshooting a "my change isn't showing" symptom, before diving into the code. + +## What to do + +1. **Ask for permission first.** A full `--no-cache` rebuild is slow, and `up --watch` runs in the + foreground and takes over the terminal. Never rebuild silently — confirm with the user, e.g.: + > "Your change may not be showing because the container is serving stale code. Want me to rebuild the + > compose stack? This does a clean `--no-cache` build and can take a few minutes." + +2. **Only after they agree**, run these two commands from the **repo root** (not from `infra/`): + + ```bash + docker compose --project-directory . -f infra/compose.yaml build --no-cache + docker compose --project-directory . -f infra/compose.yaml up --watch + ``` + + - `--project-directory .` is mandatory — the compose file lives in `infra/` but its `context` / + `develop.watch` / secrets paths are written relative to the repo root; Compose resolves them wrong + without it. + - `up --watch` is long-running and blocks the terminal (it keeps syncing edits live). Run it in the + background if you need the terminal back, and tell the user it stays running. + +3. If the user only wants the change picked up (not a full clean rebuild), a lighter option is a targeted + rebuild of the one changed service — `docker compose --project-directory . -f infra/compose.yaml up -d --build ` + — or just `docker compose watch` (what `npm run dev` does), which syncs edits live without the slow + `--no-cache` pass. Offer this when a full clean rebuild is overkill. + +## After rebuilding + +Confirm the change is actually present before assuming success — e.g. reload the page, hit the endpoint, or +`docker compose exec grep` for the edited string inside the container. Don't declare it fixed until +you've seen the new behavior. diff --git a/.claude/skills/plan-status/SKILL.md b/.claude/skills/plan-status/SKILL.md new file mode 100644 index 000000000..9d279acab --- /dev/null +++ b/.claude/skills/plan-status/SKILL.md @@ -0,0 +1,126 @@ +--- +name: plan-status +description: >- + Use on the Blog-Chat-app repo whenever the question is "where are we?" or "what's next?" — status of the + current phase plan, which tasks are done, what task to pick up, what's left before a phase ships, or + orienting at the start of a session after time away. Reads docs/superpowers/plans/ and derives real + status from git and the code rather than the plan's checkboxes (which are never ticked and always read + 0% done). Trigger it on "what's the status", "where did we leave off", "what should I work on next", + "what's left in P2", "catch me up", "is task N done", or before starting any new task on this project — + even when the user doesn't name the plan. +--- + +# Plan Status + +## Why this exists + +Phase plans live in `docs/superpowers/plans/`. They are long (P2 is ~2,200 lines) and they track steps with +`- [ ]` checkboxes — **but nobody ever ticks them.** P2 is 0-of-75 checked while Tasks 1–6 are merged to +`staging`. So the two obvious ways to answer "where are we" both fail: reading the checkboxes reports 0% +and sends you to redo Task 1, and reading the whole plan burns thousands of tokens to reach the same wrong +answer. + +The plan is the **specification**; git and the working tree are the **record of what shipped**. This skill +reads the plan for *what the work is* and reads the repo for *what's done*, then reports the join. + +## Step 1 — Scan + +```bash +python .claude/skills/plan-status/scripts/scan_plan.py +``` + +Add `--plan ` for a specific phase, `--base ` to change what counts as already-merged +(default `master`). The script picks the newest plan not marked complete, and emits JSON with: the task +ledger (number, title, line number, declared files, `Produces` interfaces, planned commit message), +per-task evidence, amendment blocks, and git position. + +**Do not read the plan file top-to-bottom.** The scan plus a targeted read of the next task's line range is +the whole job; reading 2,000 lines to report six facts is the failure mode this skill exists to prevent. + +Each task carries a `signal` built from two independent axes — do its declared files exist, and did a +commit matching its planned message land: + +| signal | meaning | what to do | +|---|---|---| +| `LIKELY_DONE` | files present **and** a matching commit | trust it | +| `NOT_STARTED` | declared files absent, nothing shipped | trust it | +| `NEEDS_CHECK` | the axes disagree | resolve it in Step 2 | +| `NO_EVIDENCE` | task declares no files (docs/gate tasks) | resolve it in Step 2 | + +## Step 2 — Resolve the frontier + +Only resolve tasks the scan left uncertain, and usually only the first one or two — a `NEEDS_CHECK` at +Task 11 doesn't matter when Task 7 is the frontier. Two things routinely make the mechanical signals +disagree, and both need judgment: + +**Commit messages drift.** The plan prescribes `feat(client): typed API wrappers (client.ts + auth/posts/users)`; +the commit that actually shipped it says `feat(client): api client layer with typed wrappers`. Token +matching misses this, so a done task shows `NEEDS_CHECK` with no evidence. Scan `git log --oneline -40` +yourself for a commit that plainly covers the task's subject. + +**Files exist as stubs.** Earlier tasks create placeholder pages so the app compiles — `PostPage.tsx` +exists from Task 5, but Task 7 is what fills it in. Existence proves nothing here; the plan itself says +"replace the Task 5 stub". This is what the task's **`Produces`** line is for: it names the exact symbols +the task must yield (`usePost(slug)`, ``, `AutoForm`). Grep for those: + +```bash +grep -rn "export function usePost\b" apps/client/src/hooks/ +``` + +Present and substantive → done. Absent, or the file is a few lines returning a heading → not done. When +still genuinely ambiguous after that, say so in the report rather than picking a side; a wrong "done" +costs more than an honest question. + +## Step 3 — Report + +Lead with this block. It is deliberately compact — the user asked where things stand, not for a recital of +the plan. Task count, titles, and line numbers all come from the scan; never assume a phase's task count +from memory. + +``` +P2 — React Client · 6/13 tasks shipped +Branch: staging (clean, up to date with origin) + +✅ 1 Vite scaffold ✅ 4 Query/router/shell +✅ 2 UI primitives ✅ 5 Auth pages +✅ 3 API client layer ✅ 6 Blog feed +▶ 7 Post detail page (gating UI) +○ 8 AutoForm ○ 9 Delete ○ 10 Likes +○ 11 Docker ○ 12 E2E ○ 13 Final gate + +⚠ Amendment 2026-07-25: `premium` is gone — ignore that + line in the Tasks 8/10/12 snippets. + +Next: Task 7 (plan L1284) + hooks/use-posts.ts + usePost + pages/PostPage.tsx replace the Task-5 stub + git checkout -b dev/post-detail-page +``` + +Rules for the block: + +- **Surface amendments.** Plans get amended in place (`> **Amendment ...**`) and those notes supersede the + code snippets below them. Someone following an amended snippet reintroduces work that was deliberately + removed — that's why this gets a line of its own rather than a footnote. +- **Flag git position honestly.** Uncommitted changes, a `dev/*` branch with unmerged work, or local + `staging` behind `origin/staging` all change what "next" means. CLAUDE.md requires feature branches off + an up-to-date `staging`, so a stale `staging` is a blocker to state, not a detail to skip. +- **Propose the branch, don't create it.** Name it after the feature, never the task number + (`dev/post-detail-page`, not `dev/task-7`) — a CLAUDE.md rule. Give the command; let the user run it. +- Keep to the shipped/next/remaining shape. Offer the detail ("want the full step list for Task 7?") + instead of pre-emptying it into the reply. + +## Scope + +This skill reports; it doesn't act. It never edits the plan (the checkboxes stay untouched — deriving +status fresh each time can't go stale, and a wrong verdict written into a doc outlives the mistake), never +creates branches, and never starts the next task. When the user then says "go", that's implementation +work — hand off to `superpowers:subagent-driven-development` or `superpowers:executing-plans`, which is +what the plan's own header asks for. + +**Completed plans** announce themselves — `(COMPLETE)` in the title or a `**Status:** all N tasks shipped` +line — and record their history in a prose "Completion log" rather than checkbox tasks. The scan flags +them in `plans[].complete`. Summarize from the Status line; don't build a ledger for a phase that's done. + +If the user asks about a phase with no plan file yet (P3–P6), say so plainly and point at spec §13, which +is where the phase table lives. diff --git a/.claude/skills/plan-status/scripts/scan_plan.py b/.claude/skills/plan-status/scripts/scan_plan.py new file mode 100644 index 000000000..696347fde --- /dev/null +++ b/.claude/skills/plan-status/scripts/scan_plan.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Scan the phase plans under docs/superpowers/plans/ and emit a compact JSON +status scan: task ledger, per-task evidence, amendments, and git position. + +This does the *mechanical* half of a status check — parsing, file existence, +commit matching. It deliberately does NOT decide whether a task is done; it +reports evidence and lets the caller judge the frontier. See SKILL.md. + +Usage: + python .claude/skills/plan-status/scripts/scan_plan.py [--plan PATH] [--base BRANCH] +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +def repo_root() -> Path: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, + ) + return Path(out.stdout.strip()) + + +def git(*args: str, cwd: Path) -> str: + out = subprocess.run(["git", *args], capture_output=True, text=True, cwd=cwd) + return out.stdout.strip() if out.returncode == 0 else "" + + +# --- plan parsing ---------------------------------------------------------- + +TASK_RE = re.compile(r"^##\s+Task\s+(\d+)\s*[:—-]\s*(.+?)\s*$", re.M) +COMMIT_RE = re.compile(r'git commit -m ["\'](.+?)["\']') +PATH_RE = re.compile(r"`([^`]+\.[A-Za-z0-9]+)`") +PRODUCES_RE = re.compile(r"^-\s*Produces:\s*(.+)$", re.M) +STEP_RE = re.compile(r"^-\s*\[( |x|X)\]\s*\*\*(.+?)\*\*", re.M) + + +def is_complete(text: str) -> bool: + """A finished plan announces itself in the title or a Status line.""" + head = text[:1200] + if re.search(r"^#\s+.*\(COMPLETE\)", head, re.M): + return True + return bool(re.search(r"^\*\*Status:\*\*\s*all\s+\d+\s+tasks?\s+shipped", head, re.M)) + + +def parse_amendments(text: str, lines: list[str]) -> list[dict]: + """Amendment blocks supersede the snippets below them — surfacing these is + the difference between following the plan and following a stale plan.""" + out = [] + for m in re.finditer(r"^>\s*\*\*(Amendment[^*]*)\*\*(.*)$", text, re.M): + line_no = text[: m.start()].count("\n") + 1 + body = [] + for ln in lines[line_no - 1 : line_no + 12]: + if not ln.startswith(">"): + break + body.append(ln.lstrip("> ").rstrip()) + out.append({ + "line": line_no, + "title": m.group(1).strip(), + "body": " ".join(body)[:600], + }) + return out + + +def resolve_paths(files_block: str, index: dict[str, list[str]]) -> list[str]: + """The plan writes continuation paths — `.../patterns/PostCard.tsx`, `EmptyState.tsx` + — where a bare filename inherits the previous path's directory. Taking those + literally makes shipped tasks look unstarted, so resolve them in reading order + against the last directory seen, then fall back to a unique basename match.""" + resolved, last_dir = [], "" + for raw in PATH_RE.findall(files_block): + p = raw.strip() + if p.startswith("./"): + p = p[2:] + base = p.rsplit("/", 1)[-1] + hits = index.get(base, []) + + if "/" in p: + # Plans abbreviate ("src/index.css" for "apps/client/src/index.css"), + # so accept a unique suffix match before calling it missing. + suffix = [h for h in hits if h == p or h.endswith("/" + p)] + chosen = p if p in hits else (suffix[0] if len(suffix) == 1 else p) + elif last_dir and f"{last_dir}/{base}" in hits: + chosen = f"{last_dir}/{base}" + elif len(hits) == 1: + chosen = hits[0] + elif hits and last_dir: + # e.g. `.env.example` exists under both apps/*; pick the one sharing + # the most path with where we already are. + chosen = max(hits, key=lambda h: len(os.path.commonprefix([h, last_dir]))) + else: + chosen = f"{last_dir}/{base}" if last_dir else base + + if "/" in chosen: + last_dir = chosen.rsplit("/", 1)[0] + resolved.append(chosen) + return sorted(set(resolved)) + + +def parse_tasks(text: str, lines: list[str], index: dict[str, list[str]]) -> list[dict]: + marks = [(m.start(), int(m.group(1)), m.group(2)) for m in TASK_RE.finditer(text)] + tasks = [] + for i, (start, num, title) in enumerate(marks): + end = marks[i + 1][0] if i + 1 < len(marks) else len(text) + block = text[start:end] + line_no = text[:start].count("\n") + 1 + + files_m = re.search(r"\*\*Files:\*\*(.*?)(?:\n\s*\n|\*\*Interfaces)", block, re.S) + files = resolve_paths(files_m.group(1), index) if files_m else [] + + produces_m = PRODUCES_RE.search(block) + produces = produces_m.group(1).strip() if produces_m else "" + + commits = COMMIT_RE.findall(block) + steps = [{"checked": c.lower() == "x", "title": t} for c, t in STEP_RE.findall(block)] + + tasks.append({ + "num": num, + "title": title.strip(), + "line": line_no, + "end_line": text[:end].count("\n") + 1, + "files": files, + "produces": produces, + "commit_msgs": commits, + "steps": steps, + "mentions_stub": "stub" in block.lower(), + }) + return tasks + + +# --- evidence -------------------------------------------------------------- + +STOP = {"feat", "fix", "chore", "docs", "refactor", "test", "client", "server", + "api", "and", "the", "a", "an", "with", "for", "to", "of", "in", "on"} + + +def tokens(subject: str) -> set[str]: + subject = re.sub(r"^[a-z]+(\([^)]*\))?!?:\s*", "", subject.lower()) + return {w for w in re.findall(r"[a-z0-9]+", subject) if w not in STOP and len(w) > 2} + + +def match_commit(planned: str, log: list[tuple[str, str]]) -> dict | None: + """Commit subjects drift from what the plan prescribed (the plan says + 'blog feed page and PostCard', the real commit says 'blog feed with + PostCard and gating prompt'), so match on token overlap, not equality.""" + want = tokens(planned) + if not want: + return None + best, best_score = None, 0.0 + for sha, subject in log: + score = len(want & tokens(subject)) / len(want) + if score > best_score: + best, best_score = (sha, subject), score + if best and best_score >= 0.7: + return {"sha": best[0], "subject": best[1], "confidence": round(best_score, 2)} + return None + + +def build_index(root: Path) -> dict[str, list[str]]: + """basename -> tracked paths. git ls-files keeps node_modules out for free.""" + index: dict[str, list[str]] = {} + for line in git("ls-files", cwd=root).splitlines(): + index.setdefault(line.rsplit("/", 1)[-1], []).append(line) + return index + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--plan", help="path to a specific plan file (default: the active one)") + ap.add_argument("--base", default="master", help="branch the merged work lands beyond (default: master)") + args = ap.parse_args() + + root = repo_root() + plans_dir = root / "docs" / "superpowers" / "plans" + if not plans_dir.is_dir(): + print(json.dumps({"error": f"no plans directory at {plans_dir}"})) + return 1 + + all_plans = sorted(plans_dir.glob("*.md")) + catalog = [] + for p in all_plans: + t = p.read_text(encoding="utf-8", errors="replace") + catalog.append({"path": str(p.relative_to(root)).replace("\\", "/"), + "complete": is_complete(t)}) + + if args.plan: + plan_path = Path(args.plan) + if not plan_path.is_absolute(): + plan_path = root / plan_path + else: + active = [c for c in catalog if not c["complete"]] + if not active: + print(json.dumps({"plans": catalog, "active_plan": None, + "note": "every plan reports itself complete"}, indent=2)) + return 0 + plan_path = root / active[-1]["path"] + + text = plan_path.read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + tasks = parse_tasks(text, lines, build_index(root)) + + branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=root) + log_raw = git("log", "--oneline", "--no-merges", "-80", cwd=root) + log = [] + for ln in log_raw.splitlines(): + sha, _, subject = ln.partition(" ") + log.append((sha, subject)) + + merged_raw = git("log", "--oneline", "--no-merges", f"{args.base}..HEAD", cwd=root) + merged_shas = {ln.split(" ", 1)[0] for ln in merged_raw.splitlines()} + + for t in tasks: + t["file_status"] = { + f: ("present" if (root / f).exists() else "MISSING") for f in t["files"] + } + hits = [h for h in (match_commit(m, log) for m in t["commit_msgs"]) if h] + for h in hits: + h["on_current_branch"] = h["sha"] in merged_shas + t["commit_evidence"] = hits + missing = [f for f, s in t["file_status"].items() if s == "MISSING"] + # Two independent axes: do the artifacts exist, and did something ship them. + # They only agree at the extremes; the disagreements are exactly the tasks + # worth verifying by hand, so name them rather than guessing. + if not t["files"] and not hits: + t["signal"] = "NO_EVIDENCE" + elif missing and not hits: + t["signal"] = "NOT_STARTED" + elif not missing and hits: + t["signal"] = "LIKELY_DONE" + else: + t["signal"] = "NEEDS_CHECK" # mixed — verify against Produces + + result = { + "plans": catalog, + "active_plan": str(plan_path.relative_to(root)).replace("\\", "/"), + "plan_title": lines[0].lstrip("# ").strip() if lines else "", + "plan_lines": len(lines), + "task_count": len(tasks), + "checkbox_stats": { + "checked": text.count("- [x]") + text.count("- [X]"), + "unchecked": text.count("- [ ]"), + }, + "git": { + "branch": branch, + "base": args.base, + "dirty": bool(git("status", "--porcelain", cwd=root)), + "ahead_of_base": len(merged_shas), + "recent": [f"{s} {m}" for s, m in log[:12]], + }, + "amendments": parse_amendments(text, lines), + "tasks": tasks, + } + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/readme-maintenance/SKILL.md b/.claude/skills/readme-maintenance/SKILL.md new file mode 100644 index 000000000..b44cf2a69 --- /dev/null +++ b/.claude/skills/readme-maintenance/SKILL.md @@ -0,0 +1,103 @@ +--- +name: readme-maintenance +description: >- + Use on the Blog-Chat-app repo right before opening a PR from a dev/* branch — check whether README.md + needs an update given what the branch actually changed, and update it if so, BEFORE the PR is opened, so + the README update lands in the same PR as the change it documents. Also use whenever the user explicitly + asks to check, update, or review README.md, independent of any PR. Trigger on "open a PR", "ready to PR", + "create the PR", "let's ship this", "update the README", "does the README need updating", or any point in + the git-feature-branch workflow where a PR is about to be created — even if the user doesn't mention the + README themselves. +--- + +# README Maintenance + +## Why this exists + +`README.md` on `staging` is a deliberately maintained baseline (rewritten 2026-07-29) covering six things: +project summary, architecture diagram, core-flow diagrams, tech stack, core features, and local-run +commands. A baseline like that only stays true if every PR that changes one of those six things updates +the matching section — otherwise it drifts the same way the pre-rewrite README did (still describing a +CRA/Redux app years after the rebuild started). This skill is the checkpoint that catches that drift at +the one moment it's cheapest to fix: before the PR opens, using the diff that's already sitting there. + +**This skill never invents a new baseline.** If README.md looks structurally wrong for reasons unrelated to +the current branch's diff (sections missing entirely, describing a different architecture altogether), +that's a signal the baseline itself needs redoing — stop and say so rather than trying to patch it +piecemeal. + +## Step 1 — Check whether README.md already moved on this branch + +```bash +python .claude/skills/readme-maintenance/scripts/check_readme.py --base origin/staging +``` + +Swap `--base` if the branch stacks on something other than `staging` (dev-off-dev, per the +feature-branch-discipline rule). The script only gathers facts — merge-base, every changed file, the +branch's commit subjects, any `package.json` diffs, and which `apps/*`/`packages/*` directories were +touched — it makes no judgment call. + +**If `readme_already_touched_on_this_branch` is true, stop here.** Skip straight to opening the PR. Don't +second-guess an update that's already part of the branch's own commits — that's the "check git commits" +part of this skill's job, and it's mechanical, not a judgment call. + +## Step 2 — Decide if the diff actually touches one of the six sections + +README.md has exactly six load-bearing sections (`## About`, `## Architecture`, `## Core flows, in a +nutshell`, `## Tech stack`, `## Core features`, `## Quick start` + `## Scripts`). Map the script's output +onto them: + +| Signal from Step 1 | Section likely affected | +|---|---| +| A new top-level dir under `apps/*` or `packages/*` (e.g. a brand-new `apps/realtime`) | Architecture diagram | +| A new REST resource, a new primary user-facing capability, or removal of one | Core features | +| `package_json_diff` adding/removing a real dependency (not a devDependency version bump) | Tech stack table | +| A new root `npm run + + diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 000000000..9e8146f3c --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@blog/client", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "preview": "vite preview" + }, + "dependencies": { + "@blog/zod-shared": "*", + "@tanstack/react-query": "^5.101.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-markdown": "^10.1.0", + "react-router": "^8.3.0", + "remark-gfm": "^4.0.1", + "socket.io-client": "^4.8.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.4", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.3", + "vite": "^8.1.5" + } +} diff --git a/apps/client/src/api/auth.test.ts b/apps/client/src/api/auth.test.ts new file mode 100644 index 000000000..a5cdc1206 --- /dev/null +++ b/apps/client/src/api/auth.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./client.js', () => ({ request: vi.fn() })) + +describe('authApi debug logging', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + vi.restoreAllMocks() + }) + + // The page-level guard in SignupPage.test.tsx cannot see this: it mocks + // authApi wholesale, so a leak inside authApi itself passes straight through. + // This is the layer that actually holds the credential on its way to the wire. + it('never logs the plaintext password when tracing a signup', async () => { + vi.stubEnv('VITE_DEBUG', 'true') + vi.resetModules() + const { request } = await import('./client.js') + const { authApi } = await import('./auth.js') + vi.mocked(request).mockResolvedValue({ + id: 'u1', + username: 'recruiter', + email: 'recruiter@example.com', + }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await authApi.signup({ + username: 'recruiter', + email: 'recruiter@example.com', + password: 'a-valid-password', + }) + + // Without this the test passes vacuously whenever DEBUG resolves false. + expect(log).toHaveBeenCalled() + expect(JSON.stringify(log.mock.calls)).not.toContain('a-valid-password') + }) +}) diff --git a/apps/client/src/api/auth.ts b/apps/client/src/api/auth.ts new file mode 100644 index 000000000..61e6f4428 --- /dev/null +++ b/apps/client/src/api/auth.ts @@ -0,0 +1,28 @@ +import { request } from './client.js' +import type { z } from 'zod' +import { SignupSchema, LoginSchema } from '@blog/zod-shared' +import { DEBUG } from '../lib/constants.js' + +export type User = { id: string; username: string; email: string } + +/** Which federated sign-in options this deployment can actually offer. */ +export type AuthProviders = { google: boolean; facebook: boolean } + +export const authApi = { + signup: (input: z.infer) => { + // Never spread `input` into a log: it carries the plaintext password, and + // the hardening pass forbids a credential reaching any log sink. Trace the + // identifying fields only. + if (DEBUG) console.log('[AUTH_API] signup called for:', input.username, input.email) + return request('/api/v1/auth/signup', { method: 'POST', body: JSON.stringify(input) }) + }, + + login: (input: z.infer) => + request('/api/v1/auth/login', { method: 'POST', body: JSON.stringify(input) }), + + logout: () => request('/api/v1/auth/logout', { method: 'POST' }), + + me: () => request('/api/v1/auth/me'), + + providers: () => request('/api/v1/auth/providers'), +} diff --git a/apps/client/src/api/chat.ts b/apps/client/src/api/chat.ts new file mode 100644 index 000000000..15533deb8 --- /dev/null +++ b/apps/client/src/api/chat.ts @@ -0,0 +1,7 @@ +import { request } from './client.js' +import type { ChatMessage } from '@blog/zod-shared' + +export const chatApi = { + /** The last 50 messages, oldest-first (see apps/server/src/lib/services/chat.ts). */ + messages: () => request('/api/v1/chat/messages'), +} diff --git a/apps/client/src/api/client.test.ts b/apps/client/src/api/client.test.ts new file mode 100644 index 000000000..41408b962 --- /dev/null +++ b/apps/client/src/api/client.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError, request } from './client.js' // eslint-disable-line @typescript-eslint/no-unused-vars + +describe('request', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('returns the parsed JSON body on a 2xx response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })), + ) + await expect(request('/api/v1/health')).resolves.toEqual({ ok: true }) + }) + + it('returns undefined for a 204 with no body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 204 }))) + await expect(request('/api/v1/posts/x')).resolves.toBeUndefined() + }) + + it('throws ApiError carrying the status and field errors on a 400', async () => { + const body = { error: { message: 'Invalid input.', fields: { title: ['Too short'] } } } + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 400 }))) + await expect(request('/api/v1/posts')).rejects.toMatchObject({ + status: 400, + message: 'Invalid input.', + fields: { title: ['Too short'] }, + }) + }) + + it('always sends credentials so the session cookie rides along', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + await request('/api/v1/posts') + expect(fetchMock).toHaveBeenCalledWith('/api/v1/posts', expect.objectContaining({ credentials: 'include' })) + }) +}) + +/** + * `request` is the single path every API call takes, so its DEBUG trace sees + * the signup and login bodies — plaintext passwords included. Per-endpoint + * fixes cannot cover this: a wrapper that logs whatever it is handed re-leaks + * anything its callers were careful about. + */ +describe('request credential logging', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.resetModules() + vi.restoreAllMocks() + }) + + it('never logs a password from a request body, even with DEBUG on', async () => { + vi.stubEnv('VITE_DEBUG', 'true') + vi.resetModules() + const { request: freshRequest } = await import('./client.js') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await freshRequest('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ username: 'recruiter', password: 'super-secret-value' }), + }) + + // Without this the test passes vacuously whenever DEBUG resolves false. + expect(log).toHaveBeenCalled() + expect(JSON.stringify(log.mock.calls)).not.toContain('super-secret-value') + }) +}) diff --git a/apps/client/src/api/client.ts b/apps/client/src/api/client.ts new file mode 100644 index 000000000..8d6d14956 --- /dev/null +++ b/apps/client/src/api/client.ts @@ -0,0 +1,54 @@ +import { DEBUG } from '../lib/constants.js' +import { redactSecrets } from '../lib/redact.js' + +export class ApiError extends Error { + readonly status: number + readonly fields: Record + + constructor(status: number, message: string, fields: Record = {}) { + super(message) + this.name = 'ApiError' + this.status = status + this.fields = fields + } +} + +export async function request(path: string, init?: RequestInit): Promise { + const method = init?.method || 'GET' + + if (DEBUG) { + // Every API call funnels through here, signup and login included, so this + // trace sees plaintext passwords. Redacting at the wrapper is what actually + // closes it — a per-endpoint fix cannot, since the wrapper logs whatever it + // is handed. Non-JSON bodies (FormData, Blob) are simply not traced. + let body: unknown = null + try { + body = typeof init?.body === 'string' ? redactSecrets(JSON.parse(init.body)) : null + } catch { + body = '[unparsed body]' + } + console.log(`[API] ${method} ${path}`, body) + } + + // A JSON body needs the matching Content-Type, or express.json() skips + // parsing and req.body arrives as {} — every field then reads as "Required". + const headers = + init?.body !== undefined + ? { 'Content-Type': 'application/json', ...init?.headers } + : init?.headers + + const response = await fetch(path, { ...init, headers, credentials: 'include' }) + + if (DEBUG) console.info(`[API Response] ${method} ${path} → ${response.status}`) + + if (!response.ok) { + const body = await response.json() + console.error(`[API Error] ${method} ${path} - ${response.status}:`, body.error) + throw new ApiError(response.status, body.error.message, body.error.fields || {}) + } + + if (response.status === 204) return undefined + + const data = await response.json() + return data +} diff --git a/apps/client/src/api/comments.ts b/apps/client/src/api/comments.ts new file mode 100644 index 000000000..36215fcef --- /dev/null +++ b/apps/client/src/api/comments.ts @@ -0,0 +1,32 @@ +import { request } from './client.js' +import type { CreateComment, UpdateComment } from '@blog/zod-shared' + +export type Comment = { + id: string + body: string + author: { id: string; username: string } + /** null for a root comment, the parent comment's id for a reply. */ + parent: string | null + createdAt: string + updatedAt: string +} + +const base = (slug: string) => `/api/v1/posts/${slug}/comments` + +export const commentsApi = { + // No `gated` here and none expected: the wall is on post bodies only, so this + // returns the same payload signed in or not. + list: (slug: string) => request(base(slug)), + + create: (slug: string, input: CreateComment) => + request(base(slug), { method: 'POST', body: JSON.stringify(input) }), + + update: (slug: string, commentId: string, input: UpdateComment) => + request(`${base(slug)}/${commentId}`, { + method: 'PATCH', + body: JSON.stringify(input), + }), + + remove: (slug: string, commentId: string) => + request(`${base(slug)}/${commentId}`, { method: 'DELETE' }), +} diff --git a/apps/client/src/api/posts.test.ts b/apps/client/src/api/posts.test.ts new file mode 100644 index 000000000..cc8195af5 --- /dev/null +++ b/apps/client/src/api/posts.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeListParams, postsApi } from './posts.js' + +/** Stubs fetch and returns the mock, so tests can assert the URL that was hit. */ +function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +const calledPath = (fetchMock: ReturnType) => fetchMock.mock.calls[0]![0] + +describe('postsApi.list', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('hits the bare collection when there are no filters', async () => { + const fetchMock = stubFetch() + await postsApi.list() + expect(calledPath(fetchMock)).toBe('/api/v1/posts') + }) + + it('sends a term as ?q=', async () => { + const fetchMock = stubFetch() + await postsApi.list({ q: 'x' }) + expect(calledPath(fetchMock)).toBe('/api/v1/posts?q=x') + }) + + it('sends a tag as ?tag=, and both filters together', async () => { + const fetchMock = stubFetch() + await postsApi.list({ q: 'x', tag: 'express' }) + expect(calledPath(fetchMock)).toBe('/api/v1/posts?q=x&tag=express') + }) + + // A cleared box must produce the unfiltered feed, not `?q=` — the server has + // its own guard, but an empty term is not a search on either side. + it('omits a whitespace-only term entirely', async () => { + const fetchMock = stubFetch() + await postsApi.list({ q: ' ' }) + expect(calledPath(fetchMock)).toBe('/api/v1/posts') + }) + + it('percent-encodes a term rather than pasting it into the URL', async () => { + const fetchMock = stubFetch() + await postsApi.list({ q: 'a&b c' }) + expect(calledPath(fetchMock)).toBe('/api/v1/posts?q=a%26b+c') + }) +}) + +// `usePosts` builds the cache key from this, so two params that produce one URL +// have to produce one object — otherwise the same request is cached twice. +describe('normalizeListParams', () => { + it('collapses a blank filter to no filter at all', () => { + expect(normalizeListParams({ q: '' })).toEqual({}) + expect(normalizeListParams({ q: ' ', tag: '' })).toEqual({}) + }) + + it('collapses a padded filter onto its trimmed form', () => { + expect(normalizeListParams({ q: ' mongo ' })).toEqual(normalizeListParams({ q: 'mongo' })) + }) + + it('keeps the filters that carry a value', () => { + expect(normalizeListParams({ q: 'mongo', tag: 'db' })).toEqual({ q: 'mongo', tag: 'db' }) + }) +}) diff --git a/apps/client/src/api/posts.ts b/apps/client/src/api/posts.ts new file mode 100644 index 000000000..d09bc40e0 --- /dev/null +++ b/apps/client/src/api/posts.ts @@ -0,0 +1,68 @@ +import { request } from './client.js' +import type { z } from 'zod' +import { CreatePostSchema, UpdatePostSchema } from '@blog/zod-shared' + +export type Post = { + id: string + title: string + slug: string + body: string + /** Server's verdict: this reader is anonymous, so `body` is only the teaser. */ + gated: boolean + author: { id: string; username: string } + tags: string[] + likeCount: number + coverImage?: string + /** Delivery URL derived server-side from coverImage. Absent when there is no cover. */ + coverUrl?: string + createdAt: string + updatedAt: string +} + +/** Feed filters, mirroring the server's `?q=` / `?tag=` query params. */ +export type PostListParams = { q?: string; tag?: string } + +/** + * Drops the filters that carry no value and trims the rest. A whitespace-only + * term is not a search — and `$text: { $search: '' }` is an error on the server. + * + * Exported because the cache key has to be built from the SAME normalized + * object the URL is. Normalizing in only one of the two places would cache + * `{ q: 'mongo ' }`, `{ q: 'mongo' }` and `{}` vs `{ q: '' }` as distinct + * entries that all fetch one identical URL. + */ +export function normalizeListParams({ q, tag }: PostListParams): PostListParams { + const normalized: PostListParams = {} + if (q?.trim()) normalized.q = q.trim() + if (tag?.trim()) normalized.tag = tag.trim() + return normalized +} + +function listPath(params: PostListParams): string { + const { q, tag } = normalizeListParams(params) + const search = new URLSearchParams() + if (q) search.set('q', q) + if (tag) search.set('tag', tag) + const query = search.toString() + return query ? `/api/v1/posts?${query}` : '/api/v1/posts' +} + +export const postsApi = { + list: (params: PostListParams = {}) => request(listPath(params)), + + get: (slug: string) => request(`/api/v1/posts/${slug}`), + + create: (input: z.infer) => + request('/api/v1/posts', { method: 'POST', body: JSON.stringify(input) }), + + update: (slug: string, input: z.infer) => + request(`/api/v1/posts/${slug}`, { method: 'PATCH', body: JSON.stringify(input) }), + + remove: (slug: string) => request(`/api/v1/posts/${slug}`, { method: 'DELETE' }), + + like: (slug: string) => + request<{ likeCount: number }>(`/api/v1/posts/${slug}/likes`, { method: 'PUT' }), + + unlike: (slug: string) => + request<{ likeCount: number }>(`/api/v1/posts/${slug}/likes`, { method: 'DELETE' }), +} diff --git a/apps/client/src/api/uploads.ts b/apps/client/src/api/uploads.ts new file mode 100644 index 000000000..72a5b6e91 --- /dev/null +++ b/apps/client/src/api/uploads.ts @@ -0,0 +1,88 @@ +import { ApiError, request } from './client.js' + +export type UploadFolder = 'covers' | 'avatars' + +export type UploadSignature = { + cloudName: string + apiKey: string + folder: string + timestamp: number + allowedFormats: string + signature: string +} + +/** + * The signature only constrains folder and format — Cloudinary has no signable + * size parameter — so the byte limit is enforced here, before we ask for one. + */ +export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 + +export class UploadsUnavailableError extends Error { + constructor() { + super('Image uploads are not set up on this server.') + this.name = 'UploadsUnavailableError' + } +} + +export const uploadsApi = { + signature: (folder: UploadFolder) => + request(`/api/v1/uploads/signature?folder=${folder}`, { method: 'POST' }), +} + +/** + * Signature from our API, bytes straight to Cloudinary. Resolves to the public + * ID, which is what gets persisted — never the delivery URL, so the host can + * change without rewriting documents. + * + * This is the one place the client talks to a third-party origin, which is why + * it uses `fetch` directly instead of the same-origin `request` wrapper. + */ +export async function uploadImage(file: File, folder: UploadFolder): Promise { + if (file.size > MAX_UPLOAD_BYTES) { + throw new Error(`That image is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is 5 MB.`) + } + + let signed: UploadSignature + try { + // `request` resolves undefined on a 204; a signature route never sends one. + const issued = await uploadsApi.signature(folder) + if (!issued) throw new Error('The server issued no upload signature.') + signed = issued + } catch (err) { + // 503 is the deployment saying it has no Cloudinary account, not a failure + // the reader can retry — the caller shows a different message for it. + if (err instanceof ApiError && err.status === 503) throw new UploadsUnavailableError() + throw err + } + + const form = new FormData() + form.append('file', file) + form.append('api_key', signed.apiKey) + form.append('timestamp', String(signed.timestamp)) + form.append('folder', signed.folder) + form.append('allowed_formats', signed.allowedFormats) + form.append('signature', signed.signature) + + // No credentials: this is a third-party origin and our session cookie has no + // business being sent to it. + const res = await fetch(`https://api.cloudinary.com/v1_1/${signed.cloudName}/image/upload`, { + method: 'POST', + body: form, + }) + + const payload: unknown = await res.json().catch(() => null) + if (!res.ok) { + const message = + typeof payload === 'object' && payload !== null && 'error' in payload + ? String((payload as { error: { message?: string } }).error?.message ?? '') + : '' + throw new Error(message || 'Cloudinary rejected the upload.') + } + + const publicId = + typeof payload === 'object' && payload !== null && 'public_id' in payload + ? (payload as { public_id?: unknown }).public_id + : undefined + if (typeof publicId !== 'string') throw new Error('Cloudinary returned no public ID.') + return publicId +} diff --git a/apps/client/src/api/users.ts b/apps/client/src/api/users.ts new file mode 100644 index 000000000..8333248e0 --- /dev/null +++ b/apps/client/src/api/users.ts @@ -0,0 +1,14 @@ +import { request } from './client.js' +import type { z } from 'zod' +import { UpdateUserSchema } from '@blog/zod-shared' + +export type UserProfile = { id: string; username: string; bio?: string; avatar?: string } + +export const usersApi = { + get: (id: string) => request(`/api/v1/users/${id}`), + + update: (id: string, input: z.infer) => + request(`/api/v1/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), + + remove: (id: string) => request(`/api/v1/users/${id}`, { method: 'DELETE' }), +} diff --git a/apps/client/src/components/layouts/PageShell.tsx b/apps/client/src/components/layouts/PageShell.tsx new file mode 100644 index 000000000..fba99bd2d --- /dev/null +++ b/apps/client/src/components/layouts/PageShell.tsx @@ -0,0 +1,70 @@ +import { Link, NavLink, useNavigate } from 'react-router' +import { useLogout, useMe } from '../../hooks/use-auth.js' + +const navLink = + 'border-b-[1.5px] border-transparent pb-1 hover:text-[var(--foreground)] aria-[current=page]:border-[var(--primary)] aria-[current=page]:text-[var(--primary)]' + +export function PageShell({ children }: { children: React.ReactNode }) { + const { data: me } = useMe() + const navigate = useNavigate() + const logout = useLogout() + + return ( +
+ {/* The content sits on a raised sheet, so the tinted paper reads as a + surround rather than as the page's own background. */} +
+
+ + B + + + +
+ +
{children}
+
+
+ ) +} diff --git a/apps/client/src/components/patterns/AutoForm.test.tsx b/apps/client/src/components/patterns/AutoForm.test.tsx new file mode 100644 index 000000000..42da6633d --- /dev/null +++ b/apps/client/src/components/patterns/AutoForm.test.tsx @@ -0,0 +1,102 @@ +// The root vitest.config.ts declares no setupFiles, so apps/client/src/test/setup.ts +// never runs — every client test file wires jest-dom and cleanup itself, as +// apps/client/src/components/patterns/PostCard.test.tsx does. +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { AutoForm } from './AutoForm.js' + +const schema = z.object({ + title: z.string().min(3, 'Title must be at least 3 characters'), + draft: z.coerce.boolean().default(false), + tags: z.array(z.string()).default([]), +}) + +function addTag(tag: string) { + const input = screen.getByLabelText('tags') + fireEvent.change(input, { target: { value: tag } }) + fireEvent.keyDown(input, { key: 'Enter' }) +} + +describe('AutoForm', () => { + afterEach(() => cleanup()) + + it('renders one labeled field per schema key', () => { + render() + expect(screen.getByLabelText('title')).toBeInTheDocument() + expect(screen.getByLabelText('draft')).toBeInTheDocument() + expect(screen.getByLabelText('tags')).toBeInTheDocument() + }) + + it('derives the control from the schema type, not the field name', () => { + render() + expect(screen.getByLabelText('draft')).toHaveAttribute('type', 'checkbox') + expect(screen.getByLabelText('tags')).toHaveAttribute('type', 'text') + }) + + // `.partial()` wraps every field in ZodOptional *outside* the ZodDefault that + // `z.array(...).default([])` already added, so a single-level unwrap reports + // `tags` as a plain string field and it renders as a bare text box. + it('still derives field kinds through a .partial() schema', () => { + const onSubmit = vi.fn() + render() + expect(screen.getByLabelText('draft')).toHaveAttribute('type', 'checkbox') + + addTag('express') + addTag('testing') + fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } }) + fireEvent.click(screen.getByText('Save')) + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['express', 'testing'] })) + }) + + it('shows the schema error message and does not call onSubmit for invalid input', () => { + const onSubmit = vi.fn() + render() + fireEvent.change(screen.getByLabelText('title'), { target: { value: 'ab' } }) + fireEvent.click(screen.getByText('Save')) + expect(screen.getByText('Title must be at least 3 characters')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('submits the tags added one at a time as a string array', () => { + const onSubmit = vi.fn() + render() + fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } }) + addTag('express') + addTag('testing') + expect(screen.getByText('express')).toBeInTheDocument() + fireEvent.click(screen.getByText('Save')) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ title: 'A valid title', tags: ['express', 'testing'] }), + ) + }) + + it('drops a tag removed with its × button', () => { + const onSubmit = vi.fn() + render() + fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } }) + addTag('keep') + addTag('drop') + fireEvent.click(screen.getByLabelText('Remove tag drop')) + fireEvent.click(screen.getByText('Save')) + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['keep'] })) + }) + + it('seeds the form from initialValues', () => { + render( + , + ) + expect(screen.getByLabelText('title')).toHaveValue('Seeded') + expect(screen.getByLabelText('draft')).toBeChecked() + expect(screen.getByText('a')).toBeInTheDocument() + expect(screen.getByText('b')).toBeInTheDocument() + expect(screen.getByLabelText('tags')).toHaveValue('') + expect(screen.getByText('Save changes')).toBeInTheDocument() + }) +}) diff --git a/apps/client/src/components/patterns/AutoForm.tsx b/apps/client/src/components/patterns/AutoForm.tsx new file mode 100644 index 000000000..a60986fec --- /dev/null +++ b/apps/client/src/components/patterns/AutoForm.tsx @@ -0,0 +1,175 @@ +import { useState, type FormEvent } from 'react' +import { ZodFirstPartyTypeKind, type z, type ZodObject, type ZodRawShape, type ZodTypeAny } from 'zod' +import { Button } from '../ui/button.js' +import { Input } from '../ui/input.js' +import { Label } from '../ui/label.js' +import { Textarea } from '../ui/textarea.js' +import { ImageUpload } from './ImageUpload.js' +import { TagsInput } from './TagsInput.js' + +type FieldKind = 'checkbox' | 'textarea' | 'tags' | 'image' | 'text' + +/** + * `ZodTypeDef` is the public (near-empty) shape of `_def`; the discriminant and + * the wrapped schema live on the first-party subtypes. This is the one narrow + * place where we look at zod's internals, so the cast is kept here. + */ +type ZodDefInternals = { typeName?: string; innerType?: ZodTypeAny; schema?: ZodTypeAny } + +function internals(schema: ZodTypeAny): ZodDefInternals { + return schema._def as ZodDefInternals +} + +/** + * Peel every wrapper off a field until the type that decides the control shows + * through. One level is not enough: `UpdatePostSchema` is `CreatePostSchema.partial()`, + * so `tags` arrives as `ZodOptional>`. Unwrapping only + * `ZodDefault` (or only `ZodOptional`) reports it as a plain string field, the + * comma-split never runs, and the edit form posts `tags: "a, b"` — which the API + * rejects. None of these wrappers expose `.unwrap()` uniformly, hence `_def`. + */ +function unwrap(schema: ZodTypeAny): ZodTypeAny { + let current = schema + for (;;) { + const def = internals(current) + const next = + def.typeName === ZodFirstPartyTypeKind.ZodDefault || + def.typeName === ZodFirstPartyTypeKind.ZodOptional || + def.typeName === ZodFirstPartyTypeKind.ZodNullable + ? def.innerType + : def.typeName === ZodFirstPartyTypeKind.ZodEffects + ? def.schema + : undefined + if (!next) return current + current = next + } +} + +function fieldKind(key: string, schema: ZodTypeAny): FieldKind { + const { typeName } = internals(unwrap(schema)) + if (typeName === ZodFirstPartyTypeKind.ZodBoolean) return 'checkbox' + if (typeName === ZodFirstPartyTypeKind.ZodArray) return 'tags' + if (key === 'body') return 'textarea' + // Keyed by name, not by type: a public ID is a plain string to zod, and a raw + // text box for one would be unusable — nobody types a Cloudinary ID by hand. + if (key === 'coverImage' || key === 'avatar') return 'image' + return 'text' +} + +/** + * Renders a form straight from a zod schema and hands `onSubmit` the **parsed** + * output, never the raw strings — so the schema in `@blog/zod-shared` that the + * server validates against is the same one that shapes and validates the form. + */ +export function AutoForm>({ + schema, + initialValues, + onSubmit, + submitLabel = 'Save', + imagePreviewUrl, +}: { + schema: S + initialValues?: Partial> + onSubmit: (values: z.infer) => void + submitLabel?: string + /** Delivery URL for an already-saved image — the form holds only its ID. */ + imagePreviewUrl?: string +}) { + // Derived once, up front: `schema.shape` is indexed by an open `string` key, + // so re-reading it per render would fight `noUncheckedIndexedAccess`. + const fields: { key: string; kind: FieldKind }[] = Object.entries(schema.shape).map( + ([key, fieldSchema]) => ({ key, kind: fieldKind(key, fieldSchema) }), + ) + + const [values, setValues] = useState>(() => { + const initial = (initialValues ?? {}) as Record + return Object.fromEntries( + fields.map(({ key, kind }) => { + const seed = initial[key] + switch (kind) { + case 'checkbox': + return [key, Boolean(seed ?? false)] + case 'tags': + return [key, Array.isArray(seed) ? seed.map(String) : []] + // null, never '': an empty string is not a valid public ID and would + // fail the schema on every submit that leaves the image blank. + case 'image': + return [key, typeof seed === 'string' ? seed : null] + default: + return [key, typeof seed === 'string' ? seed : ''] + } + }), + ) + }) + const [errors, setErrors] = useState>({}) + + // `tags` is already held as an array by TagsInput, so nothing is parsed out of + // a raw string here — the state shape per field kind is the parsed shape. + function parsedValue(key: string): unknown { + return values[key] + } + + function handleSubmit(e: FormEvent) { + e.preventDefault() + const candidate = Object.fromEntries(fields.map(({ key }) => [key, parsedValue(key)])) + const result = schema.safeParse(candidate) + if (!result.success) { + setErrors( + Object.fromEntries(result.error.issues.map((issue) => [String(issue.path[0]), issue.message])), + ) + return + } + setErrors({}) + onSubmit(result.data as z.infer) + } + + return ( +
+ {fields.map(({ key, kind }) => { + return ( +
+ {/* ImageUpload renders its own label and controls. */} + {kind !== 'image' && } + {kind === 'image' ? ( + setValues((v) => ({ ...v, [key]: publicId }))} + /> + ) : kind === 'checkbox' ? ( + setValues((v) => ({ ...v, [key]: e.target.checked }))} + /> + ) : kind === 'tags' ? ( + setValues((v) => ({ ...v, [key]: tags }))} + /> + ) : kind === 'textarea' ? ( +