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 或表達式吃掉——所以它們全都得待在 行內程式碼或程式碼區塊裡。安全契約也一樣:待在「機制」裡的約定才算數, 待在「大家記得」裡的約定只是還沒失效而已。

參考資料