Astro content collections 加草稿功能,教學通常停在第一步:
// src/content.config.ts
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
新增: draft: z.boolean().default(false),
}),
});
問題是:draft 只是一個欄位。它不會自己阻止任何頁面把草稿渲染出來。
真正的風險模型是這樣的——今天你有三個地方呼叫 getCollection('blog'):
文章頁、列表頁、RSS。三個都記得過濾,沒事。三個月後加了標籤頁、系列頁、
sitemap、JSON-LD 產生器,其中一個忘了,草稿的標題和網址就進了 RSS reader
和搜尋引擎快取。
「大家記得過濾」不是機制。下面是我把它寫成機制的三層。
第一層:唯一的取數入口
定義一個 wrapper,讓它成為整個 src/ 裡唯一允許直接讀 blog collection 的地方:
// src/lib/posts.ts — 唯一入口
import { getCollection } from 'astro:content';
import { validateRouteModel } from './route-model';
export async function getPublishedPosts() {
const posts = await getCollection('blog');
const taxonomy = await getCollection('taxonomy');
validateRouteModel(
posts,
taxonomy.map((entry) => entry.data),
);
return posts
.filter((post) => !post.data.draft)
.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() || a.id.localeCompare(b.id),
);
}
兩個細節值得說明:
- 開發與正式建置都以
!data.draft過濾。草稿預覽需要另外設計明確的本機入口, 不讓公開路由因執行模式而改變規則。validateRouteModel是本站自己的 validator, 負責先檢查文章 id、分類引用與系列順序;它不是 Astro 內建 API。 - 排序也放進來:日期由新到舊,同日以 id 排序。如果排序散落在各路由,之後改規則就會漏改。
所有消費者——頁面、RSS endpoint、sitemap、JSON-LD——一律 import 它:
// src/pages/rss.xml.js
import { getPublishedPosts } from '../lib/posts';
export async function GET(context) {
const posts = await getPublishedPosts();
// ...
}
第二層:AST 檢查,不是 grep
「唯一入口」的約定需要執行點。字串比對看起來夠用:
grep -rn "getCollection('blog')" src/ --include='*.ts' --include='*.astro'
但它擋不住 alias import:
import { getCollection as gc } from 'astro:content';
const posts = await gc('blog'); // grep 找不到這行
所以檢查必須走 AST。用 TypeScript compiler API 寫大概是這個形狀:
import ts from 'typescript';
const ALLOWLIST = new Set(['src/lib/posts.ts']);
function findViolations(file: string, source: ts.SourceFile): string[] {
const localNames = new Set<string>();
const hits: string[] = [];
const visit = (node: ts.Node): void => {
// 記下 astro:content 的 getCollection 綁到了哪些本地名字
if (
ts.isImportDeclaration(node) &&
node.moduleSpecifier.getText(source).includes('astro:content')
) {
node.importClause?.namedBindings?.forEachChild((el) => {
if (ts.isImportSpecifier(el)) {
const imported = (el.propertyName ?? el.name).text;
if (imported === 'getCollection') localNames.add(el.name.text);
}
});
}
// 對這些名字的呼叫,第一個引數若是 'blog' 字面值即違規
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
localNames.has(node.expression.text)
) {
const first = node.arguments[0];
if (first && ts.isStringLiteral(first) && first.text === 'blog') {
hits.push(`${file}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`);
}
}
node.forEachChild(visit);
};
visit(source);
return ALLOWLIST.has(file) ? [] : hits;
}
範圍必須是整個 src/**。只掃 src/pages/blog/** 會漏掉 rss.xml.js——
它不在那個目錄底下,但一樣輸出文章 URL、一樣受草稿隔離約束。
.astro 檔案的 frontmatter 是合法的 TypeScript,抽出來餵同一個 checker 即可。
上面的短例只示範 named import 與字面值呼叫。正式 lint 還必須處理 namespace import、 解構別名、re-export 與 dynamic import;本站對這些入口各有負向測試。 這是避免意外繞過的開發規則,不是對任意惡意程式碼建立的執行沙箱。
第三層:建置產物斷言
前兩層驗的是原始碼。最後一層驗產物——放一篇已知的草稿 fixture, 建置後斷言它的 URL 與標題不出現在任何輸出:
pnpm build
pnpm verify:artifacts
本站的驗證器會先確認建置清單與實際檔案存在,再掃描已知草稿的 id、標題與正文,
並比對文章、RSS 與 sitemap 集合。檔案缺失、讀取失敗和找到草稿都必須讓程序失敗;
單用反轉 grep 的退出碼,容易把讀取錯誤誤當成「沒有找到」。
這一層的價值在於它不依賴 wrapper 的內部實作。哪天有人重構掉 wrapper、 或者 Astro 改版讓過濾述詞語意變了,前兩層可能一起失效, 但「草稿字串不在產物裡」這個性質檢查仍然會抓到。
三層各擋什麼
| 層 | 擋的失效模式 | 擋不了的 |
|---|---|---|
| wrapper 唯一入口 | 新路由忘記過濾 | 有人繞過 wrapper |
| AST 檢查 | 繞過 wrapper(含 alias import) | wrapper 自身的邏輯錯誤 |
| 產物斷言 | 上面全部——包括 wrapper 寫錯 | 不在 fixture 覆蓋內的洩漏形狀 |
寫技術文章時我常想起一句話:泛型寫成 Array<T>、模板字串寫成 ${VAR},
在散文裡直接裸寫會被 MDX 當成 JSX 或表達式吃掉——所以它們全都得待在
行內程式碼或程式碼區塊裡。安全契約也一樣:待在「機制」裡的約定才算數,
待在「大家記得」裡的約定只是還沒失效而已。