AGENTS.md@layer/modules/blog · git:20261105.6462107 · 2026-11-05 · sha256 f1a5f5bda7c15d18
AGENTS.md@layer/modules/blog git:20261105.6462107A
Immutable. This exact content never changes and is served at /api/v1/blob/f1a5f5bda7c15d18.
# Blog Module — AI Agent Instructions
Git-based blog with render-time scheduling, plus the `foundry-blog` writer (CLI + MCP).
## File Map
```
layer/modules/blog/
├── index.ts # Module: pages, endpoints, guard, raw/prerender exclusions (off unless foundry.blog.enabled)
├── README.md # User documentation (setup, bin, MCP, leaks)
├── core/
│ ├── post.mjs / post.d.mts # PURE: postState, isLive, slugify, readMinutes, renderPost, parsePost, validatePostInput
│ ├── store.mjs # Node-only: files + git (list/get/upsert/schedule/unpublish)
│ └── mcp.mjs # Stdio MCP server over the store
├── runtime/
│ ├── types.ts
│ ├── composables/ # useBlogPosts, useBlogPost, useBlogCopy (site.yml `blog:`), useBlogSeo
│ ├── utils/meta.ts # PURE: postType, postLabels, cardMeta, longDate, readTimeLabel
│ ├── components/ # BlogPostHeader, BlogByline, BlogPostCards, BlogTypeFilter (imported);
│ │ # BlogPostAfter, BlogIndexAfter (registered at priority -1: sites override by name)
│ └── pages/ # BlogIndex.vue, BlogPost.vue (≤50 lines each)
└── server/
├── api/ # posts.get.ts, post.get.ts → /api/_foundry/blog/*
├── routes/rss.xml.get.ts # <prefix>/rss.xml (reuses rss/server/utils/rss-feed)
├── routes/raw.md.get.ts # /raw<prefix>/<slug>.md — live posts only (Nuxt Content's raw route skips blog)
├── middleware/content-guard.ts # 404 for outside requests to /__nuxt_content/blog/* (dump + query)
├── plugins/discovery.ts # llms.txt / llms-full.txt / sitemap filters
└── utils/posts.ts # queries + liveness (also used by /api/_foundry/content/page|surround)
layer/bin/foundry-blog.mjs # The bin (plain ESM — it runs from node_modules)
```
## Key Architecture
- **One liveness rule, in JS.** `isLive(row, new Date())` from `core/post.mjs` is the only
place "live" is decided — the server utils, content API, llms filter and sitemap all call it.
Filter the ROW a handler is about to return, never the request URL: Nitro routes many
spellings of one path (encoded, trailing slash) to the same handler.
Never filter `publishAt` in SQL and never at build time.
- **`core/post.mjs` stays plain ESM with no Node-only imports** — Nuxt imports it for
`isLive`; the bin imports it for everything. `store.mjs` and `mcp.mjs` are Node-only
and must never be imported from `runtime/` or `server/`.
- **The bin has no TypeScript** (Node does not strip types under `node_modules`).
- Pages read through `/api/_foundry/blog/*` (same reason as `useContentPage`), not
`queryCollection` in the page.
- The `article` layout is NOT reused: it is bound to the route→collection map, a string
`image`, a `date` field, and `surround` (which would name unpublished posts).
## How to Modify
- **New page copy or label:** add it to the `blog` group of `baseConfigSchema`
(`layer/content.collections.ts`) and `BlogCopy` (`runtime/types.ts`); unset must render
as before. Switches (not words) go in `BlogOptions` + `DEFAULTS` (`index.ts`).
- **New frontmatter field:** add to `baseBlogSchema` (`layer/content.collections.ts`),
`postInputSchema` + `KEY_ORDER` + `post.d.mts` (`core/post.mjs`), then use it in the pages.
- **New leak surface:** filter its rows through `findLivePost` / `queryLivePosts` /
`hiddenPostPaths` (`server/utils/posts.ts`) or add a hook to `plugins/discovery.ts`, and add
the URL to `playground/test/e2e/blog-liveness.e2e.spec.ts`.
- **New CLI command:** add it to `core/store.mjs`, the switch in `bin/foundry-blog.mjs`,
and a tool in `core/mcp.mjs`; extend `playground/test/unit/blog-{cli,mcp}.test.ts`.
## Design Principles
- Fail closed — a bad `publishAt` is a draft, an unreadable post is hidden.
- The file is the record — every write is one pathspec commit; never touch other paths.
- Scheduling is visibility, not secrecy (see the README's "Leaks closed").