🗂

npm pre/postスクリプトでビルドプロセスを自動化しよう

に公開

この記事について

この記事では、npm scriptsの機能の一つであるpre/postスクリプトについて解説します。実際のプロジェクトでの活用例を交えながら、ビルドプロセスの自動化について説明していきます。

npm scriptsの基本

Node.jsで実行するアプリケーション(React、Next.js、Astroなど)を構築する際に、npm run devnpm run buildといったコマンドを頻繁に使いますよね。

これらのコマンドは、package.jsonのscriptsセクションで以下のように定義されています:

package.json
{
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  }
}

私自身、これまでは初期構築時に自動生成されたこの設定をそのまま使用しており、カスタマイズすることはありませんでした。しかし、pre/postスクリプトを活用することで、開発効率が大幅に向上することを発見しました。

pre/postスクリプトとは

pre/postスクリプトは、メインコマンドの実行前後に自動的に他のコマンドを実行してくれるnpmの機能です。

参考:npm公式ドキュメント

基本的な設定例:

package.json
{
  "scripts": {
    "precompress": "echo 'ファイル圧縮前の準備処理'",
    "compress": "tar -czf archive.tar.gz src/",
    "postcompress": "echo 'ファイル圧縮後のクリーンアップ'"
  }
}

この設定により、以下のコマンドを実行するだけで:

npm run compress

自動的にprecompress → compress → postcompressの順番で実行されます。

実際の活用事例

私が開発しているプロジェクトでは、既存のMarkdown文書群を一括で編集し、Astro + Starlightで扱えるように変換するスクリプトがあります。具体的には以下の処理を行います:

  • ファイル名の最適化
  • frontmatterの自動生成
  • 本文中のリンクの書き換え

詳細はこちらの記事で解説しています。

課題と解決策

当初、このスクリプトは手動で実行する必要があり、以下の課題がありました:

  1. 作業の手間:毎回手動実行が必要
  2. CI/CD対応:自動化されていない
  3. ソースファイルの変更:元のMarkdown文書の内容が変更されてしまう

remarkプラグインでの解決も検討しましたが、ファイル名変更がサポートされていないため、prebuildスクリプトを採用することにしました。

実装例

package.json
{
  "scripts": {
    "prebuild": "node scripts/normalize-filenames.js",
    "predev": "node scripts/normalize-filenames.js",
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  }
}

この設定により、npm run buildnpm run devを実行するたびに、自動的にMarkdown文書の正規化処理が実行されます。

ファイル名正規化スクリプト(normalize-filenames.js)
scripts/normalize-filenames.js
// scripts/normalize-filenames.js
// Pre-buildフック用: ファイル名正規化 + frontmatter追加 + リンク修正を統合実行

import fs from 'fs';
import path from 'path';

/**
 * 指定されたディレクトリ内のすべてのMarkdownファイルを再帰的に取得する
 * @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') {
      result.push(fullPath);
    }
  }
  
  return result;
}

/**
 * ファイル名のベース部分を正規化する
 * @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 normalizeFileName(fileName) {
  const extension = '.md';
  const baseName = fileName.replace(/\.md$/i, '');
  const normalizedBaseName = normalizeBaseName(baseName);
  return normalizedBaseName + extension;
}

/**
 * ファイル名からタイトルを生成する
 * @param {string} fileName - ファイル名
 * @returns {string} タイトル
 */
function generateTitleFromFileName(fileName) {
  const baseName = fileName.replace(/\.md$/i, '');
  return normalizeBaseName(baseName);
}

/**
 * ファイルのfrontmatterからtitleを抽出する
 * @param {string} content - ファイルの内容
 * @returns {string|null} titleの値、見つからない場合はnull
 */
function extractTitleFromFrontmatter(content) {
  const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
  
  if (!frontmatterMatch) {
    return null;
  }
  
  const frontmatter = frontmatterMatch[1];
  const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
  
  if (!titleMatch) {
    return null;
  }
  
  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);
}

/**
 * frontmatterを追加する
 * @param {string} content - ファイルの内容
 * @param {string} title - タイトル
 * @param {string} slug - スラッグ
 * @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 - ファイルの内容
 * @returns {string} 更新されたファイルの内容
 */
function updateMarkdownLinks(content) {
  let updatedContent = content;
  let hasChanges = false;
  
  // Markdownリンク形式 [text](path) を検索
  const markdownLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
  
  updatedContent = updatedContent.replace(markdownLinkRegex, (match, linkText, linkPath) => {
    // HTTPリンクやアンカーのみのリンクはスキップ
    if ((linkPath.startsWith('http') && !linkPath.startsWith('https://github.com/Open-Fastpath-Master')) || 
        linkPath.startsWith('#') || 
        linkPath.startsWith('mailto:') ||
        linkPath.startsWith('/images/')) {
      return match;
    }
    
    // アンカー(#以降)を分離
    let [pathPart, anchorPart] = linkPath.split('#');
    let urlPath;
    
    if (pathPart.startsWith('/')) {
      // 既に絶対パスの場合
      if (pathPart.endsWith('/')) {
        urlPath = pathPart;
      } else if (pathPart.includes('.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')) {
        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) => {
    if ((linkPath.startsWith('http') && !linkPath.startsWith('https://github.com/Open-Fastpath-Master')) || 
        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')) {
        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;
  });
  
  if (!hasChanges) {
    console.log(`    リンク修正: 修正対象なし`);
  }
  
  return updatedContent;
}

/**
 * ファイル名正規化処理
 * @param {string} filePath - ファイルパス
 * @returns {string} 正規化後のファイルパス
 */
function processFileNameNormalization(filePath) {
  const fileName = path.basename(filePath);
  const dirName = path.dirname(filePath);
  const normalizedFileName = normalizeFileName(fileName);
  
  if (normalizedFileName !== fileName) {
    console.log(`    ファイル名正規化: ${fileName}${normalizedFileName}`);
    const newFilePath = path.join(dirName, normalizedFileName);
    
    try {
      fs.renameSync(filePath, newFilePath);
      return newFilePath;
    } catch (error) {
      console.error(`    ❌ ファイル名変更エラー: ${error.message}`);
      return filePath;
    }
  }
  
  return filePath;
}

/**
 * コンテンツ最適化処理
 * @param {string} filePath - ファイルパス
 * @returns {Object} 処理結果
 */
function processContentOptimization(filePath) {
  const fileName = path.basename(filePath);
  let contentModified = false;
  let frontmatterAdded = false;
  let titleUpdated = false;
  let linkModified = false;
  
  try {
    // ファイル内容を読み込み
    let content = fs.readFileSync(filePath, 'utf8');
    
    // ファイル名からタイトルとスラッグを生成
    const titleFromFileName = generateTitleFromFileName(fileName);
    const slugFromFileName = fileName.replace(/\.md$/i, '');
    
    // frontmatter処理
    if (!hasFrontmatter(content)) {
      console.log(`    frontmatter: 追加 - title: "${titleFromFileName}"`);
      content = addFrontmatter(content, titleFromFileName, slugFromFileName);
      contentModified = true;
      frontmatterAdded = true;
    } else {
      const existingTitle = extractTitleFromFrontmatter(content);
      
      if (!existingTitle) {
        console.log(`    title: 追加 - "${titleFromFileName}"`);
        content = updateFrontmatterTitle(content, titleFromFileName);
        contentModified = true;
        titleUpdated = true;
      } else {
        console.log(`    title: 保持 - "${existingTitle}"`);
      }
    }
    
    // リンク修正
    const contentWithUpdatedLinks = updateMarkdownLinks(content);
    if (content !== contentWithUpdatedLinks) {
      content = contentWithUpdatedLinks;
      contentModified = true;
      linkModified = true;
    }
    
    // ファイル更新
    if (contentModified) {
      fs.writeFileSync(filePath, content, 'utf8');
      console.log(`    ファイル内容: 更新済み`);
    } else {
      console.log(`    ファイル内容: 変更なし`);
    }
    
    return { frontmatterAdded, titleUpdated, linkModified };
    
  } catch (error) {
    console.error(`    エラー: ${error.message}`);
    return { frontmatterAdded: false, titleUpdated: false, linkModified: false };
  }
}

/**
 * メイン処理: src/content/docs内のMarkdownファイルを統合最適化
 */
async function main() {
  try {
    const targetDirectory = 'src/content/docs';
    
    if (!fs.existsSync(targetDirectory)) {
      console.log(`📁 対象ディレクトリが見つかりません: ${targetDirectory}`);
      return;
    }

    console.log('🚀 Markdown統合最適化処理開始...');
    console.log(`📂 対象ディレクトリ: ${targetDirectory}`);
    
    const allFiles = getAllFiles(targetDirectory);
    
    if (allFiles.length === 0) {
      console.log('📄 処理対象のMarkdownファイルが見つかりませんでした。');
      return;
    }
    
    let processedCount = 0;
    let fileRenamedCount = 0;
    let frontmatterAddedCount = 0;
    let titleUpdatedCount = 0;
    let linkModifiedCount = 0;
    let skippedCount = 0;
    
    for (const filePath of allFiles) {
      const fileName = path.basename(filePath);
      console.log(`  処理中: ${fileName}`);
      
      // 1. ファイル名正規化
      const newFilePath = processFileNameNormalization(filePath);
      if (newFilePath !== filePath) {
        fileRenamedCount++;
      }
      
      // 2. コンテンツ最適化
      const result = processContentOptimization(newFilePath);
      if (result.frontmatterAdded) frontmatterAddedCount++;
      if (result.titleUpdated) titleUpdatedCount++;
      if (result.linkModified) linkModifiedCount++;
      if (!result.frontmatterAdded && !result.titleUpdated && !result.linkModified) skippedCount++;
      
      processedCount++;
    }
    
    console.log('✅ Markdown統合最適化完了:');
    console.log(`  - 処理したファイル数: ${processedCount}`);
    console.log(`  - ファイル名変更: ${fileRenamedCount}`);
    console.log(`  - frontmatter追加: ${frontmatterAddedCount}`);
    console.log(`  - title更新: ${titleUpdatedCount}`);
    console.log(`  - リンク修正: ${linkModifiedCount}`);
    console.log(`  - 変更なし: ${skippedCount}`);
    
  } catch (error) {
    console.error('❌ エラー:', error.message);
    process.exit(1);
  }
}

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

実行方法

通常通りコマンドを実行するだけで、自動的に前処理(predevやprebuild)が実行されます:

npm run dev
# または
npm run build

pre/postスクリプトを無効にしたい場合は、--ignore-scriptsオプションを使用できます:

npm run dev --ignore-scripts

その他の活用事例

Reactアプリケーションのビルドプロセス

package.json
{
  "scripts": {
    "prebuild": "npm run clean && npm run lint && npm run type-check",
    "build": "react-scripts build",
    "postbuild": "npm run optimize && npm run deploy-assets"
  }
}

テストワークフローでの活用

package.json
{
  "scripts": {
    "pretest": "docker-compose -f docker-compose.test.yml up -d",
    "test": "jest --coverage",
    "posttest": "docker-compose -f docker-compose.test.yml down"
  }
}

デプロイメントワークフロー

package.json
{
  "scripts": {
    "predeploy": "npm run build && npm run test",
    "deploy": "gh-pages -d dist",
    "postdeploy": "npm run notify-slack"
  }
}

代替手段との比較

類似の機能として、CircleCIやGitHub ActionsなどのCI/CDツールが挙げられます。それぞれの特徴を整理すると:

手段 適用場面 メリット デメリット
pre/postスクリプト 開発環境での自動化 設定が簡単、ローカルでも動作 複雑なワークフローには不向き
CI/CDツール 本格的なワークフロー 高度な制御、外部サービス連携 設定が複雑、外部依存

より大規模で厳密なワークフローが必要な場合は、CI/CDツールの利用を検討することをお勧めします。

まとめ

pre/postスクリプトは、シンプルながら非常に強力な自動化機能です。適切に活用することで、開発効率の向上や作業ミスの削減につながります。

活用のポイント:

  • 単純な前処理・後処理の自動化に最適
  • ローカル開発環境とCI/CD環境の両方で動作
  • 設定が簡単で、既存のプロジェクトにも容易に導入可能

まずは小さな自動化から始めて、徐々に活用範囲を広げていくことをお勧めします。

Discussion