これまでの Next.js ブログサイト構築シリーズをまとめたページは以下の通りです。
前回の第10回では、 Next.js ブログを静的サイトとして本番公開し、
バッチファイルをダブルクリックするだけで
記事を VPS に転送してビルド・公開できる基本フローを構築しました。
ここまでで「ダブルクリックするだけで記事が公開される」という骨格はできましたが、
実はまだ2つの大きな課題が残っていました。
1つ目は、画像の問題です。
Obsidian で記事に画像を挿入すると ![[image.webp]] という形式でリンクされますが、
この形式は Next.js のサイトでは認識されないため、画像が表示されません。
2つ目は、URL の問題です。
Obsidian のノートのファイル名がそのまま記事の URL になるため、
日本語のノートタイトルをつけると URL も日本語になってしまい、
SEO 上も見た目上も好ましくない状態でした。
この記事では、これらの問題を一気に解決するために、
Obsidian のフロントマター設計を見直し、
画像リンクの形式変換・ファイル名の変換・エラーチェックを自動で行う
Node.js スクリプト(convert.js)を作成する手順を解説します。
この記事を読むと、以下のことができるようになります。
- Obsidian のノートファイル名(日本語)と記事の URL(英語)を別々に管理できる
- Obsidian の画像リンク形式を Next.js 用に自動変換できる仕組みが作れる
- 記事の公開前に自動でエラーチェックを行う仕組みが作れる
- 今後は Obsidian でノートを書くだけで、スムーズにブログ公開できる環境が整う
Next.js ブログで slug が必要な理由と URL 設計
第10回までの仕組みでは、 Obsidian のノートのファイル名がそのまま VPS に転送され、
記事の URL になっていました。
例えば、 Obsidian で VPSにNode.jsをインストールする方法.md というノートを作ると、
サイトの URL は以下のようになります。
https://next.example.com/posts/VPSにNode.jsをインストールする方法
URL に日本語が含まれると、以下のような問題が起きます。
- Google などの検索エンジンに URL の意味が伝わりにくく、 SEO 上不利になることがある
- X(Twitter)や LINE などの SNS でシェアしたときに URL が文字化けすることがある
- ブラウザによってはリンクが正しく動作しないことがある
かといって、 Obsidian のノートのファイル名を英語にしてしまうと、
Obsidian 内でノートを探したり整理したりするときに不便です。
そこで今回は、 Markdown のフロントマターに slug という項目を追加します。
slug とは、記事の URL として使う文字列のことです。
スクリプトがこの slug を読み取り、変換後のファイル名として使います。
これにより、 Obsidian のノートのファイル名は日本語のままで管理しつつ、
サイトの URL は英語のきれいな形にするという使い分けが実現します。
slugを使うことで、Obsidian では日本語でノートを管理しながら、サイトの URL は英語のきれいな形にできるという使い分けができるようになりました。
Obsidian でブログ記事を作成して、管理していくうえで非常に効果的な仕組みができたと感じています。
Obsidian の画像リンクを Next.js 用に自動変換する仕組み
もう一つの課題である画像の問題についても、あわせて整理しておきます。
Obsidian で画像を記事に挿入すると、以下の独自形式でリンクが書かれます。
![[image.webp]]
これは Obsidian が独自に採用している記法で、 Obsidian 上では問題なく画像が表示されます。
しかし、 Next.js のサイトでは以下の Markdown 標準形式でないと画像が表示されません。

この2つの形式の違いをまとめると以下の通りです。
| 項目 | Obsidian 形式 | Next.js 用形式 |
|---|---|---|
| 書き方 | ![[image.webp]] |  |
| 代替テキスト | 書けない | 書ける |
| Next.js での表示 | ❌ 表示されない | ✅ 表示される |
毎回手動で書き換えるのは現実的ではないため、
今回作成するスクリプトがこの変換を自動で行います。
代替テキストとは何か
代替テキストとは、画像が表示されないときに代わりに表示されるテキストのことです。
HTML では alt 属性として書きます。
代替テキストを設定しておくと、以下のメリットがあります。
- Google の画像検索に表示されやすくなる
- 視覚障害のある方がスクリーンリーダーで閲覧するときに読み上げられる
- SEO とアクセシビリティの両方に効果がある
代替テキストを含む画像リンクの書き方
Obsidian では、以下の形式で代替テキストを含めて画像を挿入できます。
![[image.webp|ここに代替テキストを書く]]
スクリプトはこの形式を読み取り、以下のように変換します。

代替テキストがない ![[image.webp]] の形式でも変換は行いますが、
スクリプトが「代替テキストがありません」と警告を表示します。
SEO のために、画像には必ず代替テキストを書くことをおすすめします。
ここでも、Obsidian で記事を作成し、いつも通りに画像を挿入しても、その画像ファイルのリンクまでスクリプトが自動的に Next.js 用に変換してくれるので、記事を書くときに形式を気にする必要がなくなりました。
代替テキストも、Obsidian の標準的な書き方がそのまま使えるので、シンプルで良いです。
Obsidian フロントマターの設計を見直す
ここからが実際の作業です。
まず Obsidian の記事フロントマターに新しい項目を追加する設計を行います。
追加する2つの項目
| 項目 | 役割 | 備考 |
|---|---|---|
slug | 記事の URL になる文字列 | 英小文字・数字・ハイフンのみ使用可 |
draft | 下書きフラグ | true の場合はスクリプトが転送対象から除外する |
完成後のフロントマター形式
今後の記事はすべて以下の形式でフロントマターを書きます。
---
title: "記事タイトル"
date: "2026-04-15"
slug: "vps-nodejs-setup"
description: "記事の説明文(100〜120文字が目安)"
ogImage: "/images/アイキャッチ画像のファイル名.webp"
draft: true
---
各項目の説明は以下の通りです。
title:ブラウザのタブや Google の検索結果に表示される記事タイトルですdate:記事の公開日です。YYYY-MM-DD形式で書きますslug:記事の URL になる文字列です。vps-nodejs-setupと書くと URL はhttps://next.example.com/posts/vps-nodejs-setupになりますdescription:Google の検索結果や SNS シェア時のカードに表示される説明文です。100〜120文字が目安ですogImage:SNS でシェアしたときのアイキャッチ画像のパスです。必ず/images/から始まる形式で書きます。省略するとサイト共通のデフォルト画像が使われますdraft:記事を書いている間はtrue(チェックあり)にしておきます。公開するときにチェックを外します
slug のルール
slug に使える文字は以下の通りです。
- 英小文字(a〜z)
- 数字(0〜9)
- ハイフン(-)
vps-nodejs-setup や nextjs-markdown-display のような形式が正しい書き方です。
大文字・スペース・日本語・アンダースコアは使えません。
スクリプトがこのルールを自動でチェックし、違反している場合は処理を止めてエラーを表示します。
Obsidian のテンプレートを設定する
新規記事を作成するたびに毎回フロントマターを手入力するのは手間がかかります。
Obsidian の標準テンプレート機能を使うことで、フロントマターを自動入力できます。
テンプレートフォルダを作成する
Obsidian の Vault 内にテンプレート用のフォルダを作成します。
フォルダ名や場所はどこでも構いません。今回は 90_Templates という名前で作成しました。
テンプレートファイルを作成する
作成したフォルダの中に ブログ記事テンプレート.md というファイルを新規作成し、
以下の内容を貼り付けます。
---
title: ""
date: {{date:YYYY-MM-DD}}
slug: ""
description: ""
ogImage: ""
draft: true
---
{{date:YYYY-MM-DD}} は Obsidian のテンプレート変数です。
テンプレートを呼び出した日の日付が 2026-04-15 のような形式で自動入力されます。
Obsidian の設定手順
- Obsidian の左下にある歯車アイコン(設定)をクリックします
- 「コアプラグイン」を開き、「テンプレート」をオンにします
- 「テンプレート」の設定画面を開き、「テンプレートフォルダの場所」に作成したフォルダ名(例:
90_Templates)を入力して保存します
テンプレートの使い方
postsフォルダで新しいノートを作成します- コマンドパレット(
Ctrl+P)を開きます - 「テンプレートを挿入」と入力して選択します
- 「ブログ記事テンプレート」を選択します
テンプレートを適用すると、フロントマターが自動で挿入された状態になります。
あとは title・slug・description などの各項目を入力するだけです。
convert.js スクリプトを作成する
いよいよ今回の作業の核心である Node.js スクリプト(convert.js)の作成です。
このスクリプトは、バッチファイルから自動的に呼び出されます。
実行される流れを整理すると以下の通りです。
deploy.bat をダブルクリックする
↓
STEP 0: 誤実行防止の確認(続行しますか?)
↓
STEP 1〜3: 事前チェック(エラーがあれば処理を止める)
↓
STEP 4〜5: VPS 上の既存ファイルと比較し、転送内容を表示して確認する
↓
STEP 6〜7: 画像リンクを変換し、変換済み記事を保存する
↓
STEP 8〜10: ログを記録し、完了メッセージを表示する
↓
バッチファイルが転送・ビルド・公開を続ける
スクリプトの見どころは「事前チェックの充実さ」です。
単に変換するだけでなく、公開前に記事の問題点を自動で検出してエラーや警告として知らせてくれる仕組みが組み込まれています。
今回、スクリプトの内容を詰めていく中で、自分が今後どのように記事を作成し、管理していくかをイメージしながら、自分自身はできる限り記事を書くことに集中できるように、面倒な作業を全部スクリプトに任せるという方向で詰めていった結果、スクリプトはかなり大きなものになりました。
しかし、その分、運用上のミスを防ぐための仕組みも充実したものになったと感じています。
スクリプトを保存するフォルダを作成する
まず、スクリプトを保存するフォルダを作成します。
エクスプローラーを開いて、以下の場所に scripts というフォルダを新規作成してください。
C:\Users\ユーザー名\scripts
ユーザー名 の部分はお使いのパソコンのユーザー名に置き換えてください。
作成できたら、スタートメニューまたはタスクバーから「Windows PowerShell」を起動して以下のコマンドで確認します。
dir C:\Users\ユーザー名\scripts
dir は「directory(ディレクトリ)」の略で、指定したフォルダの中身を一覧表示するコマンドです。
現時点では空のフォルダなので、「ファイルが見つかりません」と表示されれば正しく作成されています。
続いて、 Node.js が正しくインストールされているか確認します。
node -v
v24.14.0 のようなバージョン番号が表示されれば問題ありません。
Node.js は第2回の作業でインストール済みのため、追加のインストールは不要です。
バージョン番号が表示されない場合は、 Node.js の公式サイト(https://nodejs.org/ja/)から再インストールしてください。
スクリプトの設定値(CONFIG)
スクリプトの上部には、各フォルダのパスや接続情報を一か所にまとめた CONFIG という定数があります。
この部分だけをご自身の環境に合わせて書き換えれば動作します。
const CONFIG = {
// ブログ名(起動時の画面に表示される)
SITE_NAME: 'あなたのブログ名',
// Obsidianの記事フォルダ(変換元)
POSTS_DIR: 'Obsidianの記事フォルダのパス',
// Obsidianの画像フォルダ(転送元)
ATTACHMENTS_DIR: 'Obsidianの画像フォルダのパス',
// 変換済み記事の一時保存フォルダ(自動作成される)
CONVERTED_DIR: 'C:\\Users\\ユーザー名\\scripts\\converted-posts',
// 転送する画像ファイル名のリスト(バッチファイルから参照される)
IMAGE_LIST_FILE: 'C:\\Users\\ユーザー名\\scripts\\image-list.txt',
// 削除対象ファイルのリスト(バッチファイルから参照される)
DELETE_LIST_FILE: 'C:\\Users\\ユーザー名\\scripts\\delete-list.txt',
// 実行ログの保存先(追記保存される)
LOG_FILE: 'C:\\Users\\ユーザー名\\scripts\\deploy-log.txt',
// 画像ファイルのサイズ警告しきい値(2MB)
IMAGE_SIZE_WARNING_BYTES: 2 * 1024 * 1024,
// VPSへのSSH接続情報
VPS_USER: 'VPSのユーザー名',
VPS_HOST: 'VPSのIPアドレス',
VPS_PORT: 'SSHポート番号',
VPS_KEY: '~/.ssh/SSH鍵ファイル名',
VPS_POSTS_DIR: '~/example-blog/posts/',
};
パスを書く際の注意点が1つあります。
JavaScript では、フォルダの区切り文字であるバックスラッシュ(\)を
1文字書くために \\ と2つ重ねて書く必要があります。
例えば C:\Users\ユーザー名\scripts というパスは、
JavaScript の中では C:\\Users\\ユーザー名\\scripts と書きます。
事前チェックの仕組み
スクリプトの特徴的な機能が、処理を始める前に記事の問題点を自動で検出する「事前チェック」です。
エラーが見つかると、処理を止めて以下のようなメッセージを表示します。
=== エラーが見つかりました ===
[エラー] slugが設定されていません: 私のブログ記事.md
slugは英小文字・数字・ハイフンのみ使用できます(例: vps-nodejs-setup)
上記のエラーを修正してから再実行してください。
チェックする内容は以下の通りです。
処理を止めるエラー
| チェック内容 | 表示されるメッセージ |
|---|---|
slug が未設定 | 該当記事名を表示して中断 |
slug に使えない文字が含まれている | 正しい形式を案内して中断 |
title または date が未設定 | 該当記事名を表示して中断 |
date が YYYY-MM-DD 形式でない | 正しい形式を案内して中断 |
| 記事本文で参照している画像が見つからない | 該当画像名と記事名を表示して中断 |
ogImage に指定した画像が見つからない | 該当記事名を表示して中断 |
| 記事ファイルが空(0バイト) | 該当記事名を表示して中断 |
警告を表示して処理を続行するもの
| チェック内容 | 表示されるメッセージ |
|---|---|
| 代替テキストがない画像がある | 該当画像名と記事名を表示して続行 |
| Obsidian の内部リンクが含まれている | 該当記事名を表示して続行 |
| 画像ファイルが 2MB を超えている | 画像名とサイズを表示して続行 |
転送内容の確認機能
スクリプトは事前チェックが通過すると、 VPS 上の既存ファイルと比較して、
転送内容を以下のように表示してから処理を始めます。
=== 転送内容の確認 ===
【スキップ(下書き)】
下書き中の記事.md
【新規・更新】
【新規】vps-nodejs-setup.md(元ファイル: VPSにNode.jsをインストールする方法.md)
【更新】nextjs-nginx-setup.md(元ファイル: Nginx設定記事.md)
上記の内容で転送を開始します。よろしいですか?(y/n):
VPS 上にすでに同じ slug のファイルがあれば「更新」、なければ「新規」として分類されます。
この確認画面があることで、意図しない上書きや誤転送を防ぐことができます。
画像リンクの変換処理
転送内容を確認して y を入力すると、変換処理が始まります。
スクリプトは記事内の Obsidian 形式の画像リンクを Next.js 用に自動変換します。
変換前:![[cursor-introduction.webp|Cursorの紹介画像]]
変換後:
変換と同時に、記事内で使われている画像ファイルのファイル名を image-list.txt というファイルに書き出します。
バッチファイルがこのリストを読み取り、 Obsidian の画像フォルダから必要な画像だけを選んで VPS に転送します。
Obsidian の画像フォルダにはすべての記事の画像が混在していますが、
記事で実際に使っている画像だけを選んで転送できるのが、この仕組みの重要なポイントです。
スクリプトの作成手順
メモ帳を開いて、この記事の末尾にある convert.js の全文をコピー&ペーストします。
貼り付けたら CONFIG の中身をご自身の環境に合わせて書き換えてください。
「ファイル」→「名前を付けて保存」を選択して、以下の設定で保存します。
- 保存場所:
C:\Users\ユーザー名\scripts - ファイル名:
convert.js - ファイルの種類:すべてのファイル(ここを間違えると
.txtファイルになってしまうので注意) - 文字コード:UTF-8
保存できたら、以下のコマンドでファイルが正しく保存されたか確認します。
dir C:\Users\ユーザー名\scripts
convert.js が表示されれば保存完了です。
convert.js の全文
以下が convert.js の全文です。CONFIG の部分をご自身の環境に合わせて書き換えてから保存してください。
// ============================================================
// convert.js - Obsidian記事変換・デプロイ準備スクリプト
// ============================================================
'use strict';
// fs : ファイルの読み書きを行うための標準モジュール
// path : フォルダのパスを安全に組み立てるための標準モジュール
// readline: ターミナルでユーザーの入力を受け取るための標準モジュール
// child_process: 外部コマンド(SSH等)を実行するための標準モジュール
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { execSync } = require('child_process');
// ------------------------------------------------------------
// 設定値(ご自身の環境に合わせて書き換えてください)
// ------------------------------------------------------------
const CONFIG = {
SITE_NAME: 'あなたのブログ名',
POSTS_DIR: 'Obsidianの記事フォルダのパス',
ATTACHMENTS_DIR: 'Obsidianの画像フォルダのパス',
CONVERTED_DIR: 'C:\\Users\\ユーザー名\\scripts\\converted-posts',
IMAGE_LIST_FILE: 'C:\\Users\\ユーザー名\\scripts\\image-list.txt',
DELETE_LIST_FILE: 'C:\\Users\\ユーザー名\\scripts\\delete-list.txt',
LOG_FILE: 'C:\\Users\\ユーザー名\\scripts\\deploy-log.txt',
IMAGE_SIZE_WARNING_BYTES: 2 * 1024 * 1024,
VPS_USER: 'VPSのユーザー名',
VPS_HOST: 'VPSのIPアドレス',
VPS_PORT: 'SSHポート番号',
VPS_KEY: '~/.ssh/SSH鍵ファイル名',
VPS_POSTS_DIR: '~/example-blog/posts/',
};
const logLines = [];
function getTimestamp() {
const now = new Date();
const y = now.getFullYear();
const mo = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const mi = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
}
function log(message) {
logLines.push(message);
console.log(message);
}
function saveLog(status) {
const header = `\n=== ${getTimestamp()} ${status} ===`;
const body = logLines.join('\n');
const footer = `=== ${getTimestamp()} ログ終了 ===\n`;
fs.appendFileSync(CONFIG.LOG_FILE, `${header}\n${body}\n${footer}`, 'utf8');
}
function askQuestion(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase());
});
});
}
function getFmValue(fmText, key) {
const match = fmText.match(new RegExp(`^${key}:\\s*["']?(.+?)["']?\\s*$`, 'm'));
return match ? match[1].trim() : null;
}
async function main() {
console.log('');
console.log('============================================');
console.log(` ${CONFIG.SITE_NAME} デプロイツール`);
console.log('============================================');
console.log('本番サイトへの公開処理を開始します。');
const startAnswer = await askQuestion('続行しますか?(y/n): ');
if (startAnswer !== 'y') {
console.log('処理を中断しました。');
logLines.push('処理を中断しました(開始確認でキャンセル)。');
saveLog('デプロイ中断');
process.exit(0);
}
log(`\n=== ${getTimestamp()} デプロイ開始 ===`);
if (!fs.existsSync(CONFIG.POSTS_DIR)) {
log(`[エラー] 記事フォルダが見つかりません: ${CONFIG.POSTS_DIR}`);
saveLog('デプロイ中断(記事フォルダが存在しない)');
process.exit(1);
}
const allItems = fs.readdirSync(CONFIG.POSTS_DIR);
const subDirs = allItems.filter((item) => fs.statSync(path.join(CONFIG.POSTS_DIR, item)).isDirectory());
if (subDirs.length > 0) {
log('[エラー] postsフォルダ内にサブフォルダが見つかりました。');
log(' サブフォルダは使用できません。記事はすべてpostsフォルダ直下に置いてください。');
subDirs.forEach((d) => log(` - ${d}`));
saveLog('デプロイ中断(postsフォルダ内にサブフォルダが存在する)');
process.exit(1);
}
const mdFiles = allItems.filter((f) => f.endsWith('.md'));
if (mdFiles.length === 0) {
log('[情報] 処理対象の記事ファイルが見つかりませんでした。');
saveLog('デプロイ中断(記事ファイルが存在しない)');
process.exit(0);
}
log('\n--- 事前チェックを開始します ---');
const errors = [];
const warnings = [];
const slugSet = new Set();
const articles = [];
for (const file of mdFiles) {
const filePath = path.join(CONFIG.POSTS_DIR, file);
const stat = fs.statSync(filePath);
if (stat.size === 0) {
errors.push(`[エラー] 記事ファイルが空です: ${file}`);
continue;
}
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
errors.push(`[エラー] ファイルの読み込みに失敗しました(文字コードを確認してください): ${file}`);
continue;
}
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) {
errors.push(`[エラー] フロントマターが見つかりません: ${file}`);
errors.push(` 記事の先頭が --- で始まっているか確認してください。`);
continue;
}
const fmText = fmMatch[1];
const title = getFmValue(fmText, 'title');
const date = getFmValue(fmText, 'date');
const slug = getFmValue(fmText, 'slug');
const draft = getFmValue(fmText, 'draft');
const ogImage = getFmValue(fmText, 'ogImage');
if (draft === 'true') {
articles.push({ file, skip: true, reason: '下書き' });
continue;
}
if (!title) { errors.push(`[エラー] titleが設定されていません: ${file}`); continue; }
if (!date) { errors.push(`[エラー] dateが設定されていません: ${file}`); continue; }
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
errors.push(`[エラー] dateの形式が正しくありません: ${file}(現在の値: ${date})`);
errors.push(` 正しい形式: YYYY-MM-DD(例: 2026-04-15)`);
continue;
}
if (!slug) {
errors.push(`[エラー] slugが設定されていません: ${file}`);
errors.push(` slugは英小文字・数字・ハイフンのみ使用できます(例: vps-nodejs-setup)`);
continue;
}
if (!/^[a-z0-9-]+$/.test(slug)) {
errors.push(`[エラー] slugに使用できない文字が含まれています: ${file}(slug: ${slug})`);
errors.push(` slugは英小文字・数字・ハイフンのみ使用できます(例: vps-nodejs-setup)`);
continue;
}
if (slugSet.has(slug)) {
errors.push(`[エラー] 今回の記事の中でslugが重複しています: ${file}(slug: ${slug})`);
continue;
}
slugSet.add(slug);
const imageRefs = [];
const imgRegexWithAlt = /!\[\[([^\]|]+)\|([^\]]+)\]\]/g;
let m;
while ((m = imgRegexWithAlt.exec(content)) !== null) {
imageRefs.push({ filename: m[1].trim(), alt: m[2].trim() });
}
const imgRegexNoAlt = /!\[\[([^\]|]+)\]\]/g;
while ((m = imgRegexNoAlt.exec(content)) !== null) {
imageRefs.push({ filename: m[1].trim(), alt: '' });
}
imageRefs.filter((img) => img.alt === '').forEach((img) => {
warnings.push(`[警告] 代替テキストがありません: ${img.filename}(記事: ${file})`);
warnings.push(` SEO向上のため ![[${img.filename}|代替テキスト]] の形式で書くことをおすすめします`);
});
for (const img of imageRefs) {
const imgPath = path.join(CONFIG.ATTACHMENTS_DIR, img.filename);
if (!fs.existsSync(imgPath)) {
errors.push(`[エラー] 画像ファイルが見つかりません: ${img.filename}(記事: ${file})`);
errors.push(` 確認場所: ${CONFIG.ATTACHMENTS_DIR}`);
} else {
const imgStat = fs.statSync(imgPath);
if (imgStat.size > CONFIG.IMAGE_SIZE_WARNING_BYTES) {
warnings.push(`[警告] 画像ファイルのサイズが大きいです: ${img.filename}(${(imgStat.size / 1024 / 1024).toFixed(1)}MB)(記事: ${file})`);
warnings.push(` サイトの表示速度のために、画像を圧縮することをおすすめします`);
}
}
}
let ogImageFilename = null;
if (ogImage) {
ogImageFilename = path.basename(ogImage);
const ogPath = path.join(CONFIG.ATTACHMENTS_DIR, ogImageFilename);
if (!fs.existsSync(ogPath)) {
errors.push(`[エラー] ogImageに指定した画像が見つかりません: ${ogImageFilename}(記事: ${file})`);
errors.push(` 確認場所: ${CONFIG.ATTACHMENTS_DIR}`);
}
}
const internalLinks = content.match(/(?<!!)(\[\[[^\]]+\]\])/g);
if (internalLinks) {
warnings.push(`[警告] Obsidianの内部リンクが含まれています: ${file}`);
internalLinks.forEach((link) => {
warnings.push(` ${link} → ブログでは機能しません。確認してください。`);
});
}
articles.push({ file, slug, title, skip: false, imageRefs, ogImageFilename, content });
}
if (errors.length > 0) {
console.log('\n=== エラーが見つかりました ===');
errors.forEach((e) => console.log(e));
console.log('\n上記のエラーを修正してから再実行してください。');
errors.forEach((e) => log(e));
saveLog('デプロイ中断(事前チェックエラー)');
process.exit(1);
}
if (warnings.length > 0) {
console.log('\n=== 警告 ===');
warnings.forEach((w) => console.log(w));
warnings.forEach((w) => log(w));
console.log('');
}
log('事前チェック:問題なし');
log('\n--- VPS上の既存ファイルを確認しています ---');
let vpsFiles = [];
try {
const sshCmd = `wsl ssh -i ${CONFIG.VPS_KEY} -p ${CONFIG.VPS_PORT} ${CONFIG.VPS_USER}@${CONFIG.VPS_HOST} "ls ${CONFIG.VPS_POSTS_DIR} 2>/dev/null || echo ''"`;
const result = execSync(sshCmd, { encoding: 'utf8' });
vpsFiles = result.trim().split('\n').filter((f) => f.endsWith('.md'));
} catch (e) {
log('[エラー] VPSへの接続に失敗しました。ネットワーク接続とSSH設定を確認してください。');
saveLog('デプロイ中断(VPS接続失敗)');
process.exit(1);
}
const activeArticles = articles.filter((a) => !a.skip);
const activeSlugs = activeArticles.map((a) => `${a.slug}.md`);
const toUpload = activeArticles.map((article) => {
const type = vpsFiles.includes(`${article.slug}.md`) ? '更新' : '新規';
return { article, type };
});
const toDelete = vpsFiles.filter((vpsFile) => !activeSlugs.includes(vpsFile));
const skipped = articles.filter((a) => a.skip);
console.log('\n=== 転送内容の確認 ===');
if (skipped.length > 0) {
console.log('\n【スキップ(下書き)】');
skipped.forEach((a) => console.log(` ${a.file}`));
}
if (toUpload.length === 0 && toDelete.length === 0) {
console.log('\n変更されたファイルがありません。処理を中断します。');
log('変更なし → 処理を中断');
saveLog('デプロイ中断(変更なし)');
process.exit(0);
}
if (toUpload.length > 0) {
console.log('\n【新規・更新】');
toUpload.forEach((item) => {
console.log(` 【${item.type}】${item.article.slug}.md(元ファイル: ${item.article.file})`);
});
}
if (toDelete.length > 0) {
console.log('\n【VPSから削除】');
toDelete.forEach((f) => console.log(` ${f}`));
}
console.log('');
const confirmAnswer = await askQuestion('上記の内容で転送を開始します。よろしいですか?(y/n): ');
if (confirmAnswer !== 'y') {
log('転送内容確認でキャンセルされました。');
saveLog('デプロイ中断(転送確認でキャンセル)');
process.exit(0);
}
let approvedDeletes = [];
if (toDelete.length > 0) {
console.log('\n以下のファイルをVPSから削除します。');
toDelete.forEach((f) => console.log(` ${f}`));
const deleteAnswer = await askQuestion('削除してよろしいですか?(y/n): ');
if (deleteAnswer === 'y') {
approvedDeletes = toDelete;
} else {
log('削除をキャンセルしました。削除以外の処理は続行します。');
}
}
if (fs.existsSync(CONFIG.CONVERTED_DIR)) {
fs.rmSync(CONFIG.CONVERTED_DIR, { recursive: true });
}
fs.mkdirSync(CONFIG.CONVERTED_DIR, { recursive: true });
log('\n--- 記事の変換処理を開始します ---');
const usedImages = new Set();
for (const item of toUpload) {
const { article } = item;
let content = article.content;
content = content.replace(/!\[\[([^\]|]+)\|([^\]]+)\]\]/g, (match, filename, alt) => {
filename = filename.trim();
alt = alt.trim();
usedImages.add(filename);
return ``;
});
content = content.replace(/!\[\[([^\]|]+)\]\]/g, (match, filename) => {
filename = filename.trim();
usedImages.add(filename);
return ``;
});
if (article.ogImageFilename) usedImages.add(article.ogImageFilename);
const outputPath = path.join(CONFIG.CONVERTED_DIR, `${article.slug}.md`);
fs.writeFileSync(outputPath, content, 'utf8');
log(` 変換完了: ${article.file} → ${article.slug}.md`);
}
if (usedImages.size > 0) {
fs.writeFileSync(CONFIG.IMAGE_LIST_FILE, Array.from(usedImages).join('\n'), 'utf8');
log(` 画像リスト作成完了: ${usedImages.size}件`);
} else {
fs.writeFileSync(CONFIG.IMAGE_LIST_FILE, '', 'utf8');
log(' 転送する画像ファイルがありません。');
}
skipped.forEach((a) => log(`【スキップ(下書き)】${a.file}`));
toUpload.forEach((item) => log(`【${item.type}】${item.article.slug}.md(元ファイル: ${item.article.file})`));
if (approvedDeletes.length > 0) approvedDeletes.forEach((f) => log(`【削除承認】${f}`));
if (toDelete.length > 0 && approvedDeletes.length === 0) log('【削除キャンセル】削除はスキップされました');
log(`画像転送予定: ${usedImages.size}件`);
if (approvedDeletes.length > 0) {
fs.writeFileSync(CONFIG.DELETE_LIST_FILE, approvedDeletes.join('\n'), 'utf8');
} else {
fs.writeFileSync(CONFIG.DELETE_LIST_FILE, '', 'utf8');
}
console.log('\n=== 変換処理が完了しました ===');
console.log('続いてバッチファイルがVPSへの転送とビルドを実行します。');
console.log('');
saveLog('変換処理完了(バッチファイルが転送・ビルドを続行)');
}
main().catch((err) => {
console.error('[予期しないエラーが発生しました]', err.message);
process.exit(1);
});
まとめ
今回は、 Obsidian のフロントマター設計を見直し、画像リンクの自動変換・ファイル名の変換・事前エラーチェックを行う convert.js スクリプトを作成しました。
slug を使うことで、 Obsidian のノートは日本語のファイル名で自由に管理しながら、サイトの URL は英語のきれいな形にするという使い分けが実現しました。
また、画像リンクの変換も自動化されたことで、記事を書くときに形式を意識する必要がなくなりました。
設計を詰めていく段階では、スクリプトにどこまで任せるかを、自分の実際の運用をイメージしながら一つずつ検討していきました。
その結果、スクリプトはかなり大きなものになりましたが、その分、運用上のミスを防ぐための仕組みが充実したものになったと思っています。
次回は、今回作成した convert.js と連携して動く deploy.bat を完成させ、
実際に Obsidian で書いたテスト記事を公開するまでの全工程を解説します。





コメント