1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

@astrojs/sitemap で MDX の updatedDate を lastmod に流す最小実装

1
Posted at

Astro 公式の @astrojs/sitemap は便利ですが、Content Collections の MDX frontmatter にある updatedDate を読んでくれません。

そのままだと、生成される sitemap.xml の各エントリの lastmod がビルド時刻になってしまいます。検索エンジンには「全記事が毎ビルド更新されている」と見え、鮮度シグナルが壊れます。

この記事では、astro.config.mjs の中で MDX を直接走査して updatedDate を抽出し、@astrojs/sitemapserialize で各 URL に流し込む実装を、最小構成で紹介します。

全体像

2 つの作業をまとめてやります。

  1. MDX から lastmod マップを作る: astro.config.mjs の top-level で MDX を fs.readdir して、パスごとに updatedDate ?? pubDate を持つ Map を作る
  2. sitemap に流す: @astrojs/sitemapserialize でマップを lookup して item.lastmod に詰める
  3. paginated noindex を除外: filter/blog/<cat>/<N>/ のような noindex ページを sitemap から落とす

最終的な astro.config.mjs の構成は次の通りです。

ステップ 1: lastmod マップを作る

astro.config.mjs の中に async 関数を置き、結果を top-level await で blogLastmod 定数に入れます。

import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";

async function buildBlogLastmodMap() {
  const map = new Map();
  for (const lang of ["en", "ja"]) {
    const dir = join(process.cwd(), "src", "content", "blog", lang);
    let files = [];
    try {
      files = await readdir(dir);
    } catch {
      continue;
    }
    for (const file of files) {
      if (!file.endsWith(".mdx") && !file.endsWith(".md")) continue;
      const slug = file.replace(/\.(mdx|md)$/, "");
      const raw = await readFile(join(dir, file), "utf8");
      const fm = /^---\n([\s\S]*?)\n---/.exec(raw);
      if (!fm) continue;
      const front = fm[1];
      if (/^draft:\s*true/m.test(front)) continue; // 下書きは除外
      const updated = /^updatedDate:\s*(\S+)/m.exec(front);
      const pub = /^pubDate:\s*(\S+)/m.exec(front);
      const dateStr = (updated && updated[1]) || (pub && pub[1]);
      if (!dateStr) continue;
      const d = new Date(dateStr);
      if (Number.isNaN(d.getTime())) continue;
      const path = lang === "ja" ? `/ja/blog/${slug}/` : `/blog/${slug}/`;
      map.set(path, d.toISOString());
    }
  }
  return map;
}

const blogLastmod = await buildBlogLastmodMap();

getCollection("blog") を使わずに fs.readdir + regex で読んでいるのは、astro.config.mjs は Content Collections の loader より先に評価される ためです。config 評価時は API が初期化されていません。

frontmatter で必要なのは updatedDatepubDate だけなので、軽量 regex で十分です。

ステップ 2: serialize で sitemap に流す

@astrojs/sitemapserialize オプションで、各 URL エントリにマップから lookup した lastmod を入れます。

import sitemap from "@astrojs/sitemap";

export default defineConfig({
  // ...
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: "en",
        locales: { en: "en", ja: "ja" },
      },
      serialize(item) {
        const url = new URL(item.url);
        // /ja/ プレフィクスを剥がして path 種別で分岐
        const pathname = url.pathname.replace(/^\/ja\//, "/").replace(/^\/ja$/, "/");
        if (pathname === "/") {
          item.changefreq = "daily";
          item.priority = 1.0;
        } else if (pathname.startsWith("/blog/")) {
          item.changefreq = "monthly";
          item.priority = 0.7;
          // lookup には元の url.pathname(/ja/ 含む)を使う
          const lastmod = blogLastmod.get(url.pathname);
          if (lastmod) item.lastmod = lastmod;
        } else {
          item.changefreq = "monthly";
          item.priority = 0.5;
        }
        return item;
      },
    }),
  ],
});

changefreq / priority を一緒にセットしているのは、パス種別ごとに整合の取れた値を出すためです。priority は Google が「無視している」と公言していますが、Bing と AI クローラーは引き続き読みに来るので、揃えておく方針です。

ステップ 3: paginated noindex を filter で除外

/blog/build/2/ /blog/reviews/3/ のような 2 ページ目以降は <meta name="robots" content="noindex, follow"> を返している、という構成になっているサイトはよくあります。

このとき、sitemap で 2 ページ目以降を送ってしまうと mixed signal になります。「sitemap に載せた = インデックスしてほしい」と「meta robots = noindex」が同時に立つので、Google / Bing 双方で SEO の品質シグナルとして減点される可能性があります。

sitemap({
  filter: (page) => {
    if (page.endsWith("/404/") || page.endsWith("/404")) return false;
    if (/\/blog\/(build|reviews)\/\d+\/?$/.test(new URL(page).pathname)) return false;
    return true;
  },
  // ...
});

paginated を noindex にする運用と sitemap 除外は、セットで考える必要があります。

落とし穴

実装で踏みかけたところをまとめます。

  • top-level await: astro.config.mjsawait するには ESM 評価が前提です。Astro 5 は標準で OK ですが、.cjs 構成では動きません
  • draft: true 除外: lastmod Map 構築段階で draft をスキップしないと、下書き URL がサイトマップに乗ります
  • regex の緩さ: /^updatedDate:\s*(\S+)/m は単純行を想定しています。YAML 引用符 "..." を使うときは (\S+)"2026-05-25" を丸ごと捕捉するので、new Date() がパースできるかは要確認
  • 言語別フォルダの統合: enja を別ループで走らせて 1 つの Map に統合します。キーは /blog/<slug>//ja/blog/<slug>/ を別物として持つ
  • updatedDate の運用: 実装を整えても、注記追加レベルの編集で updatedDate を打ち替えると信頼度が落ちます。「実質改稿のときだけ更新」ルールとセットで運用するのが本筋です

実運用での updatedDate の更新基準、Zod schema 側との連携、汎用化して @astrojs/sitemap に PR を出すかの判断、Aulvem 本家にまとめています → sitemap の lastmod を MDX frontmatter から自分で差し込む — Aulvem の Astro 公式統合カスタマイズ

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?