Last active
July 17, 2025 13:47
-
-
Save mizchi/b717fe720a5c37742d925fa518fe255b to your computer and use it in GitHub Desktop.
my personal blog command
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S deno run -A | |
// Install deno install -Afg blog.ts | |
// edit your own zenn sync dir | |
const homeDir = Deno.env.get("HOME"); | |
if (!homeDir) { | |
$.log("HOME環境変数が設定されていません"); | |
Deno.exit(1); | |
} | |
const repoDir = join(homeDir, "mizchi", "zenn"); | |
const articlesDir = join(repoDir, "articles"); | |
/** | |
* blog - Mizchi Zenn Editor | |
* どのディレクトリにいても、zenn の記事をvscodeで開くためのCLIツール | |
* | |
* 使い方: | |
* $ blog <slug> # 記事を開きます(存在しなければ作成します) | |
* $ blog <slug> --update # または -u: 記事を開き、編集後にgit add/commit/pushを実行 | |
* $ blog list # 記事一覧をgit更新履歴順に表示 | |
* $ blog help # ヘルプを表示 | |
* $ blog # スラグ未指定時はgitの差分をコミットしてpush | |
* | |
* 存在しなければ雛形を作成して開きます | |
*/ | |
import { parse } from "https://deno.land/[email protected]/flags/mod.ts"; | |
import { join } from "https://deno.land/[email protected]/path/mod.ts"; | |
import { exists } from "https://deno.land/[email protected]/fs/exists.ts"; | |
import { walkSync } from "https://deno.land/[email protected]/fs/walk.ts"; | |
import $ from "https://deno.land/x/[email protected]/mod.ts"; | |
$.setPrintCommand(true); | |
// コマンドライン引数の解析 | |
const args = parse(Deno.args, { | |
boolean: ["update", "help"], | |
alias: { update: "u", help: "h" }, | |
}); | |
const command = args._[0] as string | undefined; | |
const slug = | |
command && command !== "list" && command !== "help" | |
? command.replace(/\.md$/, "") | |
: undefined; | |
const isUpdate = args.update || false; | |
// ヘルプ表示 | |
if (command === "help" || args.help) { | |
$.log(` | |
使い方: | |
blog <slug> 記事を開きます(存在しなければ作成します) | |
blog <slug> --update または -u: 記事を開き、編集後にgit add/commit/pushを実行します | |
blog list 記事一覧をgit更新履歴順に表示します | |
blog help このヘルプを表示します | |
blog スラグ未指定時はgitの差分をコミットしてpush | |
※ Zennのスラグは12文字以上必要です | |
`); | |
Deno.exit(0); | |
} | |
// 記事一覧の表示 | |
if (command === "list") { | |
await listArticles(); | |
Deno.exit(0); | |
} | |
// スラグが未指定の場合はgit操作を実行 | |
if (!command) { | |
$.log("スラグが指定されていないため、全ての変更をコミットします"); | |
await commitAllChanges(); | |
Deno.exit(0); | |
} | |
// スラグが指定された場合 | |
if (slug) { | |
// Zennのスラグは12文字以上であることを検証 | |
if (slug.length < 12) { | |
$.log( | |
`エラー: スラグは12文字以上である必要があります。現在: ${slug.length}文字` | |
); | |
$.log(`Zennの仕様により、記事のスラグは12文字以上を設定してください。`); | |
Deno.exit(1); | |
} | |
} | |
// 記事ファイルのパス | |
const filePath = join(articlesDir, `${slug}.md`); | |
// Git操作を実行する関数 | |
async function gitOperations(filePath: string) { | |
$.log("Git操作を実行します..."); | |
try { | |
// git操作をdaxで実行 | |
$.cd(repoDir); | |
// git add | |
try { | |
await $`git add ${filePath}`; | |
$.log("git add: 成功"); | |
} catch (error: unknown) { | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log(`git add に失敗しました: ${errorMessage}`); | |
return false; | |
} | |
// git commit | |
try { | |
await $`git commit -m ${"Update " + slug}`; | |
$.log("git commit: 成功"); | |
} catch (error: unknown) { | |
// 変更がない場合はエラーになるが、スキップして続行 | |
if ( | |
error instanceof Error && | |
error.message.includes("nothing to commit") | |
) { | |
$.log("コミットする変更はありませんでした"); | |
return true; | |
} | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log(`git commit に失敗しました: ${errorMessage}`); | |
return false; | |
} | |
// git push | |
try { | |
await $`git push origin`; | |
$.log("git push: 成功"); | |
} catch (error: unknown) { | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log(`git push に失敗しました: ${errorMessage}`); | |
return false; | |
} | |
$.log("Git操作が完了しました"); | |
return true; | |
} catch (error: unknown) { | |
const errorMessage = error instanceof Error ? error.message : String(error); | |
$.log("Git操作中にエラーが発生しました:", errorMessage); | |
return false; | |
} | |
} | |
// 記事一覧を表示する関数 | |
async function listArticles() { | |
try { | |
$.cd(repoDir); | |
const articlesDir = join(homeDir!, "mizchi", "zenn", "articles"); | |
// articles/*.md ファイルを取得 | |
const mdFiles: string[] = []; | |
for (const entry of walkSync(articlesDir)) { | |
if (entry.isFile && entry.name.endsWith(".md")) { | |
mdFiles.push(entry.path); | |
} | |
} | |
// コマンド表示を一時的にオフ | |
$.setPrintCommand(false); | |
// 各ファイルの情報を収集 | |
const fileInfos = await Promise.all( | |
mdFiles.map(async (filePath) => { | |
// Git のステータスを確認 | |
const statusOutput = await $`git status --porcelain ${filePath}`.text(); | |
const isUntracked = statusOutput.startsWith("??"); | |
const isModified = statusOutput.trim().length > 0 && !isUntracked; | |
// Git履歴で最終更新日時を取得(ファイルが追跡されていない場合は現在時刻) | |
let lastModified: Date; | |
let commitHash = ""; | |
if (isUntracked) { | |
// 追跡されていないファイルは現在時刻を使用 | |
lastModified = new Date(); | |
} else { | |
try { | |
// git log でファイルの最終コミット情報を取得 | |
const logOutput = | |
await $`git log -1 --format="%at %H" -- ${filePath}`.text(); | |
if (logOutput.trim()) { | |
const [timestamp, hash] = logOutput.trim().split(" "); | |
lastModified = new Date(parseInt(timestamp) * 1000); | |
commitHash = hash; | |
} else { | |
// Git履歴がない場合も現在時刻 | |
lastModified = new Date(); | |
} | |
} catch { | |
// エラー時も現在時刻 | |
lastModified = new Date(); | |
} | |
} | |
// ファイルのホームディレクトリからの相対パスを取得 | |
const relativePath = filePath.replace(homeDir!, "~"); | |
return { | |
path: filePath, | |
relativePath, | |
lastModified, | |
isUntracked, | |
isModified, | |
commitHash, | |
}; | |
}) | |
); | |
// コマンド表示を元に戻す | |
$.setPrintCommand(true); | |
// 更新日時でソート(変更ファイルと未追跡ファイルは最新として扱う) | |
fileInfos.sort((a, b) => { | |
// 変更ファイルと未追跡ファイルは最上位 | |
if (a.isUntracked || a.isModified) return -1; | |
if (b.isUntracked || b.isModified) return 1; | |
// それ以外は最終更新日時の降順 | |
return b.lastModified.getTime() - a.lastModified.getTime(); | |
}); | |
// 変更/未追跡ファイルと通常ファイルを分ける | |
const changedFiles = fileInfos.filter( | |
(info) => info.isUntracked || info.isModified | |
); | |
const unchangedFiles = fileInfos.filter( | |
(info) => !info.isUntracked && !info.isModified | |
); | |
// 結果の表示 | |
$.log("記事一覧 (Git更新履歴順):"); | |
// 変更/未追跡ファイルはすべて表示 | |
changedFiles.forEach((info) => { | |
let statusIndicator = ""; | |
let displayPath = info.relativePath; | |
// 色を変えて表示 | |
if (info.isUntracked) { | |
// 赤色で表示 | |
displayPath = `\x1b[31m${displayPath}\x1b[0m`; | |
statusIndicator = "[新規]"; | |
} else if (info.isModified) { | |
// 黄色で表示 | |
displayPath = `\x1b[33m${displayPath}\x1b[0m`; | |
statusIndicator = "[変更]"; | |
} | |
$.log(`${displayPath} ${statusIndicator}`); | |
}); | |
// 変更がないファイルは最新5件のみ表示 | |
const recentFiles = unchangedFiles.slice(0, 5); | |
// 変更ファイルがあった場合は区切り線を表示 | |
if (changedFiles.length > 0 && recentFiles.length > 0) { | |
$.log("---"); | |
} | |
// 最新の5件を表示 | |
recentFiles.forEach((info) => { | |
$.log(info.relativePath); | |
}); | |
// 省略されたファイル数があれば表示 | |
if (unchangedFiles.length > 5) { | |
$.log( | |
`\n...他 ${unchangedFiles.length - 5} ファイル(--list-all で全て表示)` | |
); | |
} | |
} catch (error: unknown) { | |
const errorMessage = error instanceof Error ? error.message : String(error); | |
$.log("記事一覧の取得中にエラーが発生しました:", errorMessage); | |
} | |
} | |
// すべての変更をコミットする関数 | |
async function commitAllChanges(): Promise<boolean> { | |
$.log("リポジトリの変更をコミットします..."); | |
try { | |
// git操作をdaxで実行 | |
$.cd(repoDir); | |
// 変更があるかチェック | |
const statusOutput = await $`git status --porcelain`.text(); | |
if (!statusOutput.trim()) { | |
$.log("コミットする変更はありませんでした"); | |
return true; | |
} | |
// git add | |
try { | |
await $`git add .`; | |
$.log("git add: 成功"); | |
} catch (error: unknown) { | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log(`git add に失敗しました: ${errorMessage}`); | |
return false; | |
} | |
// Gitのステータスを取得して変更のある記事をリストアップ | |
const changedFiles: string[] = []; | |
const statusLines = statusOutput.trim().split("\n"); | |
for (const line of statusLines) { | |
const filePath = line.substring(3).trim(); | |
if (filePath.startsWith("articles/") && filePath.endsWith(".md")) { | |
changedFiles.push(filePath.replace("articles/", "").replace(".md", "")); | |
} | |
} | |
// コミットメッセージの作成 | |
let commitMessage = "Update articles:"; | |
if (changedFiles.length > 0) { | |
commitMessage += " " + changedFiles.join(", "); | |
} else { | |
commitMessage = "Update zenn repository"; | |
} | |
// git commit | |
try { | |
await $`git commit -m ${commitMessage}`; | |
$.log("git commit: 成功"); | |
} catch (error: unknown) { | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log(`git commit に失敗しました: ${errorMessage}`); | |
return false; | |
} | |
// git push | |
try { | |
await $`git push origin`; | |
$.log("git push: 成功"); | |
} catch (error: unknown) { | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log(`git push に失敗しました: ${errorMessage}`); | |
return false; | |
} | |
$.log("Git操作が完了しました"); | |
return true; | |
} catch (error: unknown) { | |
const errorMessage = error instanceof Error ? error.message : String(error); | |
$.log("Git操作中にエラーが発生しました:", errorMessage); | |
return false; | |
} | |
} | |
// ファイルの変更をチェックする関数 | |
async function hasFileChanged(filePath: string): Promise<boolean> { | |
try { | |
// git status --porcelain を使用して、新規ファイルも含めた変更を検出 | |
$.cd(repoDir); | |
const output = await $`git status --porcelain ${filePath}`.text(); | |
// 出力があればファイルに変更がある(新規ファイルを含む) | |
return output.trim().length > 0; | |
} catch (error: unknown) { | |
const errorMessage = error instanceof Error ? error.message : String(error); | |
$.log("Git status の確認中にエラーが発生しました:", errorMessage); | |
// エラーの場合は、安全のために変更があったと判断 | |
return true; | |
} | |
} | |
// メイン処理 | |
async function main() { | |
try { | |
// ディレクトリの存在確認 | |
if (!(await exists(articlesDir))) { | |
$.log(`ディレクトリが存在しません: ${articlesDir}`); | |
Deno.exit(1); | |
} | |
// ファイルの存在確認 | |
const fileExists = await exists(filePath); | |
// ファイルが存在しない場合は雛形を作成 | |
if (!fileExists) { | |
$.log(`記事が存在しないため、雛形を作成します: ${filePath}`); | |
const template = `--- | |
title: ${slug} | |
emoji: "🤖" | |
type: "tech" # tech: 技術記事 / idea: アイデア | |
topics: [] | |
published: false | |
--- | |
`; | |
await Deno.writeTextFile(filePath, template); | |
$.log("雛形を作成しました"); | |
} else { | |
$.log(`既存の記事を開きます: ${filePath}`); | |
} | |
// VSCodeで開く (--wait オプションでVSCodeが閉じるまで待機) | |
$.log( | |
"VSCodeでファイルを開きます。編集が終わったらVSCodeを閉じてください..." | |
); | |
try { | |
// daxを使ってVSCodeを実行 | |
await $`code --wait ${filePath}`; | |
$.log("VSCodeが閉じられました"); | |
} catch (error: unknown) { | |
const errorMessage = | |
error instanceof Error ? error.message : String(error); | |
$.log("VSCodeでファイルを開くのに失敗しました:", errorMessage); | |
Deno.exit(1); | |
} | |
// updateオプションが指定されている場合 | |
if (isUpdate) { | |
// ファイルが変更されたか確認 | |
const hasChanged = await hasFileChanged(filePath); | |
if (hasChanged) { | |
$.log(`ファイル ${slug}.md の変更を検出しました`); | |
await gitOperations(filePath); | |
} else { | |
$.log("変更はありませんでした。Git操作はスキップします。"); | |
} | |
} | |
} catch (error: unknown) { | |
const errorMessage = error instanceof Error ? error.message : String(error); | |
$.log("エラーが発生しました:", errorMessage); | |
Deno.exit(1); | |
} | |
} | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment