blog
Full-stack personal blog — authoring, admin workflow, public pages, feeds (RSS/Atom/JSON), SEO, structured data, cros...
Dates
- Created
- Not recorded
- Last updated
- Not recorded
Document Metadata
- title: Blog
- description: Full-stack personal blog — authoring, admin workflow, public pages, feeds (RSS/Atom/JSON), SEO, structured data, cross-content-type linking
- status: stable
- lastUpdated: "2026-03-25 11:18 ET (America/New_York)"
- owner: Product/Engineering
Blog The blog is a branded publishing system integrated into the SMC Directory. It provi
Blog
The blog is a branded publishing system integrated into the SMC Directory. It provides the full authoring and publishing workflow for Markdown posts, public feeds, SEO/structured-data support, and cross-content-type linking with the resource directory. It also now includes a first editorial-governance slice for review-gated posts, selective byline mode, and publish-time review enforcement in admin flows.
URL Structure
| Route | Purpose |
|---|---|
/blog | Blog index — paginated grid of published posts with tag sidebar |
/blog/[slug] | Individual post detail — full article with cover image, ToC, prose body, related resources, prev/next nav |
/blog/tag/[slug] | Tag browse — filtered posts for a specific tag |
/blog/feed.xml | RSS 2.0 feed — all published posts, WebSub hub |
/blog/atom.xml | Atom 1.0 feed — all published posts, WebSub hub |
/blog/feed.json | JSON Feed 1.1 — all published posts |
/blog/sitemap.xml | Blog sitemap — index, post, and tag URLs |
/admin/blog | Admin post list — all statuses with tabs and search |
/admin/blog/reviews | Admin editorial drafting and review queue — review briefs, generate AI drafts, then capture approval/revision/rejection decisions |
/admin/blog/reviews/[id] | Brief review detail — inspect brief contents, evidence, and draft scaffold before generating a blog draft |
/admin/blog/new | Admin new post editor |
/admin/blog/[id] | Admin post editor (edit existing) |
/admin/blog/tags | Admin tag management |
Database Schema
The core blog shape is defined in db/neon/schema.ts and was introduced by db/migrations/neon/0116_blog_tables.sql, then extended by the 3041 review-gate tranche in 0121_editorial_review_controls_phase1.sql and 0122_blog_publish_review_gate_phase1.sql.
blog_posts
Primary content table. Stores Markdown source and pre-rendered HTML.
| Column | Type | Notes |
|---|---|---|
id | uuid | PK, auto-generated |
slug | text | Unique, URL-safe identifier |
title | text | Required |
subtitle | text | Optional deck line |
body | text | Markdown source |
body_html | text | Pre-rendered HTML (generated at save time) |
excerpt | text | Auto-derived from body if not provided |
cover_image_url | text | Hero/OG image URL |
cover_image_alt | text | Alt text for cover image |
status | text | draft · published · scheduled · archived |
published_at | timestamptz | Set when first published |
scheduled_for | timestamptz | Future publish timestamp |
reading_time_minutes | integer | Computed from word count |
meta_title | text | SEO title override |
meta_description | text | SEO description override |
og_image_url | text | OpenGraph image override |
sort_order | integer | Manual ordering (default 0) |
is_featured | boolean | Pin to homepage/top of index |
requires_review_gate | boolean | Enables publish-time review enforcement |
review_state | text | draft_intake_pending · in_review · needs_revision · approved_for_publish · rejected |
decision_reason_code | text | Reviewer-provided reason code for gated decisions |
byline_mode | text | organization · maggie_named |
review_decision_id | uuid | Optional link to editorial_review_decisions.id |
created_at | timestamptz | Auto-set |
updated_at | timestamptz | Auto-set |
Indexes: blog_posts_slug_key (unique), blog_posts_status_published_idx (status + published_at DESC), blog_posts_featured_idx, blog_posts_review_gate_state_idx, blog_posts_review_decision_idx.
blog_tags
| Column | Type | Notes |
|---|---|---|
id | uuid | PK |
slug | text | Unique |
name | text | Display name |
description | text | Optional |
created_at | timestamptz | Auto-set |
blog_post_tags (bridge)
| Column | Type | Notes |
|---|---|---|
post_id | uuid | FK → blog_posts, cascade delete |
tag_id | uuid | FK → blog_tags, cascade delete |
created_at | timestamptz | Auto-set |
Primary key: composite (post_id, tag_id).
Linked Review Decisions
Review-gated blog posts can point at editorial_review_decisions via blog_posts.review_decision_id.
The decision record stores:
review_statereviewer_idreview_decisiondecision_reason_codereview_notesrevision_request_summaryartifact_audit_signaturebyline_mode
Post Lifecycle
┌─────────┐ publish ┌───────────┐
│ DRAFT │ ───────────────→ │ PUBLISHED │
└─────────┘ └───────────┘
│ ↑ │ ↑
│ │ unpublish │ │
│ └────────────────────────┘ │
│ │
│ schedule ┌───────────┐ │
└──────────────→│ SCHEDULED │──────┘
└───────────┘ (auto-promoted
│ at query time)
│
┌───────────┐
│ ARCHIVED │
└───────────┘
- Draft → default state for new posts. Not publicly visible.
- Published → visible on
/blog, homepage, RSS, sitemap.published_atis set once on first publish and preserved through edits. - Scheduled →
scheduled_fortimestamp set. Automatically promoted topublishedat query time whenscheduled_for ≤ now()(lazy promotion ingetPublishedBlogPosts). - Archived → soft-removed from public view. Still accessible in admin.
Admin Workflow
Creating a Post
- Navigate to
/admin/blog→ click "New Post". - Fill in title (required) and body (Markdown). Slug auto-generates from title but is editable.
- Optionally add: subtitle, excerpt (auto-derived if blank), cover image via upload or URL, tags, SEO overrides (meta title, meta description), and review-gate metadata.
- Click "Save Draft" to create as draft, or "Publish" to go live immediately.
Editing a Post
- From
/admin/blog, click a post title to open the editor. - The editor shows the post form plus a preview toggle for rendered Markdown.
- Changes to the body auto-update the preview.
- Save updates the post, re-renders the Markdown to HTML, recomputes reading time and excerpt.
Publishing Flow
- Publish now: Click "Publish" on a draft. Sets
status=publishedandpublished_at=now(). - Schedule: Select a future date/time and click "Schedule". Sets
status=scheduledandscheduled_for. - Unpublish: Reverts a published post to draft.
published_atis preserved for re-publish. - Archive: Soft-removes from public view. Can be unarchived by publishing again.
Review-Gated Publishing
- Posts can opt into
requires_review_gate. - Review-gated posts expose admin controls for
review_state,byline_mode,decision_reason_code, and optionalreview_decision_id. publishBlogPost,createBlogPost, andupdateBlogPostall enforce the same gate whenever a post resolves topublished.- A review-gated post cannot publish unless
review_state = approved_for_publish. byline_mode = maggie_namedadditionally requires a non-emptydecision_reason_code./admin/blogincludes aReview Queuetab for gated posts that are not yet approved for publish./admin/blog/reviewsprovides the dedicated reviewer workflow for linkededitorial_review_decisions:- treat
draft_intake_pendingrows as brief-review intake first, not human review first - review a dedicated brief detail page before generating a blog draft
- generate an AI-written blog draft from the approved brief instead of treating the brief itself as the editable post body
- assign or reassign human reviewer ownership once a draft is actually being reviewed
- capture
approved_for_publish,needs_revision, andrejectedoutcomes - keep linked
blog_posts.review_state,decision_reason_code, andbyline_modesynchronized when the decision row changes - separate open queue items from completed history so already-approved decisions remain visible without pretending they are still actionable
- intake rows now show brief detail metadata inline, including problem statement, recommendation count, outline count, and evidence-reference count
- queue rows now open
/admin/blog/reviews/[id]for dedicated brief review - approving the brief can queue a real AI-generated
blog_postsdraft, and the operator then continues review from the normal/admin/blogdrafts list /admin/blog/new?reviewDecisionId=<id>remains available as a manual fallback, but it now opens with an empty article body plus read-only structured draft notes derived frombuildEditorialDraftArtifact(...)
- treat
- Blog admin navigation should now come from the admin sidebar rather than page-level shortcut bars. The remaining page header control on
/admin/blogis the action-orientedNew Postbutton.
Tag Management
Accessible at /admin/blog/tags. Supports:
- Create tags with name and optional description (slug auto-generated).
- Edit tag name/description.
- Delete tags (automatically removed from all posts via cascade).
- Tags with zero posts are still shown in admin but hidden from the public sidebar.
Server Actions
Public Actions (app/actions/blog.ts)
All public actions only return posts where status = 'published' and published_at ≤ now(). They gracefully return empty results if the database is unavailable.
| Action | Purpose |
|---|---|
getPublishedBlogPosts(page, limit) | Paginated published posts with tags. Also runs lazy scheduled-post promotion. |
getBlogPostBySlug(slug) | Single post by slug with tags |
getBlogPostsByTag(tagSlug, page, limit) | Posts filtered by tag slug |
getFeaturedBlogPosts(limit) | Featured published posts for homepage |
getRecentBlogPosts(limit) | Most recent posts for widgets |
getAdjacentPosts(publishedAt) | Previous/next posts for detail page navigation |
getBlogTags() | All tags with post counts (only tags with posts shown publicly) |
getRelatedBlogPosts(postId, tagIds, limit) | Posts sharing the same tags as a given post (for "Related Posts" section) |
getBlogPostsForResource(category, tags, limit) | Blog posts whose tags match a resource's category/tags (for resource detail page) |
searchBlogPosts(query) | Full-text search across title, subtitle, excerpt, and tag names |
Admin Actions (app/actions/blog-admin.ts)
All admin actions require authenticated admin session (assertAdmin()). Redirects to login/home if unauthorized.
| Action | Purpose |
|---|---|
createBlogPost(data) | Create new post (defaults to draft); enforces review gate if created directly as published |
updateBlogPost(id, data) | Update post fields, re-render HTML, and enforce review gate when status resolves to published |
publishBlogPost(id) | Set published, preserve original published_at, and block publish when review-gate invariants fail |
unpublishBlogPost(id) | Revert to draft |
archiveBlogPost(id) | Soft-archive |
scheduleBlogPost(id, scheduledFor) | Set scheduled status with future timestamp |
deleteBlogPost(id) | Hard delete |
createBlogTag(name, description?) | Create tag |
updateBlogTag(id, data) | Edit tag name/description |
deleteBlogTag(id) | Delete tag (cascades from posts) |
getAdminBlogPosts(status?) | List posts for admin (any status, optional filter) |
getAdminBlogPost(id) | Single post for admin editing (includes tag IDs) |
getAdminBlogTags() | All tags for admin UI |
Review Admin Actions (`app
...[truncated for intake]
Provenance
- Source file:
DOCS/features/blog.md - Source URL: https://github.com/maggielerman/smc-directory/blob/main/DOCS/features/blog.md