• #バグ
  • #画像
  • #Nuxt Content
  • #解決済み
開発完了

画像の相対パス問題と対応状況

発見経緯

2026-01-01、本番環境 https://log.eurekapu.com/toc-metro-line-design で画像が表示されない問題を発見。

ブラウザ上では ![alt text] のaltテキストのみが表示され、画像自体は読み込まれていなかった。

問題の概要

path フィールドでURLを変更すると、マークダウン内の相対画像パスが壊れる。

再現手順

  1. マークダウンファイルに path: "/article" を設定
  2. 同じファイル内で ![alt](image.png) のように相対パスで画像を参照
  3. ブラウザでページを開くと画像が404になる

具体例

問題が発生したファイル: content/2025-12-31/toc-metro-line-design.md

---
title: "TableOfContents メトロラインデザイン仕様"
path: "/toc-metro-line-design"  # ← URLを日付なしに変更
publishedAt: "2025-12-31"
---
## Before(現状)
![alt text](image-7.png)  # ← 相対パス

## After(目標)
![alt text](clip_20251231_110603.png)  # ← 相対パス

ブラウザの挙動:

  • 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
画像: ![img](image.png) → /2025-12-31/image.png ✅

# 変更後(pathでURL変更)
ファイル: content/2025-12-31/article.md
URL: /article(pathで上書き)
画像: ![img](image.png) → /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.tscontent.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-assets v1.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ファイルの相対パスを絶対パスに一括変換。

// 変換パターン
// ![alt](image.png) → ![alt](/2025-12-31/image.png)
// ![alt](./image.png) → ![alt](/2025-12-31/image.png)

メリット:

  • 一度実行すれば終わり
  • 追加の依存関係なし
  • 確実に動作する

デメリット:

  • 今後新規作成時も絶対パスで書く必要がある
  • ファイル移動時にパスの更新が必要

案B: ローカルnpmパッケージとしてremarkプラグインを作成

packages/remark-absolute-images としてローカルパッケージを作成し、npmパッケージとして登録。

必要な手順:

  1. packages/remark-absolute-images/ ディレクトリを作成
  2. package.jsonnameexports を設定
  3. TypeScriptならビルドしてJSを出力
  4. apps/web/package.jsonworkspace:* で依存追加
  5. nuxt.config.tsremarkPlugins にパッケージ名を指定

想定ディレクトリ構造:

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.jsonTypeScript設定
packages/remark-absolute-images/src/index.tsプラグイン本体

変更したファイル

ファイル変更内容
pnpm-workspace.yamlpackages/* をワークスペースに追加
apps/web/package.jsonremark-absolute-images: workspace:* を依存に追加
apps/web/nuxt.config.tsremarkPluginsに remark-absolute-images を追加

動作確認

  • /toc-metro-line-design で画像が正常に表示されることを確認
  • 相対パス ![alt](image.png) が絶対パス ![alt](/2025-12-31/image.png) に変換される

実装案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" を設定すると
 *     ![img](image.png) は /image.png に解決される(本来は /2025-12-31/image.png)
 *
 * 解決: ビルド時に相対パスを絶対パスに変換
 *     ![img](image.png) → ![img](/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パッケージ化の詳細ガイド

参考