Notes
lefthookでbun installの実行チェックをpre-commitに入れる
bun.lock が更新されたのに bun install を実行していないままコミットしようとすると、CIや他の開発者の環境で依存関係がズレる。lefthook の pre-commit に依存関係チェックを入れることで、このミスをコミット前に防ぐ。
Before: bun install を忘れてコミットしても気づけない。
After: node_modules/.bin のタイムスタンプと bun.lock を比較し、古ければコミットをブロックする。
# lefthook.yml
assert_lefthook_installed: true
pre-commit:
commands:
deps-check:
run: |
if [ ! -d node_modules/.bin ] || [ bun.lock -nt node_modules/.bin ]; then
echo "node_modules が古いです。bun install を実行してください。"
exit 1
fi
設計のポイント:
assert_lefthook_installed: true— lefthook バイナリが見つからない場合に hook 実行自体を exit 1 で失敗させる公式オプション。新規参加者がインストールを忘れても気づけるようになるnode_modules/.binをタイムスタンプ比較の対象にする —bun installはパッケージに変更がない場合でも.binのタイムスタンプを更新する(公式ドキュメントに明記はないが実装の観察値)。一方node_modulesディレクトリ直下は変更がなければタイムスタンプが変わらないことがあり、「install 済みなのに古いと判定される」フォールスポジティブが起きた。.binを対象にすることでこの問題を回避できる-nt(newer than)演算子 — bash の[[ A -nt B ]]で A が B より新しいか判定できる。bun.lock -nt node_modules/.binは「lockfileの方が新しい=install未実行」を意味する
lefthook の setup フックは staged ファイルがない状態では skip されることがあるため、commands の先頭に deps-check を入れる方が確実にブロックできた。
Tailwind Typography の prose-code: は pre 内の code にも適用される
Tailwind Typography で prose-code:text-[var(--color-text)] のようなカスタム色を設定すると、インラインコード(`code`)だけでなく、コードブロック(```...```)内の <code> にも同じ色が適用される。
コードブロックの背景は Tailwind Typography のデフォルトで暗い色(slate-800 相当)になっているため、そこに濃い文字色を上書きすると「暗い背景に暗い文字」になってコードが読めなくなる。
<!-- この class は pre > code にも効いてしまう -->
<div class="prose prose-code:text-[var(--color-text)]">
<!-- インラインコード -->
<p>Use <code>useState</code> here.</p>
<!-- コードブロック(背景が暗いのに文字も暗くなる)-->
<pre><code>const x = 1;</code></pre>
</div>
修正は pre 内の code だけを明示的に上書きする:
<!-- [&_pre_code]: でスコープを絞る -->
<div class="prose prose-code:text-[var(--color-text)] [&_pre_code]:text-slate-100">
または、インラインコードだけに色を当てたい場合は :not(pre) で絞る:
<div class="prose [&_:not(pre)_code]:text-[var(--color-text)]">
prose-code: はすべての code 要素を対象にするセレクタで、Tailwind Typography が内部で生成する --tw-prose-code 変数を上書きする。この変数は pre > code にも引き継がれるため、意図せずコードブロックにも効いてしまう。インラインコードとコードブロックの色を分けたい場合は、最初から :not(pre) のアービトラリーセレクタで限定する方が安全。
progenitor で OpenAPI spec から Rust クライアントをコンパイル時生成
Rust CLI から HTTP API を呼び出すとき、手書きの reqwest クライアントではなく progenitor crate でコンパイル時にクライアントコードを自動生成する方法を試した。
OpenAPI の JSON ファイルをリポジトリに保存しておき、build.rs から progenitor を呼ぶと、ビルド時に型付きクライアントが生成される。サーバ側の API 定義が変われば JSON ファイルを更新してリビルドするだけでクライアントも追随する。
使ってみた感想:
- 良かった点 — API の追加・変更がコンパイルエラーとして検出される。手書きクライアントと違い、フィールド名や型のズレがビルド時に発覚する
- 気になった点 — 生成されたメソッドのシグネチャが直感と異なることがある。例えば builder パターンではなく positional args で呼び出す API になっていた。手書きなら
client.list_stories().status(s).awaitと書けるところがclient.list_stories(project_id, status, limit, offset).awaitのような形になる - 向いている場面 — サーバと CLI を同一リポジトリで管理していて、API の drift を型レベルで防ぎたいとき。OpenAPI の drift check (
openapi:check) と組み合わせると、「定義がズレていたらビルドが通らない」という強いガードになる
生成されたクライアントの使用例(シグネチャは progenitor 版のイメージ):
// builder パターンではなく positional args で呼び出す
let stories = client
.list_stories(project_id, Some(status), Some(limit), None)
.await?;
次に試す(未実施)
progenitorのbuilderfeature が追加されたかどうか確認する。バージョンによっては builder スタイルの生成が選べるかもしれない
バーンダウンの理想線は「初回スコープ固定」ではなく日次スコープのスナップショットで引く
イテレーションのバーンダウンチャートを実装するとき、最初に「コミットされたスコープポイント」を1つ凍結して理想線を引くのが素直な発想だが、途中でスコープが増減すると理想線と実線がズレて意味を失う。今回は、スコープ自体も日次スナップショットに含めて、各日の理想線をその日のスコープを基準に引くように変えた。
最初の実装はこんな構造だった。
- イテレーションテーブルに
burndownScopePoints(初回コミットスコープ)を持たせる - 1日1回スナップショットを取り、
remaining_pointsだけを記録する - 理想線は
burndownScopePointsを始点として直線で引く
これだと、途中で「やっぱりこのストーリーも入れる/落とす」が起きたときに、理想線は古いスコープのまま固定されて、現実とまったく合わなくなる。新しい構造はこう。
- スナップショットテーブルに
scope_pointsカラムを追加(migration 0041) - 毎日のスナップショット時に
remaining_pointsとscope_pointsを両方記録する - 当日のスコープが残っていなければ live 集計(
Rejectedを除いた合計)でフォールバック - イテレーション側の
burndownScopePointsは捨てる
// Before: 初回スコープを凍結
await freezeScopeIfUnset(db, iterationId, committedScope);
await db.insert(snapshots).values({
iterationId,
snapshotDate: today,
remainingPoints: remaining,
});
// After: 日次でスコープも記録
await db.insert(snapshots).values({
iterationId,
snapshotDate: today,
scopePoints: scope, // ← 当日のスコープも保存
remainingPoints: remaining,
});
ポイントは、「変動しうる前提値」をスナップショットの外に置かないこと。理想線・実線・スコープ線の3本を同じ時系列ベースで描けるようになるので、後からバーンアップにも自然に拡張できる。
今回やったこと(実施済み)
- マイグレーションでスナップショットに
scope_pointsを追加 - 日次バッチ・API両方で
scope_pointsを保存 - 理想線は「各日のスコープ」を anchor に再計算
- バーンアップタブを追加するときに、同じ
scope_points列をそのまま使えた
tiptap のメンションを markdown で永続化するためのショートコード経由ラウンドトリップ
リッチテキストエディタ(tiptap)でメンション(@ユーザー)を扱うとき、保存先が markdown だと素直に往復できない。markdown には mention ノードに相当する記法がないので、エディタ → 保存 → 再表示で id とラベルが落ちる。
専用ショートコード [@ id="..." label="..."] をストレージ上のキャリアに使い、エディタ↔markdown 変換で詰め替える方式が安定した。
const MENTION_LINK = /\[@([^\]\n]+)\]\(mention:([^)]+)\)/g;
const MENTION_SHORTCODE = /\[@\s+([^\]]*)\]/g;
export function toEditorShortcodes(md: string) {
return md.replace(MENTION_LINK, (_, label, id) =>
`[@ id="${escape(id)}" label="${escape(label)}"]`
);
}
export function fromEditorShortcodes(md: string) {
return md.replace(MENTION_SHORTCODE, (full, attrs) => {
const { id, label } = parseAttrs(attrs);
return id ? `[@${escapeMd(label ?? id)}](mention:${id})` : full;
});
}
tiptap 側は Mention 拡張の renderText で @${label} を出すだけにし、独自の Suggestion ポップアップは無効化(suggestion.allow: () => false)して既存の候補UIをそのまま使う:
import { Mention } from "@tiptap/extension-mention";
export const AppMention = Mention.configure({
renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
suggestion: { char: "@", allow: () => false },
});
ポイント:
- markdown を一次ストレージにするなら、ノード固有の属性は「人間に読める識別可能なショートコード」で運ぶのが扱いやすい
- 表示・検索ヒット・他クライアントの解釈を考えて、id とラベルの両方を残しておく(id 単独だと表示崩れ、label 単独だと参照ができない)
- tiptap の Suggestion を切るかどうかは、候補UIを別実装で持っているかで決める
Tailwind v4 の dark variant をアプリ内 .dark トグルに同期する
ダークモードでだけグラデーションのフェードが白背景のままになる、というバグ。Tailwind v4 にした後、dark:from-slate-900 のような従来の指定が効いていなかった。
Tailwind v4 のデフォルトの dark バリアントは prefers-color-scheme: dark ベース。useTheme 等で <html class="dark"> を付けるアプリ内トグルを使っている場合、OS設定とアプリ内設定が一致しないとクラスベースの dark: が反応しない。
修正は CSS で dark バリアントを .dark クラス基準に上書きする1行と、グラデーションをカード背景に合わせる CSS 変数化:
@import "tailwindcss";
/* useTheme が html に付ける .dark と同期 */
@custom-variant dark (&:where(.dark, .dark *));
// Before: ライト前提の白 → ダークで残る
<div className="bg-gradient-to-t from-white to-transparent dark:from-slate-900" />
// After: カード背景の CSS 変数を使えばモード切替に追従する
<div className="bg-gradient-to-t from-[var(--color-surface)] to-transparent" />
ポイント:
- v4 で OS依存ではなくアプリ内クラスでモード切替したいなら
@custom-variant darkの上書きが必須(公式ドキュメントに記載) - 「白で消える」系のフェードは色を直書きせず、テーマ変数(
--color-surfaceなど)から引くと dark/light 両対応できる
シードスクリプトはpackage.jsonとAI向けドキュメントに両方追記する
スクロール検証用にローカルDBへ大量データを投入するシードスクリプトを作ったとき、package.json の scripts に追加するだけでなく、AIエージェント向けのガイドドキュメントにも明記した。
// package.json
"scripts": {
"seed:scroll": "bash scripts/seed-scroll-data.sh"
}
AIエージェントは package.json の scripts を参照することもあるが、「スクロール確認のためにデータを大量投入したい」という意図と使い方は、ドキュメントに書かないと推測できない。エージェント向けのガイド(例: docs/agent-guide.md)に「ローカルでスクロール検証する場合は bun run seed:scroll を実行する」のように目的ベースで書いておくと、エージェントが自律的に適切なコマンドを選べるようになる。
手順書的な書き方(「手順1: コマンドを実行する」)より、いつ使うか(「大量データが必要なスクロール検証時」)を先に書く方がエージェントの文脈マッチングに効く。
スクリプト自体も再実行可能(idempotent)に作るのがセット。INSERT OR IGNORE や prefix 付き DELETE で安全に何度でも実行できるようにしておくと、エージェントが気軽に呼べる。
メンション選択UIは表示名で見せて挿入はIDにする
コメントの@メンション機能を実装するとき、候補一覧に表示する値と実際に本文へ挿入する値を分けた。
- 候補一覧:
@田中太郎(displayName)で表示 - 挿入値:
@user_abc123(userId)を埋め込む
理由は通知システムがIDベースで動いているため。表示名は変わる可能性があるが、通知の宛先IDは変わらない。本文中のメンションも同様で、保存する値は常にIDにしておくことで、ユーザーが名前を変更しても過去コメントの通知対応が壊れない。
表示側では保存済みの @userId を読み込み時に @displayName へ変換して見せる(renderWithDisplayMentions)。編集中は候補から選んだ displayName が見えるが、エディタ内部の状態は userId を持つ。
表示・保存・通知のそれぞれで持つべきIDが異なるので、変換レイヤーをどこに置くかをあらかじめ決めておくと実装がスッキリする。
コードブロック内の@メンションは変換しない
コメント本文中の @userId を @displayName に変換するとき、コードブロック内のトークンまで置換してしまうと表示が崩れる。マークダウンをコードブロック境界で分割してから、非コード部分だけ変換するとうまくいった。
具体的には、コードブロックのパターンで本文を split し、セグメントがコードかどうかで処理を切り替える。
const CODE_SEGMENT_PATTERN = /(```[\s\S]*?```|`[^`\n]*`)/g;
function renderWithDisplayMentions(body: string, members: Member[]): string {
const idToName = new Map(members.map(m => [m.id, m.displayName]));
return body
.split(CODE_SEGMENT_PATTERN)
.map(segment =>
isCodeSegment(segment)
? segment // コードはそのまま
: replaceMentions(segment, idToName) // テキストだけ変換
)
.join("");
}
function isCodeSegment(s: string) {
return (s.startsWith("```") && s.endsWith("```")) ||
(s.startsWith("`") && s.endsWith("`"));
}
split に capture group を使うことで、コードブロック自体も配列の要素として残るのがポイント。capture group がないと区切り文字が消えてしまう。
メンションの置換時は displayName にマークダウン特殊文字が含まれうるのでエスケープも忘れずに。
自サイトへのリンクにnoreferrerを付けるとアクセス元がわからなくなる
target="_blank" のリンクに rel="noopener noreferrer" を機械的に付けがちだが、noreferrer は Referer ヘッダーを送らなくする。自サイト内のリンク(例: サービスサイトからサポートサイトへ)に付けると、遷移先のアクセス解析で「どこから来たか」が取れなくなり、direct traffic として計上されてしまう。
noopener と noreferrer は別の役割:
noopener— 遷移先ページがwindow.opener経由で元ページを操作できないようにする(セキュリティ対策)。Referer ヘッダーには影響しないnoreferrer— Referer ヘッダーを送らない。遷移先がアクセス元を知れなくなる。noopenerの効果も暗黙的に含む
現代のブラウザは target="_blank" に対して noopener 相当の挙動をデフォルトで適用するので、noopener すら省略できる場面が多い。ただし古いブラウザの互換性を考慮して rel="noopener" だけ付けるのが安全な落とし所。
使い分け:
- 外部の信頼できないリンク —
rel="noopener noreferrer"でよい。Referer を渡す必要がない - 自社・自サービスのリンク —
rel="noopener"のみ。Referer を残してアクセス解析を活かす - 内部リンク(同一ドメイン) — 通常は
target="_blank"自体が不要。使うならrel="noopener"で十分
