Skip to content

feat: cutover-blog — content-typed blog-posts sheet + viewer (closes #84)#101

Merged
themightychris merged 8 commits into
mainfrom
feat/cutover-blog
May 30, 2026
Merged

feat: cutover-blog — content-typed blog-posts sheet + viewer (closes #84)#101
themightychris merged 8 commits into
mainfrom
feat/cutover-blog

Conversation

@themightychris
Copy link
Copy Markdown
Member

Summary

Brings laddr's `blog_posts` table back online before cutover so the absence of historical posts at flip-time doesn't read as a regression. Implements #84 minimum-viable scope with the upgrade to a content-typed gitsheets sheet (per the broader #45 direction) rather than the plain-TOML interim the original #84 body proposed.

  • Schema — `BlogPost` Zod schema in `@cfp/shared/schemas`.
  • Sheet config — `.gitsheets/blog-posts.toml` with `[gitsheet.format] type='markdown' body='body'` (lands in the data repo via CodeForPhilly/codeforphilly-data#1). On-disk artifacts are Hugo-style markdown files — `+++` TOML frontmatter + body — one `.md` per slug.
  • Backend — public-store validator, in-memory state Map + slug/legacyId indices, boot loader (tolerates an absent sheet), service, serializer, two routes:
    • `GET /api/blog-posts` — paginated, newest postedAt first, optional `?tag` / `?since`
    • `GET /api/blog-posts/:slug` — single post with `bodyHtml` rendered via the existing server-side markdown pipeline
  • Importer — laddr `/blog?format=json` fetcher + `translateBlogPost` mapping. Re-runnable, idempotent (`collectExistingIds` preserves UUIDs).
  • SPA — `/blog` index + `/blog/:slug` detail. Footer link "Blog" makes the screen discoverable.
  • Specs — `specs/api/blog.md`, `specs/screens/blog-{index,detail}.md`, `BlogPost` entity in `data-model.md`, supersede the prior deferred-blog entry in `deferred.md`.

Reduces #45 by landing the content-typed substrate; lazy body loading + full reader experience remain tracked there.

Sequencing

Merge order to roll out blog content:

  1. Merge this PR first — pod boots with the new sheet validator wired in. The loader tolerates an absent sheet, so the running pod survives in either order.
  2. Merge codeforphilly-data#1 to `empty` → propagates into `fixture` and `legacy-import` on next rebase.
  3. Re-run the laddr importer against the upstream laddr instance → populates `legacy-import` with the historical posts.
  4. Merge `legacy-import` → `published` → hot-reload webhook surfaces the content.

Test plan

  • 5 new translator cases pass (happy path, slug fallback, anon author, edit-window, summary truncation)
  • 8 route tests pass (list pagination, sort by postedAt desc, soft-delete exclusion, since filter, detail by slug, 404 unknown, 404 soft-deleted)
  • 3 BlogIndex SPA tests pass (header, post card, empty state)
  • BlogPost schema tests cover minimal/full fields + reject empty title + over-long fields
  • 332 API + 33 web + 75 shared tests green; `npm run type-check && npm run lint` clean

🤖 Generated with Claude Code

themightychris and others added 8 commits May 30, 2026 09:21
Brings laddr blog_posts back online before cutover, upgraded from the
plain-TOML interim scope in #84's body to a content-typed gitsheets
sheet ([gitsheet.format] type='markdown' body='body'). Verified
gitsheets v1.3.1 supports content-typed records — on-disk artifacts
become Hugo-style markdown (+++ frontmatter + body), reviewable as
plain markdown in PRs to the data repo.

Lazy body loading and the full reader experience stay deferred to #45;
this plan reduces (but doesn't close) #45 by landing the content-typed
substrate.

Closes #84.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds api/blog.md, screens/blog-index.md, screens/blog-detail.md and
inserts BlogPost into data-model.md (with the new content-typed sheet
convention: gitsheets [gitsheet.format] type='markdown' body='body'
storing one .md per slug as Hugo-style +++-frontmatter + body).

Updates legacy-id-mapping.md to add blog-posts to the legacyId-bearing
sheet list, and supersedes the prior deferred.md entry that planned to
ship blog as files in the code repo — content-typed gitsheets keeps the
PR-review ergonomics while sitting on the same runtime + import
pipeline as everything else.

Lazy body loading and the richer reader experience stay tracked in #45.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds packages/shared/src/schemas/blog-post.ts mirroring the BlogPost
entity in specs/data-model.md. Fields: id (uuid), legacyId (optional
int), slug (≤100), title (1-200), summary (≤500 nullable), authorId
(nullable), postedAt, editedAt, featuredImageKey, deletedAt
(soft-delete marker), body (markdown), createdAt, updatedAt.

passthrough() matches the rest of the sheet schemas — denormalized
path-template fields supplied by write services survive validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two read-only endpoints per specs/api/blog.md:
  GET /api/blog-posts          paginated list, newest postedAt first,
                               optional ?tag/?since filters
  GET /api/blog-posts/:slug    single post detail with bodyHtml

Wires the blog-posts sheet through the existing store machinery:
  - public.ts registers the BlogPost validator
  - InMemoryState.blogPosts Map + blogPostIdBySlug /
    blogPostIdByLegacyId secondary indices
  - loader.ts queries blog-posts at boot (tolerates an absent sheet:
    queryAll returns [] when the data repo hasn't merged the config
    PR yet)
  - reload.ts swaps blog-posts in place on hot-reload

TagAssignment.taggableType gains 'blog_post' so blog posts can be
tagged via the same polymorphic substrate as projects/people/roles.

Writes are out of scope — content lands via PR to the data repo
(the on-disk artifact is plain markdown with TOML frontmatter).
Per-author CMS writes stay tracked in #45.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds RawBlogPostSchema (laddr's /blog?format=json) + translateBlogPost
to the import pipeline. Mapping:

  Handle | slugified Title | legacy-<id>  →  slug
  Title                                   →  title
  Body                                    →  body (markdown)
  Summary (truncated to 500)              →  summary
  AuthorID → idMaps.personByLegacy        →  authorId (anon on unresolved)
  Published                               →  postedAt (falls back to Created)
  Modified (if >60s after Published)      →  editedAt
  Created                                 →  createdAt
  Modified                                →  updatedAt

The cutover-dry-run sheet manifest now exercises /blog so import counts
get the same source-vs-imported diff guardrail as everything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new lazy-routable screens under apps/web/src/screens/:

  BlogIndex  — paginated, reverse-chrono list at /blog. Cards show
               featured image, title (linked), author (linked),
               postedAt, and the summary or first paragraph excerpt.
               URL-state pagination + ?tag filter chip.
  BlogDetail — /blog/:slug. Renders bodyHtml via the existing
               MarkdownView component (server-rendered, sanitized HTML
               per behaviors/markdown-rendering.md). 404s on unknown
               slug; soft-deleted posts also 404.

api.ts gains a BlogPostResponse type + api.blogPosts.{list,bySlug}
helpers. The AppFooter Community column gets a Blog link so the
screen is discoverable.

Tests:
  apps/api/tests/blog-posts.test.ts — 8 route tests covering list
    pagination, sort, since-filter, soft-delete exclusion, detail
    by slug, 404. Seeds records via seedRawBlob writing
    +++-frontmatter markdown files (the content-typed format).
  apps/api/tests/import-laddr.test.ts — 5 new translator cases
    (happy path, slug fallback, anon author warning, edit-window,
    summary truncation) + /blog mock route on the orchestrator
    fixture.
  apps/web/tests/BlogIndex.test.tsx — header, post card, empty state.

import-laddr.test.ts's seed-repo + the test-full-repo helper both gain
.gitsheets/blog-posts.toml with the content-typed format. The importer
pre-pass (collectExistingIds) now reads blog-posts so re-runs preserve
UUIDs — the idempotence guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests that boot the full app via buildApp() but maintain their own
SHEET_CONFIGS / fetch-mock fixtures, separate from
test-full-repo.ts's helper:

  - internal-reload.test.ts seeds its own .gitsheets/*.toml set onto a
    bare repo for the hot-reload rig. Adding blog-posts.toml keeps
    openPublicStore's validator-vs-sheet check satisfied.
  - cutover-dry-run.test.ts mocks laddr's JSON endpoints; the importer
    now fetches /blog, so the mock needs a route — empty envelope is
    enough to exercise the count-diff guardrail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 9 validation checkboxes ticked. Notes covers the title-from-H1
back-off (legacy bodies can't be assumed to lead with an H1), the
TagAssignment enum widening (`'blog_post'`), the pre-existing
swapInPlace missing-indices observation, and the importer
collectExistingIds completeness fix.

Follow-ups: re-run the importer + merge to published after PR lands;
audit reload.ts for missing pre-existing indices (separate plan);
lazy body loading + reader experience stay tracked in #45.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant