記事を公開する前の自動チェックを、Astroのcontent collectionsの元ファイルを読むNodeスクリプトで作る。未来の日付、下書きのままの記事、表紙の未設定、seoTitleと説明文の長さ、下書きへの内部リンク、front matterの必須項目、テーマ別の入口の割り当て漏れ
Astroの
この
このgit archive で--online で
このブログで起きた、公開まわりのミス
起きたのは
| コミット |
起きたこと | 直した |
|---|---|---|
| a1351bd |
3本をdate と modified が |
2c9a364 |
| a9d74e7 |
2本をdate と modified が |
7fb662f |
| e2fae09 |
題はdraft: true のdate も |
b4e486cdraft: false にし、 |
このdraft だけでdraft: false にdatePublished に
どれも
点検する項目と、ビルドで止まるかどうか
点検の
| 項目 | 見つけ方 | 区分 | ビルドで |
|---|---|---|---|
| front matterの |
title・slug・date がdraft がcta がfaq が |
エラー | 止まるtitle: "" のような |
| 公開する |
draft: false で、date か modified が |
エラー | 止まらない |
| 更新日が |
modified < date |
注意 | 止まらない |
| 下 |
draft: true で、date が |
注意 | 止まらない |
| 公開する |
--publish にdraft: true、 |
エラー | 止まらない |
| 表紙の |
draft: false で icon が無い |
注意 | 止まらない |
| seoTitleと |
seoTitleが |
注意・情報 |
止まらない |
| 下書き・存在しない |
本文の |
エラー | 止まらない |
| テーマ別の |
src/data/hubs.ts を |
公開する |
公開するsrc/lib/hubs.ts)。 |
| 会社サイトへの |
--online のhttps://www.bundlyze.co.jp/ の |
エラー | 止まらない |
seoTitleの
ハブのdraft を
スクリプトの全文
Node.js 22.18以降なら、js-yaml を、node_modules からsrc/data/hubs.ts はimport できます
// prepublish-check.mjs 記事を公開する前の点検(Astro の content collections の元ファイルを読む)
// 使い方: node prepublish-check.mjs [リポジトリのルート] [--publish slugA,slugB] [--online] [--now 2026-10-05T12:00:00+09:00]
// --publish: 今回公開するつもりの記事。下書きのまま・見つからないならエラー
// --online : 公開する記事から張っている会社サイト(bundlyze.co.jp)のURLを HEAD で確かめる
// --now : 「いま」を固定して試す(日付の点検の確認用)
// 終了コード: エラーが1件でもあれば 1
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const args = process.argv.slice(2);
// 引数を取り出す(値を取るものは次の1つも取り除く)
const take = (name, withValue) => {
const i = args.indexOf(name);
if (i < 0) return undefined;
const [, v] = args.splice(i, withValue ? 2 : 1);
return withValue ? v : true;
};
const ONLINE = take('--online', false) === true;
const nowArg = take('--now', true);
const NOW = nowArg ? new Date(nowArg) : new Date();
if (Number.isNaN(NOW.getTime())) throw new Error(`--now の日時が読めない: ${nowArg}`);
const PUBLISH = (take('--publish', true) ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const ROOT = path.resolve(args[0] ?? '.');
// js-yaml は Astro の依存として入っている。リポジトリ側の node_modules から読む
const require = createRequire(path.join(ROOT, 'package.json'));
const yaml = require('js-yaml');
// ---- 決まり(src/content.config.ts と、このブログの書き方の目安) ----
const POSTS_DIR = path.join(ROOT, 'src/content/posts'); // glob: '*/index.md'
const CTA = ['system', 'website', 'ai', 'seo', 'ads', 'sns', 'cloud', 'strategy', 'bridal', 'fitness', 'learning', 'default', 'none'];
const RULES = { seoTitleMax: 32, descMin: 80, descMax: 120 }; // 自社の目安
const SITE_HOSTS = new Set(['www.yamayamabloglink.com', 'yamayamabloglink.com']);
// 記事以外のページ(src/pages と src/content/pages)。先頭の階層で見分ける
const NON_POST_TOP = new Set(['category', 'tag', 'page', 'tools', 'feed', 'about', 'contact', 'search', 'sitemap', 'privacy-policy', 'llms.txt', 'llms-full.txt', 'covers', 'images', 'wp-content']);
// ---- 結果の記録 ----
const results = [];
const add = (level, slug, item, detail) => results.push({ level, slug, item, detail });
// ---- 1. 記事を読む ----
const posts = [];
for (const dir of fs.readdirSync(POSTS_DIR).sort()) {
const file = path.join(POSTS_DIR, dir, 'index.md');
if (!fs.existsSync(file)) continue;
const src = fs.readFileSync(file, 'utf8');
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) {
add('error', dir, 'front matter', '先頭の --- で囲んだ front matter が見つからない');
continue;
}
let data;
try {
data = yaml.load(m[1]) ?? {};
} catch (e) {
add('error', dir, 'front matter', `YAMLとして読めない: ${e.message.split('\n')[0]}`);
continue;
}
posts.push({ dir, file, data, body: m[2], slug: data.slug ?? dir, draft: data.draft === true });
}
const bySlug = new Map(posts.map((p) => [String(p.slug), p]));
const toDate = (v) => {
if (v === undefined || v === null || v === '') return undefined;
const d = v instanceof Date ? v : new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
};
const fmt = (d) => d.toLocaleString('sv-SE', { timeZone: 'Asia/Tokyo' }).slice(0, 16);
// ---- 2. 1本ずつの点検 ----
const slugCount = new Map();
for (const p of posts) {
const d = p.data;
const s = String(p.slug);
slugCount.set(s, (slugCount.get(s) ?? 0) + 1);
// 必須項目と型(content.config.ts の schema と同じ条件)
if (typeof d.title !== 'string' || !d.title.trim()) add('error', s, '必須項目', 'title がない');
if (typeof d.slug !== 'string' || !d.slug.trim()) add('error', s, '必須項目', 'slug がない');
if (d.draft !== undefined && typeof d.draft !== 'boolean') add('error', s, '型', `draft が真偽値ではない(${JSON.stringify(d.draft)})`);
if (d.cta !== undefined && !CTA.includes(d.cta)) add('error', s, '型', `cta「${d.cta}」は決められた値にない`);
if (d.categories !== undefined && !Array.isArray(d.categories)) add('error', s, '型', 'categories が配列ではない');
if (d.faq !== undefined && (!Array.isArray(d.faq) || d.faq.some((f) => typeof f?.q !== 'string' || typeof f?.a !== 'string'))) {
add('error', s, '型', 'faq は q と a の組の配列にする');
}
const date = toDate(d.date);
const modified = toDate(d.modified);
if (date === undefined) add('error', s, '必須項目', 'date がない');
if (date === null) add('error', s, '型', `date を日時として読めない(${d.date})`);
if (modified === null) add('error', s, '型', `modified を日時として読めない(${d.modified})`);
// 日付:公開する記事の date・modified が未来になっていないか
if (!p.draft) {
if (date && date > NOW) add('error', s, '未来の日付', `date ${fmt(date)} が現在 ${fmt(NOW)} より後`);
if (modified && modified > NOW) add('error', s, '未来の日付', `modified ${fmt(modified)} が現在より後`);
}
if (date && modified && modified < date) add('warn', s, '日付の前後', `modified ${fmt(modified)} が date ${fmt(date)} より前`);
// 下書きのまま、公開予定の日時を過ぎていないか。過ぎていなくても、下書きは一覧に出す
if (p.draft && date && date <= NOW) add('warn', s, '下書きのまま', `date ${fmt(date)} を過ぎているのに draft: true`);
else if (p.draft) add('info', s, '下書き', `本番には出ない(date ${date ? fmt(date) : '未設定'})`);
// 公開する記事に専用の表紙(icon)があるか
if (!p.draft && !d.icon) add('warn', s, 'icon なし', 'draft: false なのに icon が未設定(カテゴリの既定の表紙になる)');
// 長さ(自社の目安)
const len = (x) => [...String(x ?? '')].length;
if (d.seoTitle !== undefined && len(d.seoTitle) > RULES.seoTitleMax) add('warn', s, 'seoTitle の長さ', `${len(d.seoTitle)}字(目安 ${RULES.seoTitleMax}字以内)`);
if (!d.description) add(p.draft ? 'warn' : 'error', s, 'description', 'description が空');
else if (len(d.description) < RULES.descMin || len(d.description) > RULES.descMax) {
add('info', s, 'description の長さ', `${len(d.description)}字(目安 ${RULES.descMin}〜${RULES.descMax}字)`);
}
}
for (const [s, n] of slugCount) if (n > 1) add('error', s, 'slug の重複', `${n}本の記事が同じ slug`);
// 今回公開するつもりの記事が、本当に draft: false になっているか
for (const s of PUBLISH) {
const p = bySlug.get(s);
if (!p) add('error', s, '公開する記事', 'その slug の記事がない');
else if (p.draft) add('error', s, '公開する記事', 'まだ draft: true(このままでは本番に出ない)');
}
// ---- 3. 本文の内部リンク:行き先が下書き・存在しない記事になっていないか ----
const linkRe = /\]\(\s*(?:https?:\/\/([^/\s)]+))?(\/[^)\s"#?]*)/g;
// コードの中の例(```〜```、~~~〜~~~、`〜`)はリンクではないので外す
const stripCode = (md) => md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '');
for (const p of posts) {
const seen = new Set();
for (const m of stripCode(p.body).matchAll(linkRe)) {
const host = m[1];
if (host && !SITE_HOSTS.has(host)) continue;
let pathname = m[2];
try {
pathname = decodeURI(pathname);
} catch {}
const seg = pathname.split('/').filter(Boolean);
if (seg.length === 0 || NON_POST_TOP.has(seg[0])) continue;
const target = seg[0];
if (seen.has(target) || target === p.slug) continue;
seen.add(target);
const t = bySlug.get(target);
if (!t) add('error', p.slug, '内部リンク', `/${target}/ という記事もページもない`);
else if (t.draft && !p.draft) add('error', p.slug, '内部リンク', `/${target}/ は下書き(本番では404)`);
else if (t.draft && p.draft) add('info', p.slug, '内部リンク', `/${target}/ も下書き。公開するときは先に(または同時に)公開する`);
}
}
// ---- 4. テーマ別の入口(src/data/hubs.ts)への割り当て ----
// Node 22.18 以降は .ts を型を消して読み込める(hubs.ts は型の宣言と配列だけ)
const hubsFile = path.join(ROOT, 'src/data/hubs.ts');
const { hubDefs } = fs.existsSync(hubsFile) ? await import(pathToFileURL(hubsFile).href) : { hubDefs: [] };
if (!fs.existsSync(hubsFile)) add('info', '-', 'ハブ', 'src/data/hubs.ts が無いので割り当ての点検は飛ばした');
const categories = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/data/categories.json'), 'utf8'));
const withDescendants = (slug) => {
const out = new Set([slug]);
let added = true;
while (added) {
added = false;
for (const c of categories) if (c.parent && out.has(c.parent) && !out.has(c.slug)) out.add(c.slug), (added = true);
}
return out;
};
for (const def of hubDefs) {
const inCat = withDescendants(def.category);
const assigned = new Map();
for (const sec of def.sections) {
for (const slug of sec.posts) {
if (assigned.has(slug)) add('error', slug, 'ハブ', `${def.category}: 「${assigned.get(slug)}」と「${sec.title}」の両方に入っている`);
assigned.set(slug, sec.title);
const p = bySlug.get(slug);
if (!p) add('error', slug, 'ハブ', `${def.category}「${sec.title}」に書かれた slug の記事がない`);
else if (!(p.data.categories ?? []).some((c) => inCat.has(c))) add('error', slug, 'ハブ', `${def.category} のカテゴリーの記事ではない`);
}
}
for (const p of posts) {
const business = (p.data.cta ?? 'none') !== 'none';
if (!business || assigned.has(p.slug)) continue;
if (!(p.data.categories ?? []).some((c) => inCat.has(c))) continue;
// 公開する記事の漏れはビルドが止まる。下書きは公開した瞬間に止まるので、先に知らせる
add(p.draft ? 'warn' : 'error', p.slug, 'ハブ', `${def.category} の事業者向けの記事なのに、どのテーマにも入っていない`);
}
}
// ---- 5. (任意)公開する記事から張っている会社サイトのURLが開けるか ----
if (ONLINE) {
const urls = new Map();
for (const p of posts.filter((x) => !x.draft)) {
for (const m of p.body.matchAll(/\]\((https:\/\/www\.bundlyze\.co\.jp\/[^)\s"]+)\)/g)) {
const u = m[1].split('#')[0];
if (!urls.has(u)) urls.set(u, []);
urls.get(u).push(p.slug);
}
}
for (const [u, from] of urls) {
let status;
try {
const res = await fetch(u, { method: 'HEAD', redirect: 'manual' });
status = res.status;
} catch (e) {
status = `接続できない(${e.cause?.code ?? e.message})`;
}
if (status !== 200) add('error', from.join(', '), '会社サイトのリンク', `${u} → ${status}`);
}
add('info', '-', '会社サイトのリンク', `${urls.size}件のURLを確かめた`);
}
// ---- 出力 ----
const order = { error: 0, warn: 1, info: 2 };
const label = { error: 'エラー', warn: '注意', info: '情報' };
results.sort((a, b) => order[a.level] - order[b.level] || a.item.localeCompare(b.item) || String(a.slug).localeCompare(String(b.slug)));
const drafts = posts.filter((p) => p.draft).length;
console.log(`記事 ${posts.length}本(公開 ${posts.length - drafts}・下書き ${drafts})/ 現在 ${fmt(NOW)}(日本時間)`);
for (const r of results) console.log(`[${label[r.level]}] ${r.item} | ${r.slug} | ${r.detail}`);
const count = (l) => results.filter((r) => r.level === l).length;
console.log(`---\nエラー ${count('error')}・注意 ${count('warn')}・情報 ${count('info')}`);
process.exitCode = count('error') > 0 ? 1 : 0;front matterをgetCollection ではなく、content.config.ts の glob({ pattern: '*/index.md', base: './src/content/posts' }) とastro:content はcontent.config.ts を
内部リンクの/no-such-page/ と /sitemap-index.xml を
このブログで流した結果
今の
$ node prepublish-check.mjs . --online
記事 185本(公開 183・下書き 2)/ 現在 2026-10-05 13:05(日本時間)
[注意] 下書きのまま | astro-content-prepublish-check-script | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | search-console-generative-ai-control-settings | date 2026-10-05 10:00 を過ぎているのに draft: true
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 会社サイトのリンク | - | 26件のURLを確かめた
---
エラー 0・注意 2・情報 2注意の--online の
過去のコミットの状態に戻して流すと、起きたミスは見つかったか
見つかりました。git archive で--now に、
# あるコミットの記事と設定だけを取り出し、そのときの時刻で流す
h=e2fae09
mkdir -p at-$h
git archive $h package.json src | tar -x -C at-$h
ln -s "$PWD/node_modules" at-$h/node_modules # js-yaml を読むため
node prepublish-check.mjs at-$h --now 2026-10-04T23:21:18+09:00 \
--publish google-fonts-render-blocking-lcp,utm-parameter-naming-audit-scriptコミット(--now) |
エラー | 注意 | 出たもの |
|---|---|---|---|
| a1351bd |
6 | 8 | 未来のdate と modified。icon なし、 |
| a9d74e7 |
4 | 1 | 未来のdate と modified。 |
| 5519be3 |
23 | 0 | 未来のmodified が |
| e2fae09 |
22 | 0 | 未来のmodified。 |
e2fae09 に--publish を |
24 | 0 | 上にdraft: true のまま |
| b4e486c |
22 | 0 | 下modified は |
a1351bdでは、
e2fae09の
記事 181本(公開 179・下書き 2)/ 現在 2026-10-04 23:21(日本時間)
[エラー] 公開する記事 | google-fonts-render-blocking-lcp | まだ draft: true(このままでは本番に出ない)
[エラー] 公開する記事 | utm-parameter-naming-audit-script | まだ draft: true(このままでは本番に出ない)
[エラー] 未来の日付 | 【stripe】next-js-15とprismaで実装するサブスクリプション機能 | modified 2026-10-04 23:30 が現在より後
[エラー] 未来の日付 | ai-meeting-minutes-recording-workflow | modified 2026-10-04 23:30 が現在より後
(同じ形の「未来の日付」の行が18行続く)
[エラー] 未来の日付 | wedding-venue-seo-area-search | modified 2026-10-04 23:30 が現在より後
[エラー] 未来の日付 | wordpress-to-headless-cms-migration-steps | modified 2026-10-04 23:30 が現在より後
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 下書き | google-fonts-render-blocking-lcp | 本番には出ない(date 2026-10-05 07:10)
[情報] 下書き | utm-parameter-naming-audit-script | 本番には出ない(date 2026-10-05 07:20)
---
エラー 24・注意 0・情報 3--publish を--publish で
気づいていなかった、22本の更新日時
5519be3は、modified を
会社サイトのコラムへのリンクが404だった下書き
10月4日に/column/reservation-system-before-choosing に/column/saas-or-custom に
この--online で
$ node prepublish-check.mjs col404 --online
記事 185本(公開 183・下書き 2)/ 現在 2026-10-05 13:10(日本時間)
[エラー] 会社サイトのリンク | supabase-rls-multi-tenant-reservations | https://www.bundlyze.co.jp/column/reservation-system-before-choosing → 404
[注意] 下書きのまま | astro-content-prepublish-check-script | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | search-console-generative-ai-control-settings | date 2026-10-05 10:00 を過ぎているのに draft: true
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 会社サイトのリンク | - | 27件のURLを確かめた
---
エラー 1・注意 2・情報 2ただし、--online がdraft: false のdraft を
わざと壊したコピーで、点検が働くかを確かめた
今の
| 壊した |
出たもの |
|---|---|
他のdraft: true にした |
リンクしている |
src/data/hubs.ts から、 |
「どの |
1本の cta を、lp にした |
型のエラー |
1本の title を |
必須項目の |
1本の icon を消した |
icon なしの |
記事 185本(公開 182・下書き 3)/ 現在 2026-10-05 12:00(日本時間)
[エラー] ハブ | wedding-venue-seo-area-search | likelove の事業者向けの記事なのに、どのテーマにも入っていない
[エラー] 内部リンク | llmo-aio-audit-script-build-html | /seo-audit-script-build-html/ は下書き(本番では404)
[エラー] 内部リンク | utm-parameter-naming-audit-script | /seo-audit-script-build-html/ は下書き(本番では404)
[エラー] 型 | php-array-loop | cta「lp」は決められた値にない
[エラー] 必須項目 | xampp-setup | title がない
[注意] icon なし | one-click-copy | draft: false なのに icon が未設定(カテゴリの既定の表紙になる)
[注意] 下書きのまま | astro-content-prepublish-check-script | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | search-console-generative-ai-control-settings | date 2026-10-05 10:00 を過ぎているのに draft: true
[注意] 下書きのまま | seo-audit-script-build-html | date 2026-10-04 22:11 を過ぎているのに draft: true
[情報] description の長さ | google-fonts-render-blocking-lcp | 122字(目安 80〜120字)
[情報] 内部リンク | astro-content-prepublish-check-script | /seo-audit-script-build-html/ も下書き。公開するときは先に(または同時に)公開する
---
エラー 5・注意 4・情報 2注意の/seo-audit-script-build-html/ に
リポジトリの中だけでは確かめられないこと
この
- 公開した
記事の URLが、 本番で 実際に 200を 返すか (デプロイが 終わったか どうか) - 表紙の
画像が 実際に 作られたか ( iconの名前が、 表紙を 作る スクリプトの 一覧に あるかまでは 見ていません) - 会社サイト以外の
外部リンクが 切れていないか ( --onlineは会社サイトの URLだけです) - titleや
descriptionが、 ビルド後の HTMLで 正しく 出ているか
最後の
デプロイが成功した直後に、新しい記事が404を返した
1つ目の
ファイルの
- デプロイの
後、 新しい 記事の URLに、 毎回 違う値の クエリを 付けて 開く (途中で 404が キャッシュされていても、 その 結果を 見ないため) - IndexNowで
検索エンジンに 送る 直前に、 もう 一度 200を 確かめる。 404の URLは 送らない
# デプロイの後と、IndexNow で送る直前に流す
for s in slugA slugB; do
curl -s -o /dev/null -w "%{http_code} $s\n" "https://www.yamayamabloglink.com/$s/?v=$(date +%s)"
done組み込み方と、止めるかどうかの決め方
このpackage.json の prebuild に
止めるprebuild でprebuild に--now は
更新を
動作確認した環境
確認日は
- macOS 26.6.2、
Node.js 22.23.2、 Astro 7.3.5、 js-yaml 4.3.2 (Astroの 依存と して 入っている もの) - 対象:この
ブログの リポジトリ (記事185本、 うち下 書き2本)と、 git archiveで取り出した a1351bd・a9d74e7・5519be3・e2fae09・b4e486c の 状態 --onlineのHEADは、 公開中の 記事から 張っている 会社サイトの URL 26件に 送った。 404の 確認は、 リンクを 当時の URLに 戻した コピー (27件)で 行った - デプロイ直後の
404は、 2026年10月4日 21:55ごろと 10月5日 9:35ごろに、 本番の URLを 開いて 見た もの
確かめていない
- Windowsでの
動作 (パスの 扱いは node:pathに任せていますが、 流していません) prebuildに組み込んだ 状態での、 Cloudflare Pagesの ビルド環境での 動作
参照した公式ドキュメント(確認日:2026年10月5日)
スクリプトの
- content collectionsの
schemaは Zodで 検証され、 合わない ファイルが あれば エラーに なる こと、 glob()で読むファイルの 決め方 → Astro Docs 「Content collections」 - Node.js 22.18.0から、
型を 取り除いて TypeScriptの ファイルを 実行する 機能が 既定で 有効に なった こと、 型を 取り除くだけでは 動かない 構文が ある こと → Node.js v22 Docs 「Modules: TypeScript」 - 未来の
日付を 指定しない こと、 表示される 日付と 構造化データの 日付を そろえる こと → Google 検索セントラル 「Google 検索の 検索結果に 」署名日を 追加する (英語版の 最終更新 2025-12-10、 日本語版 2026-02-20) git archiveで特定の コミットの ファイルを 取り出す方法 → Git Documentation 「git-archive」 fetchのredirect: 'manual'(リダイレクトを追わずに 応答を 受け取る) → MDN 「RequestInit: redirect」。 MDNの 説明は ブラウザでの 動きで、 ブラウザでは 中身を 伏せた 応答に なります。 Node.js 22の fetchでは、https://yamayamabloglink.com/に送ると、 追わずに 301と Locationを 受け取れる ことを 確かめました
よくある質問
Astroのビルドが通れば、公開してよい状態だと言えますか?
言えません。
日付が未来の記事を公開すると、何が困りますか?
Googleの
下書きの記事へのリンクは、ビルドで見つけられませんか?
この
「公開する」と書いたコミットで、記事が下書きのまま残るのを防ぐには?
公開する