Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active July 17, 2025 13:47
Show Gist options
  • Save mizchi/b717fe720a5c37742d925fa518fe255b to your computer and use it in GitHub Desktop.
Save mizchi/b717fe720a5c37742d925fa518fe255b to your computer and use it in GitHub Desktop.
my personal blog command
#!/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