📝
Github Codespacesを使ったZenn執筆環境を整える
Github Codespacesについて
以前こちらで紹介しています
Zenn CLIについて
Zennの記事や本をGithubリポジトリで管理することが出来ます
なぜGithub CodespacesでZennの記事を執筆するのか
結論から言うとiPadと手元のPC共に同じ執筆環境にするためです
メリット・デメリットそれぞれありますが、使い方次第ではデメリットはそこまで問題ありません
メリット
- 環境を統一できる
- Github Codespacesでは起動時にユーザが指定した設定を反映してくれるためPCとiPadで環境を統一できる
 
 - ネット環境があればどこでも執筆できる
- 自宅ではPCで執筆が可能
 - カフェではiPadで執筆が可能
 
 - 荷物を軽くできる
- PCは持ち歩かずiPadとキーボードだけで済む
 
 
デメリット
- ネット環境がないと執筆出来ない
- 当たり前ですが、Githubの機能を使うためネットに繋がらなければ執筆出来ません
- Wi-Fiがあるカフェを選べば良し
 - 自分はたまの気分転換、行き付けのカフェであれば問題なし
 
 
 - 当たり前ですが、Githubの機能を使うためネットに繋がらなければ執筆出来ません
 - 画面が小さくなる
- iPad、それもiPad miniを使用するため画面が小さく見づらい
- 気分転換がメインのため長時間やるなら自宅でやれば良い
 - 人目を気にしないならARグラスなどを使って執筆するのもあり
 
 
 - iPad、それもiPad miniを使用するため画面が小さく見づらい
 
環境構築
devcontainerの設定
Github Codespacesにインストールする拡張機能や転送ポートなどの設定をしています
.devcontainer/devcontainer.json
{
  "name": "Docker in Docker Development",
  "customizations": {
    "vscode": {
      "extensions": [
        "Github.copilot",
        "Github.copilot-chat",
        "streetsidesoftware.code-spell-checker",
        "vscodevim.vim",
        "k--kato.intellij-idea-keybindings",
        "mhutchie.git-graph",
        "eamodio.gitlens",
        "pkief.material-icon-theme",
        "ms-azuretools.vscode-docker",
        "negokaz.zenn-editor",
        "mushan.vscode-paste-image"
      ],
      "settings": {
        "terminal.integrated.profiles.linux": {
          "bash": {
            "path": "/bin/bash"
          }
        },
        "vscode": {
          "workbench.colorTheme": "Github Dark",
          "workbench.iconTheme": "material-icon-theme"
        },
        "terminal.integrated.defaultProfile.linux": "bash"
      }
    }
  },
  "forwardPorts": [
    8000
  ],
  "portsAttributes": {
    "8000": {
      "label": "zenn preview",
      "onAutoForward": "notify"
    }
  },
  "initializeCommand": "npm install"
}
vscodeの設定
vscodeの設定を記載しています
基本的には仕事やローカルのvscodeと同じしています
.vscode/settings.json
{
  "workbench.colorTheme": "GitHub Dark",
  "workbench.iconTheme": "material-icon-theme",
  "editor.padding.top": 16,
  "diffEditor.renderSideBySide": false,
  "editor.colorDecorators": false,
  "editor.formatOnPaste": true,
  "editor.formatOnType": true,
  "editor.minimap.renderCharacters": false,
  "editor.minimap.showSlider": "always",
  "editor.multiCursorModifier": "ctrlCmd",
  "editor.renderControlCharacters": true,
  "editor.renderLineHighlight": "all",
  "editor.renderWhitespace": "all",
  "editor.snippetSuggestions": "top",
  "editor.tabSize": 2,
  "editor.wordWrap": "on",
  "emmet.showSuggestionsAsSnippets": true,
  "emmet.triggerExpansionOnTab": true,
  "emmet.variables": {
    "lang": "ja"
  },
  "[markdown]": {
    "files.trimTrailingWhitespace": false
  },
  "html.format.contentUnformatted": "pre, code, textarea, title, h1, h2, h3, h4, h5, h6, p",
  "html.format.extraLiners": "",
  "html.format.unformatted": null,
  "html.format.wrapLineLength": 0,
  "search.exclude": {
    "**/tmp": true
  },
  "window.title": "${activeEditorMedium}${separator}${rootName}",
  "workbench.editor.labelFormat": "short",
  "workbench.editor.tabSizing": "shrink",
  "workbench.startupEditor": "none",
  "explorer.confirmDragAndDrop": false,
  "explorer.confirmDelete": false,
  "files.autoSaveDelay": 3000,
  "files.associations": {
    "*.vue": "vue"
  },
  "javascript.updateImportsOnFileMove.enabled": "always",
  "editor.minimap.scale": 2,
  "editor.minimap.maxColumn": 60,
  "editor.suggestSelection": "first",
  "files.exclude": {
    "**/*.map": true,
    "**/node_modules": true
  },
  "editor.semanticTokenColorCustomizations": {},
  "editor.bracketPairColorization.enabled": true,
  "workbench.editor.enablePreview": false,
  "window.zoomLevel": -1,
  "files.eol": "\n",
  "editor.quickSuggestions": {
    "strings": true
  },
  "editor.parameterHints.cycle": true,
  "javascript.inlayHints.parameterNames.enabled": "all",
  "javascript.inlayHints.parameterTypes.enabled": true,
  "typescript.inlayHints.parameterNames.enabled": "all",
  "typescript.inlayHints.parameterTypes.enabled": true,
  "workbench.editor.enablePreviewFromQuickOpen": false,
  "editor.copyWithSyntaxHighlighting": false,
  "workbench.tree.renderIndentGuides": "always",
  "workbench.tree.indent": 16,
  "terminal.integrated.copyOnSelection": true,
  "terminal.integrated.rightClickBehavior": "paste",
  "workbench.colorCustomizations": {
    "editorCursor.foreground": "#54DEFD",
    "terminalCursor.foreground": "#54DEFD"
  },
  "editor.cursorBlinking": "expand",
  "editor.cursorSmoothCaretAnimation": "on",
  "debug.console.fontSize": 13,
  "chat.editor.fontSize": 13,
  "markdown.preview.fontSize": 13,
  "scm.inputFontSize": 15,
  "editor.fontSize": 13,
  "editor.fontLigatures": false,
  "vim.useSystemClipboard": true,
  "editor.codeLensFontFamily": "Moralerspace",
  "vim.highlightedyank.color": "rgba(250, 240, 170, 1)",
  "editor.insertSpaces": true,
  "files.insertFinalNewline": true,
  "files.autoSave": "afterDelay",
  "files.trimFinalNewlines": true,
  "files.trimTrailingWhitespace": true,
  "remote.extensionKind": {
    "scodevim.vim": [
      "workspace"
    ],
  },
  "git.useEditorAsCommitInput": false,
  "editor.unicodeHighlight.ambiguousCharacters": false,
  "github.copilot.enable": {
    "*": true,
    "plaintext": false,
    "markdown": false,
    "scminput": false
  },
  "editor.formatOnSave": true,
  "editor.editContext": false,
  "cSpell.words": [],
  "vim.useCtrlKeys": true,
  "markdown.copyFiles.destination": {
    "**/*.md": "/images/${documentBaseName}/"
  },
  "markdown.editor.drop.enabled": "always",
  "markdown.editor.filePaste.enabled": "always",
  "markdown.editor.filePaste.copyIntoWorkspace": "mediaFiles",
  "markdown.suggest.paths.enabled": true,
  "markdown.copyFiles.overwriteBehavior": "overwrite",
  "markdown.editor.paste.linkToFileWhenCreated": true,
}
タスクの設定
タスクとして2つを実行しています
- Zennのプレビューサーバーを立てる
 - markdownに画像に貼り付けた際にZenn用にパスを設定し直す処理
 
.vscode/tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "run zenn preview",
      "type": "shell",
      "command": "npx zenn preview",
      "isBackground": true,
      "presentation": {
        "reveal": "never",
        "panel": "new",
        "focus": false
      },
      "runOptions": {
        "runOn": "folderOpen"
      }
    },
    {
      "label": "run md watch",
      "type": "shell",
      "command": "npm run watch:all",
      "isBackground": true,
      "presentation": {
        "reveal": "never",
        "panel": "new",
        "focus": false
      },
      "runOptions": {
        "runOn": "folderOpen"
      }
    }
  ]
}
Zennのプレビューサーバーを立てるはそのままですが、markdownに画像に貼り付けた際にZenn用にパスを設定し直す処理は理由があってタスクを設定しています
markdownに画像に貼り付けた際にZenn仕様のパスを設定し直す処理
nodamushiさんが出している拡張機能がZenn仕様のパスに自動変換してくれますが、Github Codespacesでは正しく動作してくれません
そのため自分自身で実装して動作させることで対応しています
ソースコードは下記になります
watch-markdown.js
import fs from "fs";
import chokidar from "chokidar";
console.log("Starting Markdown file watcher...");
// articlesディレクトリ内のMarkdownファイルを監視
const watcher = chokidar.watch('./articles/*.md', {
  persistent: true,
  ignoreInitial: true
});
watcher.on('change', (filePath) => {
  console.log(`Markdown file changed: ${filePath}`);
  // ファイル内容を読み取り
  setTimeout(() => {
    try {
      let content = fs.readFileSync(filePath, 'utf8');
      let updated = false;
      // PNG拡張子をWebPに置換し、../パスを/に正規化
      const originalContent = content;
      content = content.replace(/(\.\.\/)?(images\/[^)]+)\.png/g, (match, prefix, imagePath) => {
        const pngPath = imagePath + '.png';
        const webpPath = imagePath + '.webp';
        // 対応するwebpファイルが存在するかチェック
        if (fs.existsSync(webpPath)) {
          console.log(`Replacing ${pngPath} with ${webpPath} in ${filePath}`);
          console.log(`Converting '../' to '/' in path`);
          updated = true;
          // ../を/に変換してwebpパスを返す
          return '/' + webpPath;
        }
        return match;
      });
      // 既にwebpだが../が付いているケースも処理
      content = content.replace(/\.\.\/(images\/[^)]+\.webp)/g, (match, imagePath) => {
        console.log(`Converting '../' to '/' in webp path: ${imagePath} in ${filePath}`);
        updated = true;
        return '/' + imagePath;
      });
      if (updated && content !== originalContent) {
        fs.writeFileSync(filePath, content, 'utf8');
        console.log(`Updated image references in: ${filePath}`);
      }
    } catch (error) {
      console.error(`Error processing ${filePath}:`, error.message);
    }
  }, 1000); // 1秒の遅延
});
console.log("Watching for Markdown file changes...");
console.log("Press Ctrl+C to stop");
// プロセス終了時の処理
process.on('SIGINT', () => {
  console.log('\nStopping Markdown watcher...');
  watcher.close();
  process.exit(0);
});
update-markdown-image-refs.js
import fs from "fs";
import path from "path";
const webpPath = process.argv[2];
const originalPath = process.argv[3];
if (!webpPath || !originalPath) {
  console.log("Usage: node update-markdown-image-refs.js <webp-path> <original-path>");
  process.exit(0);
}
console.log(`Updating references from: ${originalPath}`);
console.log(`To: ${webpPath}`);
// articlesディレクトリ内のMarkdownファイルを検索
const articlesDir = './articles';
if (!fs.existsSync(articlesDir)) {
  console.log("Articles directory not found");
  process.exit(0);
}
const markdownFiles = fs.readdirSync(articlesDir)
  .filter(file => file.endsWith('.md'))
  .map(file => path.join(articlesDir, file));
// 各Markdownファイルで画像パスを更新
markdownFiles.forEach(mdFile => {
  try {
    let content = fs.readFileSync(mdFile, 'utf8');
    const originalFileName = path.basename(originalPath);
    const webpFileName = path.basename(webpPath);
    console.log(`Processing file: ${mdFile}`);
    console.log(`Looking for: ${originalFileName}`);
    console.log(`Replacing with: ${webpFileName}`);
    console.log(`Content preview: ${content.substring(0, 200)}...`);
    // 複数のパターンでマッチング
    const originalFileNameEscaped = originalFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    // より包括的なパターンでマッチング
    let updated = false;
    // シンプルな文字列置換アプローチを使用
    if (content.includes(originalFileName)) {
      console.log(`Found ${originalFileName} in content`);
      const beforeReplace = content;
      content = content.replace(new RegExp(originalFileNameEscaped, 'g'), webpFileName);
      if (beforeReplace !== content) {
        updated = true;
        console.log(`Successfully replaced ${originalFileName} with ${webpFileName}`);
      }
    } else {
      console.log(`${originalFileName} not found in content`);
    }
    if (updated) {
      fs.writeFileSync(mdFile, content, 'utf8');
      console.log(`Updated image references in: ${mdFile}`);
    } else {
      console.log(`No references found in: ${mdFile}`);
    }
  } catch (error) {
    console.error(`Error updating ${mdFile}:`, error.message);
  }
});
convert-to-webp.js
import fs from "fs";
import path from "path";
import sharp from "sharp";
import { exec } from "child_process";
async function convertImage() {
  // コマンドライン引数を結合して完全なファイルパスを取得
  const args = process.argv.slice(2);
  if (args.length === 0) process.exit(0);
  // 引数を結合してファイルパスを再構築
  const input = args.join(' ');
  console.log(`Processing file: ${input}`);
  // ファイルの書き込み完了を待機する関数
  async function waitForFileReady(filePath, maxWaitTime = 10000) {
    const startTime = Date.now();
    let lastSize = 0;
    while (Date.now() - startTime < maxWaitTime) {
      try {
        const stats = fs.statSync(filePath);
        const currentSize = stats.size;
        // ファイルサイズが安定している(1秒間変化なし)かつ0バイトでない場合
        if (currentSize > 0 && currentSize === lastSize) {
          await new Promise(resolve => setTimeout(resolve, 1000));
          const newStats = fs.statSync(filePath);
          if (newStats.size === currentSize) {
            console.log(`File ready: ${filePath} (${currentSize} bytes)`);
            return true;
          }
        }
        lastSize = currentSize;
        await new Promise(resolve => setTimeout(resolve, 500));
      } catch (error) {
        // ファイルがまだ作成中の場合
        await new Promise(resolve => setTimeout(resolve, 500));
      }
    }
    console.log(`Timeout waiting for file to be ready: ${filePath}`);
    return false;
  }
  // ファイルが存在するかチェック
  if (!fs.existsSync(input)) {
    // ファイルが存在しない場合は静かに終了(削除イベントの場合)
    process.exit(0);
  }
  // ファイルの書き込み完了を待機
  console.log(`Waiting for file to be ready...`);
  const isReady = await waitForFileReady(input);
  if (!isReady) {
    console.log(`File not ready, skipping conversion: ${input}`);
    process.exit(0);
  }
  // 既にwebpファイルの場合は何もしない
  if (path.extname(input).toLowerCase() === '.webp') {
    console.log(`Already webp format: ${input}`);
    process.exit(0);
  }
  console.log(`Input extension: ${path.extname(input).toLowerCase()}`);
  const output = input.replace(/\.[^/.]+$/, ".webp");
  console.log(`Output path: ${output}`);
  // 入力と出力が同じ場合をチェック
  if (input === output) {
    console.log(`Input and output are the same: ${input}`);
    process.exit(0);
  }
  console.log(`Converting ${input} to ${output}...`);
  sharp(input)
    .webp({ quality: 85 })
    .toFile(output)
    .then(() => {
      console.log(`Sharp conversion completed. Starting markdown update...`);
      // Markdownファイルの画像参照を更新
      exec(`node update-markdown-image-refs.js "${output}" "${input}"`, (error, stdout, stderr) => {
        if (error) {
          console.error(`Error updating markdown references: ${error}`);
        } else if (stdout) {
          console.log(stdout);
        }
        // Markdown更新完了後に元ファイルを削除
        try {
          fs.unlinkSync(input);
          console.log(`Converted ${input} -> ${output}`);
        } catch (err) {
          console.error(`Error deleting original file: ${err}`);
        }
      });
    })
    .catch(err => console.error(`Error converting ${input}:`, err));
}
// 関数を実行
convertImage().catch(err => {
  console.error('Error in convertImage:', err);
  process.exit(1);
});
イメージ画像
PC

iPad

Discussion