feat: cutover-blog — content-typed blog-posts sheet + viewer (closes #84)#101
Merged
Conversation
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>
This was referenced May 30, 2026
Closed
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.
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.
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:
Test plan
🤖 Generated with Claude Code