REVELUP
shwld
shwld
作ることに関わるひとを幸福にしたい スキ: 個人開発 設計 アジャイル 関数型 DDD

Notes

Friend only
This note is available to friends only.
3 days ago
Sign in to read

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

設計のポイント:

  1. assert_lefthook_installed: true — lefthook バイナリが見つからない場合に hook 実行自体を exit 1 で失敗させる公式オプション。新規参加者がインストールを忘れても気づけるようになる
  2. node_modules/.bin をタイムスタンプ比較の対象にする — bun install はパッケージに変更がない場合でも .bin のタイムスタンプを更新する(公式ドキュメントに明記はないが実装の観察値)。一方 node_modules ディレクトリ直下は変更がなければタイムスタンプが変わらないことがあり、「install 済みなのに古いと判定される」フォールスポジティブが起きた。.bin を対象にすることでこの問題を回避できる
  3. -nt(newer than)演算子 — bash の [[ A -nt B ]] で A が B より新しいか判定できる。bun.lock -nt node_modules/.bin は「lockfileの方が新しい=install未実行」を意味する

lefthook の setup フックは staged ファイルがない状態では skip されることがあるため、commands の先頭に deps-check を入れる方が確実にブロックできた。

3 days ago

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) のアービトラリーセレクタで限定する方が安全。

3 days ago

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 の builder feature が追加されたかどうか確認する。バージョンによっては builder スタイルの生成が選べるかもしれない
4 days ago
Friend only
This note is available to friends only.
5 days ago
Sign in to read
Friend only
This note is available to friends only.
6 days ago
Sign in to read
Friend only
This note is available to friends only.
6 days ago
Sign in to read
Friend only
This note is available to friends only.
9 days ago
Sign in to read

バーンダウンの理想線は「初回スコープ固定」ではなく日次スコープのスナップショットで引く

イテレーションのバーンダウンチャートを実装するとき、最初に「コミットされたスコープポイント」を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 列をそのまま使えた
10 days ago
Friend only
This note is available to friends only.
11 days ago
Sign in to read
Friend only
This note is available to friends only.
11 days ago
Sign in to read
Friend only
This note is available to friends only.
13 days ago
Sign in to read
Friend only
This note is available to friends only.
13 days ago
Sign in to read
Friend only
This note is available to friends only.
13 days ago
Sign in to read
Friend only
This note is available to friends only.
12 days ago
Sign in to read
Friend only
This note is available to friends only.
12 days ago
Sign in to read

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を別実装で持っているかで決める
12 days ago

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 両対応できる
12 days ago
Friend only
This note is available to friends only.
13 days ago
Sign in to read

シードスクリプトは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 で安全に何度でも実行できるようにしておくと、エージェントが気軽に呼べる。

13 days ago
Friend only
This note is available to friends only.
13 days ago
Sign in to read

メンション選択UIは表示名で見せて挿入はIDにする

コメントの@メンション機能を実装するとき、候補一覧に表示する値と実際に本文へ挿入する値を分けた。

  • 候補一覧: @田中太郎(displayName)で表示
  • 挿入値: @user_abc123(userId)を埋め込む

理由は通知システムがIDベースで動いているため。表示名は変わる可能性があるが、通知の宛先IDは変わらない。本文中のメンションも同様で、保存する値は常にIDにしておくことで、ユーザーが名前を変更しても過去コメントの通知対応が壊れない。

表示側では保存済みの @userId を読み込み時に @displayName へ変換して見せる(renderWithDisplayMentions)。編集中は候補から選んだ displayName が見えるが、エディタ内部の状態は userId を持つ。

表示・保存・通知のそれぞれで持つべきIDが異なるので、変換レイヤーをどこに置くかをあらかじめ決めておくと実装がスッキリする。

13 days ago

コードブロック内の@メンションは変換しない

コメント本文中の @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 にマークダウン特殊文字が含まれうるのでエスケープも忘れずに。

13 days ago
Friend only
This note is available to friends only.
14 days ago
Sign in to read

自サイトへのリンクに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" だけ付けるのが安全な落とし所。

使い分け:

  1. 外部の信頼できないリンク — rel="noopener noreferrer" でよい。Referer を渡す必要がない
  2. 自社・自サービスのリンク — rel="noopener" のみ。Referer を残してアクセス解析を活かす
  3. 内部リンク(同一ドメイン) — 通常は target="_blank" 自体が不要。使うなら rel="noopener" で十分
14 days ago
Friend only
This note is available to friends only.
14 days ago
Sign in to read
Friend only
This note is available to friends only.
17 days ago
Sign in to read
Friend only
This note is available to friends only.
15 days ago
Sign in to read
Friend only
This note is available to friends only.
17 days ago
Sign in to read
Friend only
This note is available to friends only.
17 days ago
Sign in to read
Next Page