🔧

Quartz4でZenn互換の画像パス(/images)を使えるようにする改造

に公開

はじめに

Quartz4は素晴らしい静的サイトジェネレーターですが、デフォルトではcontentフォルダ内の画像のみを処理します。一方、Zennでは/imagesフォルダに画像を配置するのが標準的です。

両方のプラットフォームでコンテンツを管理する場合、画像パスの整合性を保つことが重要になります。今回は、Quartz4でZenn互換の画像パス(/images/画像名.png)を使えるように改造した話です。

問題:Quartz4のデフォルトの画像管理

Quartz4のデフォルト設定では、以下のような制限があります:

  • 画像はcontentフォルダ内に配置する必要がある
  • ![](images/sample.png)のような相対パスでの参照が前提
  • プロジェクトルートの/imagesフォルダは処理されない

これにより、Zennで使用している![](/images/sample.png)形式の画像参照が機能せず、コンテンツの移植時に大量のパス修正が必要になってしまいます。

解決方法:assets.tsプラグインの改造

Quartz4のquartz/plugins/emitters/assets.tsファイルを改造して、プロジェクトルートの/imagesフォルダからも画像を読み込めるようにしました。

主な変更点

1. filesToCopy関数の拡張

const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
  // 既存:contentフォルダの非MDファイルを取得
  const contentFiles = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
  
  // 新規:rootのimagesフォルダからもファイルを取得
  const rootImagesPath = path.resolve(path.dirname(argv.directory), "images")
  let imagesFiles: FilePath[] = []
  
  if (fs.existsSync(rootImagesPath)) {
    const imagesGlob = await glob("**", rootImagesPath as FilePath, cfg.configuration.ignorePatterns)
    // パス区切り文字を統一してimages/プレフィックスを付与
    imagesFiles = imagesGlob.map(fp => path.posix.join("images", fp) as FilePath)
  }
  
  return [...contentFiles, ...imagesFiles]
}

2. copyFile関数の改良

const copyFile = async (argv: Argv, fp: FilePath) => {
  let src: FilePath
  
  // Windows/Linux対応:パス区切り文字を正規化
  const normalizedFp = fp.replace(/\\/g, '/')
  
  // images/で始まるファイルはrootディレクトリから取得
  if (normalizedFp.startsWith("images/")) {
    const rootDir = path.dirname(argv.directory)
    src = joinSegments(rootDir, fp) as FilePath
  } else {
    // 従来通りcontentディレクトリから取得
    src = joinSegments(argv.directory, fp) as FilePath
  }

  const name = slugifyFilePath(fp)
  const dest = joinSegments(argv.output, name) as FilePath

  // ディレクトリ作成とファイルコピー
  const dir = path.dirname(dest) as FilePath
  await fs.promises.mkdir(dir, { recursive: true })
  await fs.promises.copyFile(src, dest)
  return dest
}

3. partialEmit関数でのホットリロード対応

async *partialEmit(ctx, _content, _resources, changeEvents) {
  for (const changeEvent of changeEvents) {
    const ext = path.extname(changeEvent.path)
    if (ext === ".md") continue

    // rootのimagesフォルダの変更を検出
    const rootImagesPath = path.resolve(path.dirname(ctx.argv.directory), "images")
    const isRootImageFile = changeEvent.path.startsWith(rootImagesPath)
    
    if (changeEvent.type === "add" || changeEvent.type === "change") {
      if (isRootImageFile) {
        // 絶対パスを相対パスに変換
        const relativePath = path.relative(path.dirname(ctx.argv.directory), changeEvent.path) as FilePath
        yield copyFile(ctx.argv, relativePath)
      } else {
        yield copyFile(ctx.argv, changeEvent.path)
      }
    } else if (changeEvent.type === "delete") {
      // 削除処理も同様に対応
      let filePath: FilePath
      if (isRootImageFile) {
        filePath = path.relative(path.dirname(ctx.argv.directory), changeEvent.path) as FilePath
      } else {
        filePath = changeEvent.path
      }
      
      const name = slugifyFilePath(filePath)
      const dest = joinSegments(ctx.argv.output, name) as FilePath
      await fs.promises.unlink(dest)
    }
  }
}

技術的なポイント

Windows/Linux対応

パス区切り文字の違い(Windows: \, Linux: /)に対応するため、path.posix.join()と正規化処理を使用しました。

ホットリロード対応

開発時のnpx quartz syncでも、rootの/imagesフォルダの変更を即座に反映できるようにpartialEmit関数も対応しました。

既存機能の保持

従来のcontentフォルダ内の画像処理は維持しつつ、新機能を追加する形で実装しました。

結果

改造後は以下のようになりました:

  • /images/sample.png形式でZenn互換の画像参照が可能
  • content/images/sample.png形式の従来の方法も継続利用可能
  • ✅ ホットリロード(npx quartz sync)でも正常動作
  • ✅ Windows/Linux両環境で動作確認済み

まとめ

この改造により、Quartz4でZennと同じ画像パス構造を使用できるようになりました。これで:

  1. コンテンツの移植が簡単: Zennの記事をそのままQuartz4で使用可能
  2. 管理の一元化: 画像を一箇所(/images)で管理
  3. 開発効率向上: パス修正の手間が不要

同様の課題を抱えている方の参考になれば幸いです。

参考リンク

Discussion