自動化

コンテンツ生成からデプロイまで、ドキュメントタスクを自動化し、ドキュメントワークフローをより効率的で信頼性の高いものにする方法を発見します。

組み込みスクリプト

ドキュメント作成

適切な構造で新しいドキュメントを自動作成:

# 日本語ガイドドキュメントを作成
node scripts/create-document.js sample-docs ja v2 guide "New Feature Overview"

# コンポーネント例を作成
node scripts/create-document.js sample-docs ja v2 components "Sidebar Migration"

# 高度なトピックを作成
node scripts/create-document.js sample-docs ja v2 advanced "Automation Recipes"

スクリプトは自動的に:

  • ディレクトリ構造を作成
  • 適切なメタデータでフロントマターを生成
  • ナビゲーションリンクを設定
  • コンテンツテンプレートを提供

バージョン管理

新しいドキュメントバージョンを作成:

# 新しいバージョンを作成(前バージョンから自動コピー)
node scripts/create-version.js sample-docs v3

# インタラクティブに表示名やコピー方法を選ぶ
node scripts/create-version.js sample-docs v3 --interactive

# 空のディレクトリだけ用意して自分で構成を作る
node scripts/create-version.js sample-docs v3 --no-copy

サイドバー生成

サイドバーナビゲーションを自動生成:

# すべてのアプリを対象にサイドバーを生成
pnpm build:sidebar

# sample-docs だけを対象に生成
pnpm build:sidebar-selective --projects=sample-docs

カテゴリ検証

多言語のカテゴリキーが一致しているかを検証:

# apps/ 配下の project.config.json を一括検証
node scripts/validate-category-structure.js

CI/CDパイプライン

GitHub Actions

libx では .github/workflows/cloudflare-pages-deploy.yml が品質チェックと Cloudflare Pages へのデプロイを一括管理しています。主要フローは次のとおりです。

jobs:
  quality-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
        continue-on-error: true
      - run: pnpm prettier --check .
        continue-on-error: true

  deploy:
    needs: quality-check
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build:sidebar
      - run: pnpm build
      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          command: pages deploy dist --project-name libx

自動品質チェック

現在は quality-check ジョブ内で Lint と Prettier のチェックのみを行い、失敗しても PR 全体を止めないよう continue-on-error: true で実行しています。リンク検証やアクセシビリティチェックを追加したい場合は、同じワークフローに node scripts/check-links.js などを追記して段階的に強化すると安全です。

カスタム自動化スクリプト

コンテンツ検証

ドキュメント品質を自動検証:

// scripts/validate-content.js
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

class ContentValidator {
  constructor(contentDir) {
    this.contentDir = contentDir;
    this.errors = [];
  }
  
  async validateAll() {
    const files = await this.getAllMdxFiles();
    
    for (const file of files) {
      await this.validateFile(file);
    }
    
    return this.errors;
  }
  
  async validateFile(filePath) {
    const content = fs.readFileSync(filePath, 'utf-8');
    const { data: frontmatter, content: body } = matter(content);
    
    // フロントマター検証
    this.validateFrontmatter(filePath, frontmatter);
    
    // コンテンツ検証
    this.validateContent(filePath, body);
    
    // リンク検証
    await this.validateLinks(filePath, body);
  }
  
  validateFrontmatter(filePath, frontmatter) {
    const required = ['title', 'description'];
    
    for (const field of required) {
      if (!frontmatter[field]) {
        this.errors.push({
          file: filePath,
          type: 'frontmatter',
          message: `Missing required field: ${field}`
        });
      }
    }
  }
  
  validateContent(filePath, content) {
    // 空のセクション検査
    const emptySections = content.match(/##\s+[^#\n]+\n\s*##/g);
    if (emptySections) {
      this.errors.push({
        file: filePath,
        type: 'content',
        message: 'Empty section detected'
      });
    }
    
    // TODO項目の検査
    const todos = content.match(/TODO:|FIXME:|XXX:/gi);
    if (todos) {
      this.errors.push({
        file: filePath,
        type: 'content',
        message: `Found ${todos.length} TODO items`
      });
    }
  }
  
  async validateLinks(filePath, content) {
    const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
    let match;
    
    while ((match = linkRegex.exec(content)) !== null) {
      const url = match[2];
      
      if (url.startsWith('/')) {
        // 内部リンクの検証
        const exists = await this.checkInternalLink(url);
        if (!exists) {
          this.errors.push({
            file: filePath,
            type: 'link',
            message: `Broken internal link: ${url}`
          });
        }
      }
    }
  }
  
  async getAllMdxFiles() {
    // MDXファイルを再帰的に検索
    const files = [];
    
    function scanDirectory(dir) {
      const items = fs.readdirSync(dir);
      
      for (const item of items) {
        const fullPath = path.join(dir, item);
        const stat = fs.statSync(fullPath);
        
        if (stat.isDirectory()) {
          scanDirectory(fullPath);
        } else if (item.endsWith('.mdx')) {
          files.push(fullPath);
        }
      }
    }
    
    scanDirectory(this.contentDir);
    return files;
  }
}

// 使用例
const validator = new ContentValidator('apps/sample-docs/src/content/docs');
const errors = await validator.validateAll();

if (errors.length > 0) {
  console.error('Validation errors found:');
  errors.forEach(error => {
    console.error(`${error.file}: ${error.message}`);
  });
  process.exit(1);
}

画像最適化

画像を自動最適化:

// scripts/optimize-images.js
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';

class ImageOptimizer {
  constructor(publicDir) {
    this.publicDir = publicDir;
    this.sizes = [320, 640, 1024, 1920];
    this.formats = ['webp', 'avif'];
  }
  
  async optimizeAll() {
    const images = await this.findImages();
    
    for (const image of images) {
      await this.optimizeImage(image);
    }
  }
  
  async optimizeImage(imagePath) {
    const basename = path.basename(imagePath, path.extname(imagePath));
    const dirname = path.dirname(imagePath);
    
    // 元の画像情報を取得
    const metadata = await sharp(imagePath).metadata();
    
    // レスポンシブサイズを生成
    for (const size of this.sizes) {
      if (metadata.width && metadata.width > size) {
        for (const format of this.formats) {
          const outputPath = path.join(
            dirname, 
            `${basename}-${size}w.${format}`
          );
          
          await sharp(imagePath)
            .resize(size)
            .toFormat(format, { quality: 80 })
            .toFile(outputPath);
            
          console.log(`Generated: ${outputPath}`);
        }
      }
    }
  }
  
  async findImages() {
    const images = [];
    const supportedFormats = ['.jpg', '.jpeg', '.png', '.gif'];
    
    function scanDirectory(dir) {
      const items = fs.readdirSync(dir);
      
      for (const item of items) {
        const fullPath = path.join(dir, item);
        const stat = fs.statSync(fullPath);
        
        if (stat.isDirectory()) {
          scanDirectory(fullPath);
        } else if (supportedFormats.includes(path.extname(item).toLowerCase())) {
          images.push(fullPath);
        }
      }
    }
    
    scanDirectory(this.publicDir);
    return images;
  }
}

SEO最適化

SEOメタデータを自動生成:

// scripts/generate-seo.js
import fs from 'fs';
import matter from 'gray-matter';

class SEOGenerator {
  constructor(contentDir, baseUrl) {
    this.contentDir = contentDir;
    this.baseUrl = baseUrl;
  }
  
  async generateSitemap() {
    const pages = await this.getAllPages();
    const sitemap = this.createSitemap(pages);
    
    fs.writeFileSync('public/sitemap.xml', sitemap);
    console.log('Sitemap generated: public/sitemap.xml');
  }
  
  async generateRobots() {
    const robots = `User-agent: *
Allow: /

Sitemap: ${this.baseUrl}/sitemap.xml`;
    
    fs.writeFileSync('public/robots.txt', robots);
    console.log('Robots.txt generated: public/robots.txt');
  }
  
  createSitemap(pages) {
    const urls = pages.map(page => `
  <url>
    <loc>${this.baseUrl}${page.url}</loc>
    <lastmod>${page.lastmod}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>`).join('');
    
    return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
  }
  
  async getAllPages() {
    // ページ情報を収集
    const pages = [];
    
    // 実装...
    
    return pages;
  }
}

監視とアラート

パフォーマンス監視

Lighthouseを使用した自動パフォーマンス測定:

// scripts/performance-check.js
import lighthouse from 'lighthouse';
import chromeLauncher from 'chrome-launcher';

class PerformanceMonitor {
  async checkSite(url) {
    const chrome = await chromeLauncher.launch({chromeFlags: ['--headless']});
    
    const options = {
      logLevel: 'info',
      output: 'json',
      onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
      port: chrome.port,
    };
    
    const runnerResult = await lighthouse(url, options);
    
    await chrome.kill();
    
    return this.processResults(runnerResult);
  }
  
  processResults(results) {
    const scores = results.lhr.categories;
    const report = {
      performance: scores.performance.score * 100,
      accessibility: scores.accessibility.score * 100,
      bestPractices: scores['best-practices'].score * 100,
      seo: scores.seo.score * 100
    };
    
    // アラート条件
    const thresholds = {
      performance: 90,
      accessibility: 95,
      bestPractices: 90,
      seo: 95
    };
    
    const alerts = [];
    for (const [metric, score] of Object.entries(report)) {
      if (score < thresholds[metric]) {
        alerts.push(`${metric}: ${score} (threshold: ${thresholds[metric]})`);
      }
    }
    
    return { scores: report, alerts };
  }
}

デッドリンクチェック

// scripts/check-links.js
import fetch from 'node-fetch';
import fs from 'fs';

class LinkChecker {
  constructor() {
    this.brokenLinks = [];
    this.checkedUrls = new Set();
  }
  
  async checkAllLinks() {
    const files = await this.getAllMdxFiles();
    
    for (const file of files) {
      await this.checkFileLinks(file);
    }
    
    return this.brokenLinks;
  }
  
  async checkFileLinks(filePath) {
    const content = fs.readFileSync(filePath, 'utf-8');
    const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
    let match;
    
    while ((match = linkRegex.exec(content)) !== null) {
      const url = match[2];
      
      if (url.startsWith('http')) {
        await this.checkExternalLink(url, filePath);
      } else if (url.startsWith('/')) {
        await this.checkInternalLink(url, filePath);
      }
    }
  }
  
  async checkExternalLink(url, filePath) {
    if (this.checkedUrls.has(url)) return;
    
    this.checkedUrls.add(url);
    
    try {
      const response = await fetch(url, { method: 'HEAD', timeout: 5000 });
      if (!response.ok) {
        this.brokenLinks.push({
          file: filePath,
          url,
          status: response.status
        });
      }
    } catch (error) {
      this.brokenLinks.push({
        file: filePath,
        url,
        error: error.message
      });
    }
  }
}

統合とワークフロー

Slackとの統合

ビルド結果をSlackに通知:

// scripts/slack-notify.js
import { WebClient } from '@slack/web-api';

class SlackNotifier {
  constructor(token, channel) {
    this.slack = new WebClient(token);
    this.channel = channel;
  }
  
  async notifyBuildSuccess(buildInfo) {
    await this.slack.chat.postMessage({
      channel: this.channel,
      text: 'ドキュメントビルドが成功しました!',
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: `✅ *ドキュメントビルド成功*\n\n*ブランチ:* ${buildInfo.branch}\n*コミット:* ${buildInfo.commit}\n*ビルド時間:* ${buildInfo.duration}秒`
          }
        },
        {
          type: 'actions',
          elements: [
            {
              type: 'button',
              text: {
                type: 'plain_text',
                text: 'ドキュメントを見る'
              },
              url: buildInfo.url
            }
          ]
        }
      ]
    });
  }
  
  async notifyBuildFailure(error) {
    await this.slack.chat.postMessage({
      channel: this.channel,
      text: 'ドキュメントビルドが失敗しました',
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: `❌ *ドキュメントビルド失敗*\n\n*エラー:* ${error.message}`
          }
        }
      ]
    });
  }
}

自動翻訳統合

機械翻訳APIとの統合:

// scripts/auto-translate.js
import { translate } from '@google-cloud/translate';

class AutoTranslator {
  constructor(credentials) {
    this.translate = new translate.Translate(credentials);
  }
  
  async translateDocument(sourcePath, targetPath, targetLang) {
    const content = fs.readFileSync(sourcePath, 'utf-8');
    const { data: frontmatter, content: body } = matter(content);
    
    // フロントマターを翻訳
    const translatedFrontmatter = await this.translateFrontmatter(frontmatter, targetLang);
    
    // コンテンツを翻訳
    const translatedBody = await this.translateContent(body, targetLang);
    
    // 翻訳されたドキュメントを作成
    const translatedContent = matter.stringify(translatedBody, translatedFrontmatter);
    
    fs.writeFileSync(targetPath, translatedContent);
  }
  
  async translateFrontmatter(frontmatter, targetLang) {
    const translated = { ...frontmatter };
    
    if (frontmatter.title) {
      translated.title = await this.translateText(frontmatter.title, targetLang);
    }
    
    if (frontmatter.description) {
      translated.description = await this.translateText(frontmatter.description, targetLang);
    }
    
    return translated;
  }
  
  async translateText(text, targetLang) {
    const [translation] = await this.translate.translate(text, targetLang);
    return translation;
  }
}

次のステップ

自動化の基本を理解したら: