specs/deferred.md currently says blog posts get replaced by "staff-authored markdown files in the code repo at apps/web/src/content/blog/<slug>.md, shipped via PR." That decision predates gitsheets v1.2's content-typed records.
With v1.2 we can give blog posts their own gitsheets sheet — markdown bodies + TOML frontmatter — and get a better outcome than files-in-code-repo:
Why this beats the original deferral
| Concern |
Files-in-code-repo |
Content-typed sheet |
| PR-reviewable |
✅ |
✅ |
| Publish cadence |
Tied to web deploys |
Immediate on data-repo merge |
| Tags / cross-links |
Ad-hoc frontmatter |
Native TagAssignment |
| Author attribution |
Hand-stamp in frontmatter |
Native Person reference |
| Snapshot inclusion |
Not in data snapshot |
In the snapshot (pseudonymized) |
| API serving |
Bespoke Vite handler |
Existing read API pipeline |
/blog index cost |
Bundle every post into web build |
queryAll({ withBody: false }) |
| laddr-import revival |
Out of scope |
Resurrect blog_posts table on the existing one-shot import |
Sheet shape
# .gitsheets/blog-posts.toml
[gitsheet]
root = 'blog-posts'
path = '${{ slug }}'
[gitsheet.format]
type = 'markdown'
body = 'body'
[gitsheet.schema]
$ref = './schemas/BlogPost.schema.json'
BlogPost entity (in packages/shared/src/schemas/blog-post.ts):
id UUIDv7
legacyId (laddr's BlogPost.ID, for the importer's idempotence)
slug (kebab-case, slug-handle conventions)
title
summary (short markdown — stays in frontmatter)
authorId → Person
postedAt (iso8601)
editedAt nullable
featuredImageKey nullable (attachment via gitsheets)
deletedAt nullable (soft-delete)
body (the markdown body — the designated content field)
- standard
createdAt / updatedAt
Routing
Add to the SPA:
/blog — index (paginated, optional tag filter)
/blog/:slug — detail
/blog/tag/:namespace/:slug — tag-filtered (reuse TagsNamespace pattern)
API:
GET /api/blog-posts (list with facets, q, sort, page)
GET /api/blog-posts/:slug (detail)
POST/PATCH/DELETE — staff-only (per the original spec, blog wasn't a per-user-role CMS)
laddr-import revival
The existing one-shot importer at apps/api/scripts/import-laddr.ts currently skips blog_posts. Re-add it as another translator in apps/api/scripts/import-laddr/translators.ts:
- Map
BlogPost.Slug → slug (slugify-with-dedupe if invalid)
- Map
BlogPost.Title → title
- Map
BlogPost.Body → body
- Map
BlogPost.AuthorID → resolve via the existing idMaps.personByLegacy
- Map
BlogPost.Published (and similar) → postedAt
- Preserve
legacyId so re-runs are idempotent
Sequencing
- Depends on #44 (content-typed gitsheets is the substrate) — or stand on its own as the first content-typed sheet in the project. Either order works since blog-posts is a brand-new sheet that doesn't conflict with the existing TOML-only ones.
- Sequenced after
cutover-prep so existing migration paths stay valid through cutover.
Spec updates needed
specs/deferred.md — update the "Blog (/blog) as a user-facing CMS" entry from "files in code repo" to "content-typed sheet, see this issue."
- New spec files:
specs/api/blog.md, specs/screens/blog-index.md, specs/screens/blog-detail.md.
specs/data-model.md — add BlogPost entity.
specs/behaviors/legacy-id-mapping.md — note the new BlogPost.legacyId axis.
Out of scope: comments, reactions, the multi-author "posts under a topic" workflow — keep it as simple as the original deferral imagined.
specs/deferred.md currently says blog posts get replaced by "staff-authored markdown files in the code repo at
apps/web/src/content/blog/<slug>.md, shipped via PR." That decision predates gitsheets v1.2's content-typed records.With v1.2 we can give blog posts their own gitsheets sheet — markdown bodies + TOML frontmatter — and get a better outcome than files-in-code-repo:
Why this beats the original deferral
TagAssignmentPersonreference/blogindex costqueryAll({ withBody: false })blog_poststable on the existing one-shot importSheet shape
BlogPostentity (inpackages/shared/src/schemas/blog-post.ts):idUUIDv7legacyId(laddr'sBlogPost.ID, for the importer's idempotence)slug(kebab-case, slug-handle conventions)titlesummary(short markdown — stays in frontmatter)authorId→ PersonpostedAt(iso8601)editedAtnullablefeaturedImageKeynullable (attachment via gitsheets)deletedAtnullable (soft-delete)body(the markdown body — the designated content field)createdAt/updatedAtRouting
Add to the SPA:
/blog— index (paginated, optional tag filter)/blog/:slug— detail/blog/tag/:namespace/:slug— tag-filtered (reuseTagsNamespacepattern)API:
GET /api/blog-posts(list with facets, q, sort, page)GET /api/blog-posts/:slug(detail)POST/PATCH/DELETE— staff-only (per the original spec, blog wasn't a per-user-role CMS)laddr-import revival
The existing one-shot importer at
apps/api/scripts/import-laddr.tscurrently skipsblog_posts. Re-add it as another translator inapps/api/scripts/import-laddr/translators.ts:BlogPost.Slug→slug(slugify-with-dedupe if invalid)BlogPost.Title→titleBlogPost.Body→bodyBlogPost.AuthorID→ resolve via the existingidMaps.personByLegacyBlogPost.Published(and similar) →postedAtlegacyIdso re-runs are idempotentSequencing
cutover-prepso existing migration paths stay valid through cutover.Spec updates needed
specs/deferred.md— update the "Blog (/blog) as a user-facing CMS" entry from "files in code repo" to "content-typed sheet, see this issue."specs/api/blog.md,specs/screens/blog-index.md,specs/screens/blog-detail.md.specs/data-model.md— addBlogPostentity.specs/behaviors/legacy-id-mapping.md— note the newBlogPost.legacyIdaxis.Out of scope: comments, reactions, the multi-author "posts under a topic" workflow — keep it as simple as the original deferral imagined.