🗒️

【怠惰でもできる】ズボラ専用TIL環境構築

に公開

【怠惰でもできる】ズボラ専用TIL環境構築

毎日TIL(Today I Learned)を書くのは良い習慣ですが、ファイル名を考えたり、日付を入力したり、フォルダに整理したりするのは面倒ですよね。この記事では、最小限のコマンドでTILを管理できる環境の構築方法を紹介します。

この記事で作るもの

  • コマンド1つでTILファイルを自動作成
  • 月次で自動アーカイブ(フォルダ整理)
  • タグの自動正規化と整形
  • 複数のテンプレートから選択可能

面倒な作業は一切なし。コマンドを実行するだけです。

前提条件

  • Node.js と npm(または pnpm)がインストールされていること
  • Git がインストールされていること
  • 基本的なターミナル操作ができること

セットアップ手順

1. リポジトリの作成

まず、TILを管理するためのリポジトリを作成します。

mkdir til_diary
cd til_diary
git init

2. package.json の作成

プロジェクトのルートに package.json を作成します。
Pathで表すならここです。TIL_diary/package.json

{
  "name": "til_diary",
  "private": true,
  "type": "module",
  "version": "0.1.0",
  "description": "TIL workspace scripts",
  "scripts": {
    "til": "node scripts/til.mjs",
    "til:new": "node scripts/til.mjs new",
    "til:archive": "node scripts/til.mjs archive",
    "til:arc": "node scripts/til.mjs arc"
  }
}

3. フォルダの作成

以下のようにフォルダを作成します。

  • TIL_diary/templates/
  • TIL_diary/scripts/
    を作ってください。テンプレートを保存するところ,コマンド類のスクリプトを保存するところです。
mkdir -p scripts templates

4. タグ定義ファイルの作成

tags.yaml を作成して、使用するタグを定義します。エイリアス(別名)も設定できます。
こちらは頻繁に編集することを想定しているので,ルートに配置します。
Pathで表すならここです。TIL_diary/tags.yaml

タグ名:
  desc: 説明
  aliases: [別名1,別名2]

tags:
  til:
    desc: Today I Learned
    aliases: []
  vscode:
    desc: Visual Studio Code
    aliases: [vs-code]
  js:
    desc: JavaScript
    aliases: [javascript]
  rust:
    desc: Rust
    aliases: []
  shell:
    desc: Shell scripting
    aliases: [bash, zsh]

5. テンプレートファイルの作成

templates ディレクトリ(TIL_diary/templates/) に、以下のテンプレートファイルを作成します。

templates/note.md(基本ノート)

---
id: {{ID}}
title: {{TITLE}}
date: {{DATE}}
type: {{TYPE}}
tags: [{{TAGS}}]
links: []
---

## 今日のTIL

-

## メモ

-

## 次にやること

-

templates/snippet.md(コードスニペット)

---
id: {{ID}}
title: {{TITLE}}
date: {{DATE}}
type: {{TYPE}}
tags: [{{TAGS}}]
links: []
---

## スニペット

```言語名
// コードをここに
```

## 説明

-

templates/bug.md(バグ記録)

---
id: {{ID}}
title: {{TITLE}}
date: {{DATE}}
type: {{TYPE}}
tags: [{{TAGS}}]
links: []
---

## 問題

-

## 原因

-

## 解決方法

-

templates/reading.md(読書記録)

---
id: {{ID}}
title: {{TITLE}}
date: {{DATE}}
type: {{TYPE}}
tags: [{{TAGS}}]
links: []
---

## 読んだもの

-

## 学んだこと

-

## 感想

-

templates/retro.md(振り返り)

---
id: {{ID}}
title: {{TITLE}}
date: {{DATE}}
type: {{TYPE}}
tags: [{{TAGS}}]
links: []
---

## 今週/今月の振り返り

-

## 良かったこと

-

## 改善したいこと

-

6. CLIスクリプトの作成

scripts/til.mjs を作成します。このスクリプトがTIL管理の核となります。

#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';

function exitWith(message, code = 1) {
  console.error(message);
  process.exit(code);
}

function parseArgs(argv) {
  const args = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--') break;
    if (a.startsWith('--')) {
      const [k, v] = a.split('=');
      const key = k.replace(/^--/, '');
      if (v !== undefined) args[key] = v;
      else if (argv[i + 1] && !argv[i + 1].startsWith('-')) args[key] = argv[++i];
      else args[key] = true;
    } else if (a.startsWith('-')) {
      const key = a.replace(/^-/, '');
      if (argv[i + 1] && !argv[i + 1].startsWith('-')) args[key] = argv[++i];
      else args[key] = true;
    } else {
      args._.push(a);
    }
  }
  return args;
}

function ensureDir(dir) {
  fs.mkdirSync(dir, { recursive: true });
}

function todayYMD() {
  const d = new Date();
  const yyyy = d.getFullYear();
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const dd = String(d.getDate()).padStart(2, '0');
  return `${yyyy}-${mm}-${dd}`;
}

function makeId(dateISO) {
  const now = new Date();
  const ymd = dateISO.replace(/-/g, '');
  const hh = String(now.getHours()).padStart(2, '0');
  const mi = String(now.getMinutes()).padStart(2, '0');
  const ss = String(now.getSeconds()).padStart(2, '0');
  return `${ymd}T${hh}${mi}${ss}Z`;
}

function toYY(yyyy) { return String(yyyy).slice(2); }
function toYYYY(yy) { return 2000 + Number(yy); }

function parseDateFromFilename(name) {
  const m = name.match(/^(\d{2})-(\d{2})-(\d{2})\.md$/);
  if (!m) return null;
  const [_, yy, mm, dd] = m;
  const yyyy = toYYYY(yy);
  return { yyyy, mm, dd };
}

function readTagsMap() {
  const file = path.join(process.cwd(), 'tags.yaml');
  if (!fs.existsSync(file)) return { aliasToPrimary: {}, primaries: new Set() };
  const txt = fs.readFileSync(file, 'utf8');
  const aliasToPrimary = {};
  const primaries = new Set();
  let current = null;
  for (const raw of txt.split(/\r?\n/)) {
    const line = raw.trimEnd();
    if (/^tags:\s*$/.test(line)) continue;
    const mKey = line.match(/^([A-Za-z0-9_\-]+):\s*$/);
    if (mKey) { current = mKey[1]; primaries.add(current); continue; }
    const mAliases = line.match(/^aliases:\s*\[(.*)\]\s*$/);
    if (mAliases && current) {
      const list = mAliases[1]
        .split(',')
        .map(s => s.trim())
        .filter(Boolean);
      for (const a of list) aliasToPrimary[a] = current;
    }
  }
  return { aliasToPrimary, primaries };
}

function normalizeTags(input) {
  if (!input) return [];
  const { aliasToPrimary, primaries } = readTagsMap();
  const raw = input.split(',').map(s => s.trim()).filter(Boolean);
  const out = [];
  for (const t of raw) {
    const norm = aliasToPrimary[t] || t;
    if (!primaries.size || primaries.has(norm)) out.push(norm);
    else console.warn(`警告: タグ "${t}" は tags.yaml に登録されていません`);
  }
  return [...new Set(out)].sort();
}

function cmdNew(args) {
  const dateStr = args.d || args.date || todayYMD();
  const [yyyy, mm, dd] = dateStr.split('-');
  const yy = toYY(yyyy);
  const type = args.y || args.type || 'note';
  const tags = normalizeTags(args.g || args.tags || 'til');
  const title = args.t || args.title || dateStr;
  const shouldOpen = args.o || args.open;

  const templatePath = path.join(process.cwd(), 'templates', `${type}.md`);
  if (!fs.existsSync(templatePath)) {
    exitWith(`エラー: テンプレート "${type}.md" が見つかりません`);
  }

  const template = fs.readFileSync(templatePath, 'utf8');
  const id = makeId(dateStr);
  const tagsStr = tags.length > 0 ? tags.join(', ') : '';
  
  const content = template
    .replace(/\{\{ID\}\}/g, id)
    .replace(/\{\{TITLE\}\}/g, title)
    .replace(/\{\{DATE\}\}/g, dateStr)
    .replace(/\{\{TYPE\}\}/g, type)
    .replace(/\{\{TAGS\}\}/g, tagsStr);

  const isPastMonth = new Date(dateStr) < new Date(todayYMD().substring(0, 7) + '-01');
  const targetDir = isPastMonth ? path.join(yyyy, mm) : process.cwd();
  ensureDir(targetDir);

  const filename = `${yy}-${mm}-${dd}.md`;
  const filepath = path.join(targetDir, filename);

  if (fs.existsSync(filepath)) {
    exitWith(`エラー: ファイル "${filepath}" は既に存在します`);
  }

  fs.writeFileSync(filepath, content, 'utf8');
  console.log(`作成しました: ${filepath}`);

  if (shouldOpen) {
    spawnSync('code', [filepath], { stdio: 'inherit' });
  }
}

function cmdArchive(args) {
  const root = process.cwd();
  const files = fs.readdirSync(root)
    .filter(f => /^\d{2}-\d{2}-\d{2}\.md$/.test(f))
    .map(f => ({ name: f, parsed: parseDateFromFilename(f) }))
    .filter(f => f.parsed);

  if (files.length === 0) {
    console.log('アーカイブ対象のファイルがありません');
    return;
  }

  const today = new Date();
  const currentMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
  const until = args.until ? new Date(args.until) : null;

  const toArchive = files.filter(f => {
    const fileMonth = `${f.parsed.yyyy}-${f.parsed.mm}`;
    if (fileMonth === currentMonth) return false;
    if (until) {
      const fileDate = new Date(`${f.parsed.yyyy}-${f.parsed.mm}-${f.parsed.dd}`);
      return fileDate <= until;
    }
    return true;
  });

  if (toArchive.length === 0) {
    console.log('アーカイブ対象のファイルがありません');
    return;
  }

  if (args['dry-run']) {
    console.log('以下のファイルをアーカイブします:');
    toArchive.forEach(f => {
      const dest = path.join(f.parsed.yyyy, f.parsed.mm, f.name);
      console.log(`  ${f.name} -> ${dest}`);
    });
    return;
  }

  for (const f of toArchive) {
    const destDir = path.join(root, f.parsed.yyyy, f.parsed.mm);
    ensureDir(destDir);
    const dest = path.join(destDir, f.name);
    
    if (fs.existsSync(dest)) {
      if (args.force) {
        let counter = 1;
        let newDest = dest;
        while (fs.existsSync(newDest)) {
          const ext = path.extname(f.name);
          const base = path.basename(f.name, ext);
          newDest = path.join(destDir, `${base}-${counter}${ext}`);
          counter++;
        }
        fs.renameSync(path.join(root, f.name), newDest);
        console.log(`移動しました: ${f.name} -> ${newDest}`);
      } else {
        console.log(`スキップ: ${dest} は既に存在します`);
      }
    } else {
      const result = spawnSync('git', ['mv', f.name, dest], { cwd: root });
      if (result.status === 0) {
        console.log(`移動しました: ${f.name} -> ${dest}`);
      } else {
        fs.renameSync(path.join(root, f.name), dest);
        console.log(`移動しました: ${f.name} -> ${dest} (git mv 失敗、通常の移動を使用)`);
      }
    }
  }
}

const cmd = process.argv[2];
const args = parseArgs(process.argv.slice(3));

if (cmd === 'new') {
  cmdNew(args);
} else if (cmd === 'archive' || cmd === 'arc') {
  cmdArchive(args);
} else {
  exitWith('使用法: til new|archive|arc [オプション]');
}

使い方

基本的な使い方

今日のTILを作成する

pnpm til new
# または
npm run til:new

これだけで、今日の日付で 25-11-06.md のようなファイルが作成されます。

タグを指定して作成する

pnpm til new -g "rust,shell"

タグは自動的に正規化され、アルファベット順に整形されます。tags.yaml にエイリアスが定義されていれば、それも自動変換されます(例: javascriptjs)。

テンプレートを指定する

pnpm til new -y bug -g "shell,ux"

利用可能なテンプレート:

  • note: 基本ノート(デフォルト)
  • snippet: コードスニペット
  • bug: バグ記録
  • reading: 読書記録
  • retro: 振り返り

作成後にVS Codeで開く

pnpm til new -o

過去の日付で作成する

pnpm til new -d 2025-10-31 -y bug -g "shell,ux"

過去の月の日付を指定した場合、最初から 2025/10/25-10-31.md のように適切なディレクトリに作成されます。

オプション一覧

  • オプション:
    • -d, --date YYYY-MM-DD: 作成日(既定: 今日)。
    • -t, --title: 本文タイトル(既定: 日付文字列)。ファイル名には使用しません。
    • -y, --type: テンプレ種類 note|snippet|bug|reading|retro(既定: note)。
    • -g, --tags: カンマ区切りタグ。tags.yaml と照合し、エイリアス正規化・アルファベット順整形(未登録は警告)。
    • -o, --open: 生成後に VS Code で開きます。

アーカイブ機能

今月以外のファイルをアーカイブする

pnpm til arc
# または
pnpm til archive

リポジトリ直下の YY-MM-DD.md 形式のファイルのうち、今月以外のものを YYYY/MM/ ディレクトリに移動します。

アーカイブ前に確認する

pnpm til arc --dry-run

実際に移動せず、どのファイルが移動されるか確認できます。

特定の日付までをアーカイブする

pnpm til arc --until 2025-10-31

指定した日付以前(<=)のファイルのみをアーカイブします。

競合時に自動リネームする

pnpm til arc --force

移動先に同名ファイルが存在する場合、-1, -2 などを付与して移動します。

オプション一覧

  • オプション:
    • --until YYYY-MM-DD: 指定日「以前(<=)」のファイルをアーカイブ対象に限定。
    • --dry-run: 実行せず予定のみ表示。
    • --force: 競合時に -1, -2 などを付与して移動(既定はスキップ)。

実際の使用例

日々の記録

# 毎朝、今日のTILを作成してVS Codeで開く
pnpm til new -y note -g "til" -o

バグを記録する

# バグを見つけたらすぐに記録
pnpm til new -y bug -g "rust,debugging" -t "配列のインデックスエラー"

月初の整理

# まず確認
pnpm til arc --dry-run

# 問題なければ実行
pnpm til arc

ファイル構造の例

til_diary/
├── 25-11-06.md          # 今月のファイル(ルート直下)
├── 25-11-07.md
├── 2025/
│   └── 10/
│       ├── 25-10-31.md  # 過去月のファイル(アーカイブ済み)
│       └── 25-10-30.md
├── scripts/
│   └── til.mjs
├── templates/
│   ├── note.md
│   ├── snippet.md
│   ├── bug.md
│   ├── reading.md
│   └── retro.md
├── tags.yaml
└── package.json

メリット

  1. 最小限のコマンド: ファイル名や日付を入力する必要がない
  2. 自動整理: 月次で自動的にフォルダに整理される→基本的な執筆はルートで行える。
  3. タグ管理: エイリアス対応で、タグの表記ゆれを防止
  4. テンプレート: 用途に応じたテンプレートで、やるべき作業は内容を書くだけ!
  5. Git履歴保持: git mv を使用するため、ファイル移動の履歴が保持される

カスタマイズ

テンプレートの編集

templates/ ディレクトリ内のファイルを編集することで、テンプレートをカスタマイズできます。

タグの追加

tags.yaml に新しいタグを追加することで、タグシステムを拡張できます。

tags:
  python:
    desc: Python
    aliases: [py]
  docker:
    desc: Docker
    aliases: []

発展

VSCodeのショートカット登録

ルートの.vscodetasks.jsonを追加して以下のように設定すればpnpm new -opnpm arcなどのよく使う操作をタスクに設定できます。

{
	"version": "2.0.0",
	"tasks": [
	  {
		"label": "TIL: new -o",
		"type": "shell",
		"command": "pnpm",
		"args": ["til", "new", "-o"],
		"options": {
		  "cwd": "${workspaceFolder}"
		},
		"problemMatcher": [],
		"group": {
		  "kind": "build",
		  "isDefault": true
		}
	  },
	  {
		"label": "TIL: arc",
		"type": "shell",
		"command": "pnpm",
		"args": ["til", "arc"],
		"options": {
		  "cwd": "${workspaceFolder}"
		},
		"problemMatcher": []
	  },
	  {
		"label": "TIL: push (today)",
		"type": "shell",
		"command": "pnpm",
		"args": ["til", "push"],
		"options": {
		  "cwd": "${workspaceFolder}"
		},
		"problemMatcher": []
	  }
	]
  }

さらにこれをVSCodeのショートカットに入れればコマンドすら打ち込まずに執筆できます。究極のズボラですね。
一応,.vscode/settings.jsonに以下のように書かないとグローバルにショートカットが設定されるのでご注意を。

{
  "files.defaultLanguage": "markdown",
  "editor.wordWrap": "on",
  "files.trimTrailingWhitespace": true,
  "frontMatter.taxonomy.tags": [
    "til",
    "vscode",
    "js"
  ],
  "tildiary.enableKeybindings": true
}

↑↑↑これを.vscode/settings.jsonに。↑↑↑

↓↓↓これをVScodeのショートカット設定に追加…↓↓↓

    {
        "key": "cmd+n",
        "command": "workbench.action.tasks.runTask",
        "args": "TIL: new -o",
        "when": "config.tildiary.enableKeybindings"
    },
    {
        "key": "ctrl+a",
        "command": "workbench.action.tasks.runTask",
        "args": "TIL: arc",
        "when": "config.tildiary.enableKeybindings"
    },
    {
        "key": "ctrl+p",
        "command": "workbench.action.tasks.runTask",
        "args": "TIL: push (today)",
        "when": "config.tildiary.enableKeybindings"
      }

さいごに

この環境を構築することで、TILを書くことだけに集中できます。ファイル名を考える必要も、フォルダに整理する必要もありません。コマンド1つで、大体の操作をやらずにすみます。

怠惰でも、ズボラでも、TILを続けられる環境が整いました。ぜひ試してみてください!
(という私は今TIL2日目なので3日坊主にならなければいいなーと思ってます。)

割とAIに聞きながら適当にスクリプトは作っているので,改良点・問題点あれば言ってください。
ここにgithubのリンクを貼っておきます。こちらをcloneしていただければすぐに使えるようになっています。

https://github.com/WatNeru/TIL-workspace

Discussion