💫

既存のMarkdown文書群を30分でWebサイト化!Astro+Starlightの超実践ガイド

に公開

この記事について

この記事の前半では、AstroとStarlightを使って既存のMarkdown文書をさくっとWebサイト化する手順を紹介します。記事の後半ではこれを達成するために必要な処理を実装したスクリプトの仕様を解説します。

Astro+Starlightでつくるドキュメントサイト

解決したい課題

生成AIが全盛の近年では、LLMに如何に精度の高いコンテキストを提供できるかが鍵であるということが認知されてきたため、そのLLMが扱いやすい形式のドキュメントが増えてきていますし、今後増々増えることが予想されます。その代表格がMarkdown形式のドキュメントではないでしょうか?

Markdown文書は比較的気軽に作成できるものの、それを整形された状態で閲覧するにはMarkdownに対応したビューアーが必要です。しかし勤務場所によっては業務で使用するPCへのビューアーやエディタのインストールの制限が厳しく(私の現在の勤務先はまさしくそうです)、せっかく蓄積されつつあるMarkdown形式のドキュメントを活用や共有することができないということも発生しています。

解決方法

今回紹介するAstro + Starlightであれば、既存のMarkdown文書群を活用してドキュメントサイトとして簡単に公開できますのでその手順を紹介したいと思います。
また、既存のMarkdown文書は以下のような特徴を持つことが多いと思われます。

  1. ファイル名が日本語になっている
  2. ファイル内に他のMarkdown文書へのリンクが記載されているがそのリンク名も日本語
  3. これらのファイルがネストしたディレクトリ配下に多数配置されている
  4. mermaidブロックが存在する

後述のoptimize-md-for-sl.cjsでは1と2と3の課題を解決します。4については別の記事で対処方法を紹介したいと思います。

Astro + Starlightとは

Astro

MPA(Multi Page Application)を構築するのに最適化されたフロントエンドフレームワークです。Reactなどと同様にコンポーネント指向のためUIの構築を効率的に行うことができる一方、HTMLに近い構文なので学習コストが低いです。また必要に応じてReact等のコンポーネントも動作させることができます。
Astroの最大の特徴は「Islandsアーキテクチャ」で、不要なJavaScriptを自動的に排除し、必要な部分のみインタラクティブにします。これによりデフォルトで高速なWebサイトを構築できます。ブログやマーケティングサイト、eコマースなどコンテンツ駆動のWebサイトに特に適しており、SEOに優れた高パフォーマンスなサイトを開発者レベルを問わず作成できます。
https://astro.build/

Starlight

ドキュメントサイトを簡単に構築することに特化したAstro公式のフレームワークです(2023年5月に初回リリース、2025年6月にローンチ2周年を迎えました)。StarlightはAstroのインテグレーション(機能追加というイメージ)なので、Astroプロジェクトを作成するときのオプションから選択することにより使用することができます。主な特徴としては以下のようなものがあります。

  • md(Markdown)やmdx(Markdown + JSX)を所定のフォルダに置くだけでHTMLページを生成、ホストできる
  • SideBar、TableOfContents、検索ウィンドウ、ダークモード、多言語対応などが自動生成される
  • TypeScriptの型安全性を活かしたフロントマターのバリデーション機能を内蔵
  • Pagefindによる高速なサイト内検索とSEO最適化がデフォルトで組み込まれており、コンテンツ作成に集中できる設計

https://starlight.astro.build/ja/

ドキュメントサイトを最短で構築する手順

ここでは、既存のMarkdown文書を使ってドキュメントサイトとして構築する手順を紹介します。
前提として、nodeやnpmなどのツールはすでに作業PCにインストールされていることとします。

npm(またはpnpmやyarnでも可)で新しいAstroプロジェクトを作成します。

npm create astro@latest
> npx
> create-astro

 astro   Launch sequence initiated.
   dir   Where should we create your new project?
         astro-starlight-demo

Astroプロジェクト作成ウィザードで、どのテンプレートを使用するかを聞かれるので、Use docs (Starlight) templateを選択します。

  tmpl   How would you like to start your new project?
         — A basic, helpful starter project 
         — Use blog template 
         > Use docs (Starlight) template 
         — Use minimal (empty) template 

その他の質問に回答します

  deps   Install dependencies?
         Yes
   git   Initialize a new git repository?
         Yes

初期化が完了すると、このようなディレクトリ構成のAstroプロジェクトが作成されます。

C:\USERS\YOU\DEVELOPS\ASTRO-STARLIGHT-DEMO
│  .gitignore
│  astro.config.mjs
│  package-lock.json
│  package.json
│  README.md
│  tsconfig.json
├─public
│      favicon.svg
└─src
    │  content.config.ts
    ├─assets
    │      houston.webp
    └─content
        └─docs
            │  index.mdx
            ├─guides
            │       example.md
            └─reference
                    example.md
*以下省略

既存のMarkdown文書を用意します。

今回配置したサンプルのMarkdown文書名とディレクトリ
├─Eコマースシステム
│  ├─仕様
│  │  ├─基本設計
│  │  │      システム構成解説-システム構成図.md
│  │  │      基本設計書-画面設計.md
│  │  │
│  │  └─詳細設計
│  │          API仕様書_v1_5.md
│  │          Data-Model_01.md
│  │
│  ├─手順
│  │      Course_A_Outline.md
│  │      Docker-Tools-Commands.md
│  │      環境構築手順Mac・UTM版.md
│  │
│  ├─要件
│  │      システム要件定義書_ver2.md
│  │
│  └─設計
│      ├─DB設計
│      │      インフラ構築手順書.md
│      │
│      └─UI設計
│              UI設計詳細_モバイル版.md

ちなみに、このサンプルドキュメント群は以下のようなプロンプトを使ってClaudeCode + Claude4で作成しました。

claude4に与えたプロンプト例

/mnt/c/you/delis/develops/OFP_doc_viewer_app/optimize-md-for-sl仕様書.md
で説明しているスクリプトのテストのためのテストデータを作成してください。
テストデータは以下の要件とします。

  1. Markdown文書を10個作成する
  2. Markdown文書のファイル名は日本語にする
  3. ファイル名の日本語はoptimize-md-for-sl仕様書.mdの対応ケースに記載のケースを含んだものとする
  4. 生成するMarkdown文書の中には、生成したファイル名とディレクトリ名を指すリンクを記載する
  5. アンカーも含んだリンクも生成すること
  6. Markdown文書の本文は、架空のシステム開発案件に関する環境構築手順、要件、仕様、設計の内容を含む。
  7. 仕様、設計の文書には自然文だけではなくMermaid記法で表したUMLを一つ以上入れること
  8. 手順、要件、仕様、設計ごとにディレクトリを分けて文書を作成する。仕様、設計に関しては2階層以上ネストしたディレクトリにする

以上の要件を満たす文書を
/mnt/c/Users/you/develops/OFP_doc_viewer_app/testdata
ディレクトリの下に作成してください。

既存のMarkdown文書群または新規作成したMarkdown文書を以下のディレクトリに配置します。

cp Eコマースシステム src/content/docs

src/content/docsディレクトリ配下に配置されたMarkdown文書がStarlightによって表示対象ページと扱われます。

Starlightで表示できるようにするために既存のMarkdown文書を加工するスクリプトoptimize-md-for-sl.cjs(仕様は後述)をプロジェクトディレクトリに配置します。

cd C:\USERS\YOU\DEVELOPS\ASTRO-STARLIGHT-DEMO
cp optimize-md-for-sl.cjs .

optimize-md-for-sl.cjsを実行します。

### 引数として処理対象としたいディレクトリを指定(再帰的に処理するので配下のサブディレクトリも対象となる)
node optimize-md-for-sl src/content/docs

実行すると、optimize-md-for-sl.cjsは標準出力に以下のようなログを出力します。

見つかったMarkdownファイル: 12件

処理中: example.md
  ファイル名正規化: example.md → Example.md
  ファイル名: 変更完了
  frontmatter: 存在します
  既存のtitle: "Example Guide"
  ファイル名ベースのtitle: "Example"
  title更新: スキップ(既存のtitleを保持)
  リンク修正: 修正対象なし
  ファイル内容: 変更なし

~~~途中省略~~~

処理中: UI設計詳細_モバイル版.md
  ファイル名: 変更不要
  frontmatter: 存在しません
  生成されたtitle: "UI設計詳細_モバイル版"
  生成されたslug: "UI設計詳細_モバイル版"
  frontmatter: 追加しました
  リンク修正: [基本設計書-画面設計.md](../../仕様/基本設計/基本設計書-画面設計.md#UI/UXガイドライン) → [基本設計書-画面設計.md](/基本設計書-画面設計/#UI/UXガイドライン)
  リンク修正: [システム要件定義書_ver2.md](../../要件/システム要件定義書_ver2.md) → [システム要件定義書_ver2.md](/システム要件定義書_ver2/)
  リンク修正: [Course A Outline.md](../../手順/Course A Outline.md) → [Course A Outline.md](/Course A Outline/)
  リンク修正: [API仕様書_v1 5.md](../../仕様/詳細設計/API仕様書_v1 5.md#商品API) → [API仕様書_v1 5.md](/API仕様書_v1 5/#商品API)
  リンク修正: [インフラ構築手順書.md](../DB設計/インフラ構築手順書.md#バックアップ・復旧) → [インフラ構築手順書.md](/インフラ構築手順書/#バックアップ・復旧)
  ファイル内容: 更新済み

処理結果:
- 処理したファイル数: 12
- ファイル名変更: 6
- frontmatter追加: 10
- title更新: 0
- リンク修正: 10
- スキップ: 2

すべての処理が完了しました!

実行結果として処理対象ファイルのコードの先頭に以下のような frontmatter (先頭の---で囲われた行)が付与されます。

sample.md
---
title: システム要件定義書_ver2
slug: システム要件定義書_ver2
description: Githubに保管されていたドキュメントを表示しています。
---

# システム要件定義書_ver2

## プロジェクト概要
ECサイト構築プロジェクトの要件定義書(第2版)です。

もしドキュメント群の構成をSideBarに反映したい場合は、astro.config.mjsを以下のように編集します。

参考:サイドバーのナビゲーション

astro.config.mjs
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';

// https://astro.build/config
export default defineConfig({
	integrations: [
		starlight({
			title: 'My Docs',
			social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/withastro/starlight' }],
			sidebar: [
				{
					label: 'Guides',
					autogenerate: { directory: 'guides' },
				},
				{
					label: 'Reference',
					autogenerate: { directory: 'reference' },
				},
				{
					label: 'Eコマースシステム',
					items: [
						{
							label: '要件',
							items: [
								{ label: 'システム要件定義書_ver2', link: '/システム要件定義書_ver2/' },
							],
						},
						{
							label: '仕様',
							items: [
								{
									label: '基本設計',
									items: [
										{ label: 'システム構成解説-システム構成図', link: '/システム構成解説-システム構成図/' },
										{ label: '基本設計書-画面設計', link: '/基本設計書-画面設計/' },
									],
								},
								{
									label: '詳細設計',
									items: [
										{ label: 'API仕様書_v1_5', link: '/API仕様書_v1_5/' },
										{ label: 'Data-Model_01', link: '/Data-Model_01/' },
									],
								},
							],
						},
						{
							label: '設計',
							items: [
								{
									label: 'DB設計',
									items: [
										{ label: 'インフラ構築手順書', link: '/インフラ構築手順書/' },
									],
								},
								{
									label: 'UI設計',
									items: [
										{ label: 'UI設計詳細_モバイル版', link: '/UI設計詳細_モバイル版/' },
									],
								},
							],
						},
						{
							label: '手順',
							items: [
								{ label: 'Course_A_Outline', link: '/Course_A_Outline/' },
								{ label: 'Docker-Tools-Commands', link: '/Docker-Tools-Commands/' },
								{ label: '環境構築手順Mac・UTM版', link: '/環境構築手順Mac・UTM版/' },
							],
						},
					],
				},
			],
		}),
	],
});

src/content/docs直下にホームページとなるindex.mdxを用意します。
今回はデフォルトのindex.mdxを流用しましたが、ここにSideBarを表示するためにtemplate: splashという設定をコメントアウトしています。
参考:フロントマターのフィールド

index.mdx(のfrontmatter部分を抜粋)
---
title: Welcome to Starlight
description: Get started building your docs site with Starlight.
## template: splash
hero:
  tagline: Congrats on setting up a new Starlight project!
  image:
    file: ../../assets/houston.webp
  actions:
    - text: Example Guide
      link: /guides/example/
      icon: right-arrow
    - text: Read the Starlight docs
      link: https://starlight.astro.build
      icon: external
      variant: minimal
---

ソースコードをビルドします(この段階でいったんnpm run devでテスト起動しても可)

npm run build

以下のようにビルドされ、HTMLが生成されます

> astro-starlight-demo@0.0.1 build
> astro build

18:07:49 [content] Syncing content
18:07:49 [content] Synced content
18:07:49 [types] Generated 1.62s
18:07:49 [build] output: "static"
18:07:49 [build] mode: "static"
18:07:49 [build] directory: C:\Users\delis\develops\astro-starlight-demo\dist\
18:07:49 [build] Collecting build info...
18:07:49 [build] ✓ Completed in 1.80s.
18:07:49 [build] Building static entrypoints...
18:07:51 [vite] ✓ built in 1.08s
18:07:51 [build] ✓ Completed in 1.11s.

 building client (vite) 
18:07:51 [vite] ✓ 17 modules transformed.
18:07:51 [vite] dist/_astro/ec.8zarh.js                                                              2.42 kB
18:07:51 [vite] dist/_astro/ec.v4551.css                                                            18.22 kB │ gzip:  3.96 kB
18:07:51 [vite] dist/_astro/MobileTableOfContents.astro_astro_type_script_index_0_lang.C181hMzK.js   0.67 kB │ gzip:  0.40 kB
18:07:51 [vite] dist/_astro/TableOfContents.astro_astro_type_script_index_0_lang.CKWWgpjV.js         1.67 kB │ gzip:  0.86 kB
18:07:51 [vite] dist/_astro/page.7qqag-5g.js                                                         2.17 kB │ gzip:  0.97 kB
18:07:51 [vite] dist/_astro/Search.astro_astro_type_script_index_0_lang.DMZ5WJ-J.js                  2.69 kB │ gzip:  1.38 kB
18:07:51 [vite] dist/_astro/ui-core.Ft0Z9wO7.js                                                     68.07 kB │ gzip: 21.71 kB
18:07:51 [vite] ✓ built in 134ms

 generating static routes 
18:07:51 ▶ @astrojs/starlight/routes/static/404.astro
18:07:51   └─ /404.htmlEntry docs → 404 was not found.
 (+15ms) 
18:07:51 ▶ @astrojs/starlight/routes/static/index.astro
18:07:51   ├─ /guides/example/index.html (+7ms)
18:07:51   ├─ /Course_A_Outline/index.html (+5ms) 
18:07:51   ├─ /reference/example/index.html (+4ms)
18:07:51   ├─ /index.html (+10ms) 
18:07:51   ├─ /システム要件定義書_ver2/index.html (+4ms)
18:07:51   ├─ /基本設計書-画面設計/index.html (+4ms) 
18:07:51   ├─ /UI設計詳細_モバイル版/index.html (+4ms)
18:07:51   ├─ /システム構成解説-システム構成図/index.html (+6ms) 
18:07:51   ├─ /環境構築手順Mac・UTM版/index.html (+4ms)
18:07:51   ├─ /Docker-Tools-Commands/index.html (+4ms)
18:07:51   ├─ /Data-Model_01/index.html (+3ms) 
18:07:51   ├─ /インフラ構築手順書/index.html (+4ms)
18:07:51   └─ /API仕様書_v1_5/index.html (+4ms)
18:07:51 ✓ Completed in 126ms.

 generating optimized images 
18:07:51   ▶ /_astro/houston.CZZyCf7p_Z2wV2f.webp (before: 96kB, after: 27kB) (+115ms) (1/1)
18:07:51 ✓ Completed in 115ms.


Running Pagefind v1.3.0 (Extended)
Running from: "C:\\Users\\delis\\develops\\astro-starlight-demo\\node_modules\\@astrojs\\starlight"
Source:       "..\\..\\..\\dist"
Output:       "..\\..\\..\\dist\\pagefind"

[Walking source directory]
Found 14 files matching **/*.{html}

[Parsing files]
Found a data-pagefind-body element on the site.
↳ Ignoring pages without this tag.

[Reading languages]
Discovered 1 language: en

[Building search indexes]
Total:
  Indexed 1 language
  Indexed 13 pages
  Indexed 1186 words
  Indexed 0 filters
  Indexed 0 sorts

Finished in 0.136 seconds
18:07:52 [WARN] [@astrojs/sitemap] The Sitemap integration requires the `site` astro.config option. Skipping.
18:07:52 [build] 14 page(s) built in 4.36s
18:07:52 [build] Complete!

ビルド結果をWEBサイトとして起動して結果を確認します。

npm run preview

WEBブラウザでサイトにアクセスします。
Astroアプリのデフォルトのポート番号は4321です。

http://localhost:4321


あとは、通常のアプリケーションと同じようにビルドした成果物をホストサーバーにデプロイすれば公開できます。

StarlightのSideBarについて

私が今回Starlightを使おうと思った一番の理由はこのSideBar機能であるといっても過言ではありません。SideBar機能(上図のスクリーンショットの左側に表示されているナビゲーション)は前述のとおりastro.config.mjsに設定さえすればあとは自動でファイル一覧を作成してくれますし、設定によって表示方法をカスタムしたり、CSSを適用できたりもします。

https://starlight.astro.build/ja/guides/sidebar/

optimize-md-for-sl.cjs の仕様

続いて、既存のMarkdown文書を簡単にStarlightに配置できるようにするために必要な処理を実行するためのスクリプトについて説明します。

ソースコード
const fs = require('fs');
const path = require('path');

/**
 * 指定されたディレクトリ内のすべてのファイルを再帰的に取得する
 * @param {string} dirPath - ディレクトリのパス
 * @param {string[]} result - 結果を格納する配列
 * @returns {string[]} ファイルパスの配列
 */
function getAllFiles(dirPath, result = []) {
  const items = fs.readdirSync(dirPath);
  
  for (const item of items) {
    const fullPath = path.join(dirPath, item);
    const stat = fs.statSync(fullPath);
    
    if (stat.isDirectory()) {
      // サブディレクトリを再帰的に処理
      getAllFiles(fullPath, result);
    } else if (stat.isFile() && path.extname(item) === '.md') {
      // Markdownファイルのみを対象とする
      result.push(fullPath);
    }
  }
  
  return result;
}

/**
 * ファイルのfrontmatterからtitleを抽出する
 * @param {string} content - ファイルの内容
 * @returns {string|null} titleの値、見つからない場合はnull
 */
function extractTitleFromFrontmatter(content) {
  // frontmatterの正規表現(---で囲まれた部分)
  const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
  
  if (!frontmatterMatch) {
    return null;
  }
  
  const frontmatter = frontmatterMatch[1];
  
  // titleの値を抽出
  const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
  
  if (!titleMatch) {
    return null;
  }
  
  // titleの値をクリーンアップ(クォートがあれば除去)
  let title = titleMatch[1].trim();
  title = title.replace(/^["']|["']$/g, '');
  
  return title;
}

/**
 * frontmatterが存在するかチェックする
 * @param {string} content - ファイルの内容
 * @returns {boolean} frontmatterが存在する場合true
 */
function hasFrontmatter(content) {
  return /^---\s*\n([\s\S]*?)\n---/.test(content);
}

/**
 * ファイル名のベース部分を正規化する(共通処理)
 * @param {string} baseName - 拡張子を除いたファイル名
 * @returns {string} 正規化されたファイル名
 */
function normalizeBaseName(baseName) {
  // 空白をアンダースコアに置換
  let normalized = baseName.replace(/\s+/g, '_');
  
  // 先頭末尾の空白を除去
  normalized = normalized.trim();
  
  // 各単語の最初の文字を大文字にする(オプション)
  normalized = normalized.replace(/\b\w/g, char => char.toUpperCase());
  
  return normalized;
}

/**
 * ファイル名からタイトルを生成する(拡張子を除去し、適切な形式に変換)
 * @param {string} fileName - ファイル名
 * @returns {string} タイトル
 */
function generateTitleFromFileName(fileName) {
  // 拡張子を除去
  const baseName = fileName.replace(/\.md$/i, '');
  
  // 共通の正規化処理を適用
  return normalizeBaseName(baseName);
}

/**
 * ファイル名を正規化する(generateTitleFromFileNameのルールに従って変換)
 * @param {string} fileName - 元のファイル名
 * @returns {string} 正規化されたファイル名
 */
function normalizeFileName(fileName) {
  // .md拡張子を分離
  const extension = '.md';
  const baseName = fileName.replace(/\.md$/i, '');
  
  // 共通の正規化処理を適用
  const normalizedBaseName = normalizeBaseName(baseName);
  
  return normalizedBaseName + extension;
}

/**
 * frontmatterを追加する
 * @param {string} content - ファイルの内容
 * @param {string} title - タイトル
 * @param {string} slug - スラッグ(ファイル名から.mdを除いたもの)
 * @returns {string} frontmatterが追加されたファイルの内容
 */
function addFrontmatter(content, title, slug) {
  const frontmatter = `---
title: ${title}
slug: ${slug}
description: Githubに保管されていたドキュメントを表示しています。
---

`;
  
  return frontmatter + content;
}

/**
 * frontmatterのtitleを更新する
 * @param {string} content - ファイルの内容
 * @param {string} newTitle - 新しいタイトル
 * @returns {string} titleが更新されたファイルの内容
 */
function updateFrontmatterTitle(content, newTitle) {
  return content.replace(
    /^title:\s*.+$/m,
    `title: ${newTitle}`
  );
}

/**
 * ファイル内のリンク参照を更新する(.mdパスを適切なURLパスに変換)
 * @param {string} content - ファイルの内容
 * @param {string} baseDir - ベースディレクトリ
 * @returns {string} 更新されたファイルの内容
 */
function updateMarkdownLinks(content, baseDir) {
  let updatedContent = content;
  let hasChanges = false;
  
  // Markdownリンク形式 [text](path) を検索(.mdファイルと/で終わるディレクトリパス両方に対応)
  const markdownLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
  
  updatedContent = updatedContent.replace(markdownLinkRegex, (match, linkText, linkPath) => {
    // HTTPリンクやアンカーのみのリンクはスキップ(ただしother-repositoryは対象とする)
    if ((linkPath.startsWith('http') && !linkPath.startsWith('https://github.com/other-repository')) || 
        linkPath.startsWith('#') || 
        linkPath.startsWith('mailto:')) {
      return match;
    }
    
    // アンカー(#以降)を分離
    let [pathPart, anchorPart] = linkPath.split('#');
    let urlPath;
    
    if (pathPart.startsWith('/')) {
      // 既に絶対パスの場合
      if (pathPart.endsWith('/')) {
        // 既に/で終わっている場合はそのまま
        urlPath = pathPart;
      } else if (pathPart.includes('.md')) {
        // .mdファイルの場合はファイル名を取得してルートパスに変換
        const fileName = pathPart.split('/').pop().replace(/\.md$/, '');
        urlPath = `/${fileName}/`;
      } else {
        // その他の場合はファイル名を取得してルートパスに変換
        const fileName = pathPart.split('/').pop();
        urlPath = `/${fileName}/`;
      }
    } else if (pathPart.includes('../') || pathPart.includes('./')) {
      // 相対パス(../や./を含む): パスの最後のファイル名のみを取得
      let fileName = pathPart.split('/').pop();
      if (fileName.endsWith('.md')) {
        fileName = fileName.replace(/\.md$/, '');
      }
      urlPath = `/${fileName}/`;
    } else {
      // その他の相対パス
      if (pathPart.includes('.md')) {
        // .mdファイルの場合
        const fileName = pathPart.split('/').pop().replace(/\.md$/, '');
        urlPath = `/${fileName}/`;
      } else if (pathPart.endsWith('/')) {
        // 既に/で終わっているディレクトリパスの場合
        const fileName = pathPart.replace(/\/$/, '').split('/').pop();
        urlPath = `/${fileName}/`;
      } else if (pathPart.includes('/')) {
        // ディレクトリパスを含む場合
        const fileName = pathPart.split('/').pop();
        urlPath = `/${fileName}/`;
      } else {
        // ファイル名のみの場合
        urlPath = `/${pathPart}/`;
      }
    }
    
    // アンカーがある場合は再結合
    const finalUrl = anchorPart ? `${urlPath}#${anchorPart}` : urlPath;
    const newLink = `[${linkText}](${finalUrl})`;
    
    if (match !== newLink) {
      hasChanges = true;
      console.log(`  リンク修正: ${match}${newLink}`);
    }
    
    return newLink;
  });
  
  // HTMLのhref属性内のリンクも処理
  const htmlHrefRegex = /href=["']([^"']+?)["']/g;
  
  updatedContent = updatedContent.replace(htmlHrefRegex, (match, linkPath) => {
    // HTTPリンクやアンカーのみのリンクはスキップ(ただしother-repositoryは対象とする)
    if ((linkPath.startsWith('http') && !linkPath.startsWith('https://github.com/other-repository')) || 
        linkPath.startsWith('#') || 
        linkPath.startsWith('mailto:')) {
      return match;
    }
    
    // アンカー(#以降)を分離
    let [pathPart, anchorPart] = linkPath.split('#');
    let urlPath;
    
    if (pathPart.startsWith('/')) {
      // 既に絶対パスの場合
      if (pathPart.endsWith('/')) {
        // 既に/で終わっている場合はそのまま
        urlPath = pathPart;
      } else if (pathPart.includes('.md')) {
        // .mdファイルの場合はファイル名を取得してルートパスに変換
        const fileName = pathPart.split('/').pop().replace(/\.md$/, '');
        urlPath = `/${fileName}/`;
      } else {
        // その他の場合はファイル名を取得してルートパスに変換
        const fileName = pathPart.split('/').pop();
        urlPath = `/${fileName}/`;
      }
    } else if (pathPart.includes('../') || pathPart.includes('./')) {
      // 相対パス(../や./を含む): パスの最後のファイル名のみを取得
      let fileName = pathPart.split('/').pop();
      if (fileName.endsWith('.md')) {
        fileName = fileName.replace(/\.md$/, '');
      }
      urlPath = `/${fileName}/`;
    } else if (pathPart.includes('/')) {
      // ディレクトリパスを含む場合: ファイル名のみを取得してルートパスに変換
      let fileName = pathPart.split('/').pop();
      if (fileName.endsWith('.md')) {
        fileName = fileName.replace(/\.md$/, '');
      } else if (pathPart.endsWith('/')) {
        fileName = pathPart.replace(/\/$/, '').split('/').pop();
      }
      urlPath = `/${fileName}/`;
    } else {
      // その他の相対パス → /で終わるパスに変換
      if (pathPart.endsWith('.md')) {
        urlPath = `/${pathPart.replace(/\.md$/, '')}/`;
      } else {
        urlPath = `/${pathPart}/`;
      }
    }
    
    // アンカーがある場合は再結合
    const finalUrl = anchorPart ? `${urlPath}#${anchorPart}` : urlPath;
    const quote = match.includes('"') ? '"' : "'";
    const newHref = `href=${quote}${finalUrl}${quote}`;
    
    if (match !== newHref) {
      hasChanges = true;
      console.log(`  HTML href修正: ${match}${newHref}`);
    }
    
    return newHref;
  });
  
  // URLエンコードされた.mdファイルのリンクも処理
  const encodedMdRegex = /\[([^\]]*)\]\(([^)]*%2E(?:md|MD)[^)]*)\)/g;
  
  updatedContent = updatedContent.replace(encodedMdRegex, (match, linkText, linkPath) => {
    // URLデコードしてファイル名を取得
    try {
      const decodedPath = decodeURIComponent(linkPath);
      let [pathPart, anchorPart] = decodedPath.split('#');
      
      // ファイル名を抽出
      const fileName = pathPart.split('/').pop().replace(/\.md$/i, '');
      const urlPath = `/${fileName}/`;
      
      // アンカーがある場合は再結合(アンカー部分はエンコードされたまま保持)
      const finalUrl = anchorPart ? `${urlPath}#${anchorPart}` : urlPath;
      const newLink = `[${linkText}](${finalUrl})`;
      
      if (match !== newLink) {
        hasChanges = true;
        console.log(`  エンコード済みリンク修正: ${match}${newLink}`);
      }
      
      return newLink;
    } catch (error) {
      console.log(`  URLデコードエラー: ${linkPath}`);
      return match; // デコードに失敗した場合は元のままにする
    }
  });
  
  // 行の中に含まれる.mdファイルパスも処理(より柔軟な検索)
  const relativePathRegex = /(\s|^|[>])([^\s<>)]+\.md)(\s|$|[<#])/g;
  
  updatedContent = updatedContent.replace(relativePathRegex, (match, prefix, mdPath, suffix) => {
    // リンク形式以外の.mdファイルへの参照を/で終わるURLパスに変換
    // ただし、絶対パス(/で始まる)は上記の処理で既に処理済みなのでスキップ
    if (mdPath.endsWith('.md') && !mdPath.startsWith('/') && !mdPath.startsWith('http')) {
      const fileName = mdPath.split('/').pop().replace(/\.md$/, '');
      const urlPath = `/${fileName}/`;
      const newPath = `${prefix}${urlPath}${suffix}`;
      
      if (match !== newPath) {
        hasChanges = true;
        console.log(`  パス修正: ${mdPath}${urlPath}`);
      }
      
      return newPath;
    }
    
    return match;
  });
  
  if (!hasChanges) {
    console.log(`  リンク修正: 修正対象なし`);
  }
  
  return updatedContent;
}

/**
 * メイン処理
 */
async function main() {
  try {
    // コマンドライン引数を取得
    const args = process.argv.slice(2);
    
    if (args.length === 0) {
      console.error('使用方法: node optimize-md-for-sl.cjs <ディレクトリパス>');
      console.error('例: node optimize-md-for-sl.cjs ./documents');
      process.exit(1);
    }
    
    const targetDirectory = args[0];
    
    // ディレクトリの存在確認
    if (!fs.existsSync(targetDirectory)) {
      console.error(`エラー: ディレクトリが見つかりません: ${targetDirectory}`);
      process.exit(1);
    }
    
    const stat = fs.statSync(targetDirectory);
    if (!stat.isDirectory()) {
      console.error(`エラー: 指定されたパスはディレクトリではありません: ${targetDirectory}`);
      process.exit(1);
    }
    
    console.log(`処理対象ディレクトリ: ${targetDirectory}`);
    console.log('');
    
    // すべてのMarkdownファイルを取得
    const allFiles = getAllFiles(targetDirectory);
    
    if (allFiles.length === 0) {
      console.log('処理対象のMarkdownファイルが見つかりませんでした。');
      return;
    }
    
    console.log(`見つかったMarkdownファイル: ${allFiles.length}`);
    console.log('');
    
    let processedCount = 0;
    let addedCount = 0;
    let updatedCount = 0;
    let skippedCount = 0;
    let linkModifiedCount = 0;
    let fileRenamedCount = 0;
    
    // 各ファイルを処理
    for (const filePath of allFiles) {
      const fileName = path.basename(filePath);
      const dirName = path.dirname(filePath);
      
      console.log(`処理中: ${fileName}`);
      
      try {
        // ファイル名の正規化をチェック
        const normalizedFileName = normalizeFileName(fileName);
        let currentFilePath = filePath;
        
        if (normalizedFileName !== fileName) {
          console.log(`  ファイル名正規化: ${fileName}${normalizedFileName}`);
          const newFilePath = path.join(dirName, normalizedFileName);
          
          // ファイル名を変更
          fs.renameSync(currentFilePath, newFilePath);
          currentFilePath = newFilePath;
          fileRenamedCount++;
          console.log(`  ファイル名: 変更完了`);
        } else {
          console.log(`  ファイル名: 変更不要`);
        }
        
        // ファイルの内容を読み込む
        let content = fs.readFileSync(currentFilePath, 'utf8');
        const originalContent = content;
        let contentModified = false;
        
        // ファイル名からタイトルとスラッグを生成(正規化されたファイル名を使用)
        const currentFileName = path.basename(currentFilePath);
        const titleFromFileName = generateTitleFromFileName(currentFileName);
        const slugFromFileName = currentFileName.replace(/\.md$/i, ''); // .mdを除去してスラッグとする
        
        // frontmatterの存在確認
        if (!hasFrontmatter(content)) {
          console.log(`  frontmatter: 存在しません`);
          console.log(`  生成されたtitle: "${titleFromFileName}"`);
          console.log(`  生成されたslug: "${slugFromFileName}"`);
          
          // frontmatterを追加
          content = addFrontmatter(content, titleFromFileName, slugFromFileName);
          contentModified = true;
          addedCount++;
          console.log(`  frontmatter: 追加しました`);
        } else {
          console.log(`  frontmatter: 存在します`);
          
          // 既存のtitleを確認
          const existingTitle = extractTitleFromFrontmatter(content);
          
          if (existingTitle) {
            console.log(`  既存のtitle: "${existingTitle}"`);
            console.log(`  ファイル名ベースのtitle: "${titleFromFileName}"`);
            
            // titleを更新するかどうかをユーザーに確認(自動で更新しない)
            if (existingTitle !== titleFromFileName) {
              console.log(`  title更新: スキップ(既存のtitleを保持)`);
              skippedCount++;
            } else {
              console.log(`  title更新: 不要(同じ値)`);
              skippedCount++;
            }
          } else {
            console.log(`  既存のtitle: 見つかりません`);
            console.log(`  生成されたtitle: "${titleFromFileName}"`);
            
            // titleがない場合は追加
            content = updateFrontmatterTitle(content, titleFromFileName);
            contentModified = true;
            updatedCount++;
            console.log(`  title: 追加しました`);
          }
        }
        
        // リンクを修正
        const contentWithUpdatedLinks = updateMarkdownLinks(content, dirName);
        if (content !== contentWithUpdatedLinks) {
          content = contentWithUpdatedLinks;
          contentModified = true;
          linkModifiedCount++;
        }
        
        // ファイルの更新があった場合に書き込む
        if (contentModified) {
          fs.writeFileSync(currentFilePath, content, 'utf8');
          console.log(`  ファイル内容: 更新済み`);
        } else {
          console.log(`  ファイル内容: 変更なし`);
        }
        
        processedCount++;
        
      } catch (error) {
        console.error(`  エラー: ${error.message}`);
      }
      
      console.log('');
    }
    
    console.log('処理結果:');
    console.log(`- 処理したファイル数: ${processedCount}`);
    console.log(`- ファイル名変更: ${fileRenamedCount}`);
    console.log(`- frontmatter追加: ${addedCount}`);
    console.log(`- title更新: ${updatedCount}`);
    console.log(`- リンク修正: ${linkModifiedCount}`);
    console.log(`- スキップ: ${skippedCount}`);
    console.log('');
    console.log('すべての処理が完了しました!');
    
  } catch (error) {
    console.error('エラー:', error.message);
    process.exit(1);
  }
}

// スクリプトを実行
main();


概要

optimize-md-for-sl.cjsは、AstroプロジェクトのMarkdownファイルに対して以下の処理を行う統合スクリプトです:

  1. ファイル名の正規化: スペースを含むファイル名をアンダースコア形式に変換
  2. Frontmatter の追加・更新: ファイル名から生成したtitleとslugを含むfrontmatterを追加
  3. リンクの修正: ファイル内のMarkdownおよびHTMLリンクをAstroのルーティング形式に適合するよう変換

背景・課題

Astroのルーティング特性

  • 日本語や特殊文字を含むファイル名でも、frontmatterでslugを明示的に指定することで意図した通りのURLが生成される
  • Astroはsrc/content/docs/Eコマースシステム/要件/システム要件定義書_ver2.mdのようなファイルを/システム要件定義書_ver2/index.htmlとして出力する
  • ソースコードのディレクトリ構造に関わらず、ファイル名がそのままルート直下のURLになる

ビルドしたファイルの保存のされ方はビルドログを確認するとイメージしやすいと思います。

 generating static routes 
18:07:51 ▶ @astrojs/starlight/routes/static/404.astro
18:07:51   └─ /404.htmlEntry docs → 404 was not found.
 (+15ms) 
18:07:51 ▶ @astrojs/starlight/routes/static/index.astro
18:07:51   ├─ /guides/example/index.html (+7ms)
18:07:51   ├─ /Course_A_Outline/index.html (+5ms) 
18:07:51   ├─ /reference/example/index.html (+4ms)
18:07:51   ├─ /index.html (+10ms) 
18:07:51   ├─ /システム要件定義書_ver2/index.html (+4ms)
18:07:51   ├─ /基本設計書-画面設計/index.html (+4ms) 
18:07:51   ├─ /UI設計詳細_モバイル版/index.html (+4ms)
18:07:51   ├─ /システム構成解説-システム構成図/index.html (+6ms) 
18:07:51   ├─ /環境構築手順Mac・UTM版/index.html (+4ms)
18:07:51   ├─ /Docker-Tools-Commands/index.html (+4ms)
18:07:51   ├─ /Data-Model_01/index.html (+3ms) 
18:07:51   ├─ /インフラ構築手順書/index.html (+4ms)
18:07:51   └─ /API仕様書_v1_5/index.html (+4ms)
18:07:51 ✓ Completed in 126ms.

解決する問題

  • frontmatterが存在しないファイルでのslug自動生成による予期しないURL変更
  • ファイル名に日本語(全角文字)が含まれる場合リンク文字として使用できない
  • 本文中のリンクがAstroのルーティング形式と一致せず404エラーになる問題

処理の流れ

1. ファイル探索

指定ディレクトリ内の全`.md`ファイルを再帰的に取得
↓
各ファイルに対して以下の処理を実行

2. ファイル名正規化

ファイル名の正規化をチェック
├─ 正規化が必要な場合
│  ├─ normalizeFileName()でファイル名を正規化
│  └─ fs.renameSync()でファイル名を変更
└─ 正規化が不要な場合: スキップ

3. Frontmatter処理

frontmatterの存在確認
├─ 存在しない場合
│  ├─ ファイル名からtitleを生成
│  ├─ ファイル名からslugを生成
│  └─ frontmatterを追加
└─ 存在する場合
   ├─ 既存のtitleを確認
   ├─ titleが存在する場合: 既存値を保持
   └─ titleが存在しない場合: 生成したtitleを追加

4. リンク修正処理

ファイル内容をスキャンして以下のパターンを検索・変換
├─ Markdownリンク形式: [text](path)
├─ HTMLリンク形式: href="path"
├─ URLエンコード済みリンク: %2Emd
└─ その他の.mdファイル参照

5. ファイル更新

変更があった場合のみファイルを更新
├─ frontmatter追加/更新
├─ リンク修正
└─ 統計情報の出力

対応ケース

ファイル名・文字種対応

ケース 対応状況
日本語ファイル名 システム要件定義書_ver2.md ✅ 対応
英数字ファイル名 Docker-tools-commands.md ✅ 対応
特殊文字含有 システム構成解説-システム構成図.md ✅ 対応
ハイフン・アンダースコア data-model_01.md ✅ 対応
スペース含有 API仕様書_v1 5.md ✅ 対応

リンク形式対応

1. Markdownリンク形式

元のリンク 変換後 対応状況
[text](file.md) [text](/file/) ✅ 対応
[text](dir/file.md) [text](/file/) ✅ 対応
[text](/dir/file.md) [text](/file/) ✅ 対応
[text](../dir/file.md) [text](/file/) ✅ 対応
[text](./file.md) [text](/file/) ✅ 対応
[text](file.md#anchor) [text](/file/#anchor) ✅ 対応
[text](dir/file/) [text](/file/) ✅ 対応
[text](file/) [text](/file/) ✅ 対応

2. HTMLリンク形式

元のリンク 変換後 対応状況
href="file.md" href="/file/" ✅ 対応
href="dir/file.md" href="/file/" ✅ 対応
href="dir/file" href="/file/" ✅ 対応
href='file.md' href='/file/' ✅ 対応
href="/dir/file.md" href="/file/" ✅ 対応

3. URLエンコード済みリンク

元のリンク 変換後 対応状況
%E7%92%B0%E5%A2%83.md /環境/ ✅ 対応
file%2Emd /file/ ✅ 対応

4. 除外対象リンク

リンク種別 処理
HTTPリンク(一般) https://example.com 変換対象外
HTTPリンク(もし特定のリンク先があれば) https://github.com/other_repository/... ✅ 変換対象
メールリンク mailto:test@example.com 変換対象外
アンカーのみ #section 変換対象外
既に適切な形式 /file/ 変換対象外

Frontmatter生成仕様

Title生成ルール

// 入力: "API仕様書_v1 5.md"
// 処理:
1. 拡張子除去: "API仕様書_v1 5"
2. 空白→アンダースコア: "API仕様書_v1_5" (空白が含まれていた場合)
3. 先頭末尾の空白除去: "API仕様書_v1_5"
4. タイトルケース化: "API仕様書_v1_5"
// 出力: "API仕様書_v1_5"

// スペースを含むファイル名の例:
// 入力: "Course A Outline.md"
// 処理:
1. 拡張子除去: "Course A Outline"
2. 空白→アンダースコア: "Course_A_Outline"
3. 先頭末尾の空白除去: "Course_A_Outline"
4. タイトルケース化: "Course_A_Outline"
// 出力: "Course_A_Outline"

Slug生成ルール

// 入力: "システム要件定義書_ver2.md"
// 処理: .md拡張子のみ除去
// 出力: "システム要件定義書_ver2"

生成されるFrontmatter

日本語ファイル名の場合:

---
title: システム要件定義書_ver2
slug: システム要件定義書_ver2
description: Githubに保管されていたドキュメントを表示しています。
---

スペースを含むファイル名の場合:

---
title: Course_A_Outline
slug: Course A Outline
description: Githubに保管されていたドキュメントを表示しています。
---

技術仕様

動作環境

  • Node.js環境
  • CommonJS形式(.cjs)

依存関係

  • fs: ファイルシステム操作
  • path: パス操作

正規表現パターン

Markdownリンク検索

/\[([^\]]*)\]\(([^)]+)\)/g

HTMLリンク検索

/href=["']([^"']+?)["']/g

URLエンコード済み.md検索

/\[([^\]]*)\]\(([^)]*%2E(?:md|MD)[^)]*)\)/g

注意事項・制限事項

処理対象

  • .md拡張子のファイルのみが対象
  • 既存のfrontmatterがある場合、既存のtitleは保持される
  • バイナリファイルや他の形式のファイルは無視される

安全性

  • 元ファイルのバックアップは作成されない
  • 変更があった場合のみファイルを更新
  • エラーが発生した場合でも他のファイル処理は継続

パフォーマンス

  • 大量のファイルでも効率的に処理
  • メモリ使用量は処理するファイル数に比例
  • 正規表現による高速なパターンマッチング

トラブルシューティング

よくある問題

1. ファイルが見つからない

エラー: ディレクトリが見つかりません: ./invalid/path

解決策: 正しいディレクトリパスを指定してください

2. 権限エラー

エラー: EACCES: permission denied

解決策: ファイル/ディレクトリの書き込み権限を確認してください

3. リンクが修正されない

確認ポイント:

  • HTTPリンクや外部リンクは変換対象外
  • 既に正しい形式(/file/)のリンクは変換されない
  • コンソール出力で「リンク修正: 修正対象なし」と表示される

デバッグ情報

スクリプトは詳細なログを出力するため、どのファイルのどの部分で問題が発生しているかを確認できます。

関連ドキュメント

Discussion