画像の相対パス問題と対応状況
発見経緯
2026-01-01、本番環境 https://log.eurekapu.com/toc-metro-line-design で画像が表示されない問題を発見。
ブラウザ上では ![alt text] のaltテキストのみが表示され、画像自体は読み込まれていなかった。
問題の概要
path フィールドでURLを変更すると、マークダウン内の相対画像パスが壊れる。
再現手順
- マークダウンファイルに
path: "/article"を設定 - 同じファイル内で
のように相対パスで画像を参照 - ブラウザでページを開くと画像が404になる
具体例
問題が発生したファイル: content/2025-12-31/toc-metro-line-design.md
---
title: "TableOfContents メトロラインデザイン仕様"
path: "/toc-metro-line-design" # ← URLを日付なしに変更
publishedAt: "2025-12-31"
---
## Before(現状)
 # ← 相対パス
## After(目標)
 # ← 相対パス
ブラウザの挙動:
- URL:
https://log.eurekapu.com/toc-metro-line-design - 画像リクエスト:
https://log.eurekapu.com/image-7.png→ 404 - 実際の画像:
https://log.eurekapu.com/2025-12-31/image-7.pngに存在
原因
# 変更前(path未設定)
ファイル: content/2025-12-31/article.md
URL: /2025-12-31/article
画像:  → /2025-12-31/image.png ✅
# 変更後(pathでURL変更)
ファイル: content/2025-12-31/article.md
URL: /article(pathで上書き)
画像:  → /image.png ❌(404)
ブラウザは現在のURLを基準に相対パスを解決する。URLが /article の場合、image.png は /image.png に解決されるが、実際の画像は /2025-12-31/image.png に配置されている。
影響範囲
path フィールドを持ち、かつ相対画像パスを使用しているファイル: 24件
検出コマンド:
cd apps/web/content
for f in $(grep -r "^path:" --include="*.md" -l); do
if grep -q '!\[.*\]([^/h][^)]*\.\(png\|jpg\|jpeg\|gif\|webp\))' "$f" 2>/dev/null; then
echo "$f"
fi
done
試した対応策
1. remarkプラグインでビルド時に変換(失敗)
アプローチ: マークダウンのパース時に相対パスを絶対パスに変換するremarkプラグインを作成。
実装:
lib/remark-absolute-images.tsを作成nuxt.config.tsのcontent.build.markdown.remarkPluginsに登録
結果: ❌ 失敗
原因: Nuxt Content v3では、remarkPluginsにnpmパッケージ名(文字列)のみ登録可能。ローカルの関数を直接登録することはサポートされていない。
// これは動作しない
remarkPlugins: {
[myLocalPlugin as any]: {} // ← 関数を文字列化してnpmパッケージ名として解釈される
}
エラー内容:
[nitro] ERROR RollupError: .nuxt/mdc-imports.mjs (4:271): Unterminated string constant
(Note that you need plugins to import files that are not JavaScript)
2: import _RemarkGfm from 'remark-gfm'
3: import _RemarkMath from 'remark-math'
4: import _FunctionremarkAbsoluteImagesreturntreefileconstfilePathfilePathfileHistory0const
contentDirextractContentDirfilePathifcontentDirreturn0UnistUtilVisitVisittreeimagenodeif
isRelativePathnodeUrlImagePngimagePngconstcleanUrlnodeUrlReplacenodeUrlcontentDircleanUrl
from 'function ...
^
5: return (tree, file) => {
6: // ファイルパスからディレクトリを取得
原因の詳細: Nuxt Content v3は remarkPlugins に登録された関数を toString() で文字列化し、その文字列をnpmパッケージ名として import ... from '...' の形式で読み込もうとする。関数の文字列表現は有効なモジュール名ではないため、パースエラーが発生する。
作成したプラグイン: lib/remark-absolute-images.ts
import { visit } from 'unist-util-visit';
import type { Root, Image } from 'mdast';
import type { VFile } from 'vfile';
function isRelativePath(url: string): boolean {
if (!url) return false;
if (url.startsWith('/')) return false;
if (url.startsWith('http://') || url.startsWith('https://')) return false;
if (url.startsWith('data:')) return false;
return true;
}
function extractContentDir(filePath: string): string | null {
const normalizedPath = filePath.replace(/\\/g, '/');
const contentMatch = normalizedPath.match(/content\/(.+)\/[^/]+\.md[x]?$/i);
if (!contentMatch) return null;
return '/' + contentMatch[1];
}
export default function remarkAbsoluteImages() {
return (tree: Root, file: VFile) => {
const filePath = file.path || file.history?.[0] || '';
const contentDir = extractContentDir(filePath);
if (!contentDir) return;
visit(tree, 'image', (node: Image) => {
if (isRelativePath(node.url)) {
const cleanUrl = node.url.replace(/^\.\//, '');
node.url = `${contentDir}/${cleanUrl}`;
}
});
};
}
nuxt.config.ts での登録方法(失敗):
import remarkAbsoluteImages from "./lib/remark-absolute-images";
export default defineNuxtConfig({
content: {
build: {
markdown: {
remarkPlugins: {
"remark-gfm": {},
"remark-math": {},
[remarkAbsoluteImages as any]: {} // ← これが問題
}
}
}
}
});
2. nuxt-content-assets モジュール(不可)
アプローチ: 相対パスを自動で絶対パスに変換するサードパーティモジュール。
結果: Nuxt Content v3 非対応のため不可
調査結果:
nuxt-content-assetsv1.4.4 のpeerDependenciesは@nuxt/content: ^2.0.0- 本プロジェクトは
@nuxt/[email protected]を使用 - インストール時に peer 互換エラーが発生
✕ unmet peer @nuxt/content@^2.0.0: found 3.9.0
- GitHub Issue #3140 でも「nuxt-content-assets does not work with Nuxt Content 3」と報告されている
- 2026-01-01時点で Nuxt Content v3 用の同等モジュールは存在しない
参考: nuxt-content-assets · Nuxt Modules
今後の対応案
案A: マークダウンファイル自体を修正(推奨)
スクリプトで24ファイルの相対パスを絶対パスに一括変換。
// 変換パターン
//  → 
//  → 
メリット:
- 一度実行すれば終わり
- 追加の依存関係なし
- 確実に動作する
デメリット:
- 今後新規作成時も絶対パスで書く必要がある
- ファイル移動時にパスの更新が必要
案B: ローカルnpmパッケージとしてremarkプラグインを作成
packages/remark-absolute-images としてローカルパッケージを作成し、npmパッケージとして登録。
必要な手順:
packages/remark-absolute-images/ディレクトリを作成package.jsonにnameとexportsを設定- TypeScriptならビルドしてJSを出力
apps/web/package.jsonにworkspace:*で依存追加nuxt.config.tsのremarkPluginsにパッケージ名を指定
想定ディレクトリ構造:
packages/
└── remark-absolute-images/
├── package.json
├── tsconfig.json
├── src/
│ └── index.ts
└── dist/
└── index.js # ビルド後
package.json例:
{
"name": "remark-absolute-images",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsc"
}
}
apps/web/package.json への追加:
{
"dependencies": {
"remark-absolute-images": "workspace:*"
}
}
nuxt.config.ts での登録:
remarkPlugins: {
"remark-gfm": {},
"remark-math": {},
"remark-absolute-images": {} // ← パッケージ名を文字列で指定
}
メリット:
- 相対パスのまま書ける
- ビルド時に自動変換
- Nuxt Content v3の制約に適合
デメリット:
- 初期セットアップが必要
- パッケージのビルドが必要
案C: nuxt-content-assets モジュール導入
サードパーティモジュールに依存。
現在のステータス
- 問題の特定
- remarkプラグインでの対応を試行(失敗 - Nuxt Content v3では関数登録不可)
- nuxt-content-assets モジュール調査(失敗 - v3非対応)
- 代替案の決定 → 案B: ローカルnpmパッケージ
- 対応実施 ✅ 完了
実装結果
案B: ローカルnpmパッケージ を採用し、問題を解決した。
詳細な実装手順とファイル一覧は ローカルnpmパッケージ化ガイド を参照。
作成したファイル
| ファイル | 役割 |
|---|---|
packages/remark-absolute-images/package.json | パッケージ定義 |
packages/remark-absolute-images/tsconfig.json | TypeScript設定 |
packages/remark-absolute-images/src/index.ts | プラグイン本体 |
変更したファイル
| ファイル | 変更内容 |
|---|---|
pnpm-workspace.yaml | packages/* をワークスペースに追加 |
apps/web/package.json | remark-absolute-images: workspace:* を依存に追加 |
apps/web/nuxt.config.ts | remarkPluginsに remark-absolute-images を追加 |
動作確認
/toc-metro-line-designで画像が正常に表示されることを確認- 相対パス
が絶対パスに変換される
実装案B: ローカルパッケージの詳細
ステップ1: パッケージディレクトリ作成
mkdir -p packages/remark-absolute-images/src
ステップ2: package.json 作成
packages/remark-absolute-images/package.json:
{
"name": "remark-absolute-images",
"version": "1.0.0",
"description": "Remark plugin to convert relative image paths to absolute paths based on content directory",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/mdast": "^4.0.4",
"typescript": "^5.0.0",
"vfile": "^6.0.3"
},
"files": [
"dist"
]
}
ステップ3: tsconfig.json 作成
packages/remark-absolute-images/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
ステップ4: プラグイン本体 作成
packages/remark-absolute-images/src/index.ts:
/**
* remarkプラグイン: 画像の相対パスを絶対パスに変換
*
* 問題: pathフィールドでURLを変更すると、相対画像パスが壊れる
* 例: content/2025-12-31/article.md で path: "/article" を設定すると
*  は /image.png に解決される(本来は /2025-12-31/image.png)
*
* 解決: ビルド時に相対パスを絶対パスに変換
*  → 
*/
import { visit } from 'unist-util-visit';
import type { Root, Image } from 'mdast';
import type { VFile } from 'vfile';
/**
* 相対パスかどうかを判定
* - /で始まる → 絶対パス
* - http:// または https:// で始まる → 外部URL
* - それ以外 → 相対パス
*/
function isRelativePath(url: string): boolean {
if (!url) return false;
if (url.startsWith('/')) return false;
if (url.startsWith('http://') || url.startsWith('https://')) return false;
if (url.startsWith('data:')) return false;
return true;
}
/**
* ファイルパスからコンテンツディレクトリを抽出
* 例: C:/repo/apps/web/content/2025-12-31/article.md → /2025-12-31
* /content/2025-12-31/article.md → /2025-12-31
*/
function extractContentDir(filePath: string): string | null {
// Windowsパスを正規化
const normalizedPath = filePath.replace(/\\/g, '/');
// content/ 以降のパスを抽出
const contentMatch = normalizedPath.match(/content\/(.+)\/[^/]+\.md[x]?$/i);
if (!contentMatch) return null;
return '/' + contentMatch[1];
}
/**
* remarkプラグイン本体
*/
export default function remarkAbsoluteImages() {
return (tree: Root, file: VFile) => {
// ファイルパスからディレクトリを取得
const filePath = file.path || file.history?.[0] || '';
const contentDir = extractContentDir(filePath);
if (!contentDir) {
// コンテンツディレクトリが特定できない場合はスキップ
return;
}
// 画像ノードを探索して変換
visit(tree, 'image', (node: Image) => {
if (isRelativePath(node.url)) {
// ./image.png → image.png に正規化
const cleanUrl = node.url.replace(/^\.\//, '');
// 絶対パスに変換
node.url = `${contentDir}/${cleanUrl}`;
}
});
};
}
ステップ5: パッケージビルド
cd packages/remark-absolute-images
pnpm install
pnpm build
ステップ6: apps/web に依存追加
apps/web/package.json に追加:
{
"dependencies": {
"remark-absolute-images": "workspace:*"
}
}
cd apps/web
pnpm install
ステップ7: nuxt.config.ts に登録
export default defineNuxtConfig({
content: {
build: {
markdown: {
remarkPlugins: {
"remark-gfm": {},
"remark-math": {},
"remark-absolute-images": {}
}
}
}
}
});
ステップ8: 動作確認
cd apps/web
pnpm dev
# http://localhost:3000/toc-metro-line-design で画像が表示されることを確認
実装案C: nuxt-content-assets モジュール
ステップ1: モジュールインストール
cd apps/web
pnpm add nuxt-content-assets
ステップ2: nuxt.config.ts に追加
重要: nuxt-content-assets は @nuxt/content より前に記述する必要がある。
export default defineNuxtConfig({
modules: [
"@nuxtjs/robots",
"@nuxtjs/sitemap",
"nuxt-content-assets", // ← @nuxt/content より前に追加
"@nuxt/content",
"nuxt-og-image"
]
});
ステップ3: 動作確認
pnpm dev
# http://localhost:3000/toc-metro-line-design で画像が表示されることを確認
参考: nuxt-content-assets · Nuxt Modules
関連ファイル
packages/remark-absolute-images/- ローカルnpmパッケージ(実装済み)nuxt.config.ts- remarkPlugins設定/url-structure-improvement- URL構造改善計画(この問題の発端)/local-npm-package-guide- ローカルnpmパッケージ化の詳細ガイド