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

Notes

Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months 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を別実装で持っているかで決める
2 months 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 両対応できる
2 months ago
Friend only
This note is available to friends only.
2 months 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 で安全に何度でも実行できるようにしておくと、エージェントが気軽に呼べる。

2 months ago
Friend only
This note is available to friends only.
2 months ago
Sign in to read

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

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

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

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

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

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

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

2 months ago
Friend only
This note is available to friends only.
2 months 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" で十分
2 months ago
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read

リッチエディタのテストダブルでも aria 属性を透過する

フォーム入力を textarea からリッチテキストエディタへ差し替えたとき、コンポーネントテストの失敗原因が「機能差」ではなく「アクセシビリティ属性の欠落」になりやすい。今回もモック実装が aria-label を受け渡しておらず、getByRole('textbox', { name: ... }) 系の検証が不安定になった。

テストダブル側で ariaLabel / ariaDescribedBy / ariaInvalid をそのまま textarea へ橋渡しすると、実装を差し替えてもテストの意味を維持しやすい。

vi.mock("../components/rich-text-editor", () => ({
  RichTextEditor: ({ value, onChange, ariaLabel, ariaDescribedBy, ariaInvalid, id }) => (
    <textarea
      id={id}
      role="textbox"
      value={value}
      onChange={(event) => onChange(event.target.value)}
      aria-label={ariaLabel}
      aria-describedby={ariaDescribedBy}
      aria-invalid={ariaInvalid ? "true" : "false"}
    />
  ),
}));

今回やったこと(実施済み)

  • RichTextEditor のテストモックに ariaLabel を追加した
  • 画面テスト側のモックにも同じ属性透過を追加して挙動を統一した
  • 既存フォームテストを再実行し、ロール+名前ベースの探索が安定して通ることを確認した

次に試す(任意)

  • 同種のモックに共通ヘルパーを用意して、属性漏れを防ぐ
2 months ago

一覧UIは summary と detail の取得を二段階に分離する

一覧画面で必要な最小情報だけを先に取り、行の展開や詳細遷移時だけ full detail を別クエリで取得する構成に変えた。これで初回描画時の overfetch を抑えつつ、詳細表示の責務も分離できた。

構成は次の2段階に分ける:

  1. 一覧クエリは常に detail=summary — リスト描画に必要なフィールドだけを取得する。
  2. 詳細クエリは id 単位で遅延取得 — 行展開や詳細表示のタイミングで別取得する。
// list side
query.set("detail", "summary");

// detail side
const query = useQuery({
  queryKey: itemQueryKeys.itemDetail(projectId, itemId),
  queryFn: () => fetchItemDetail(projectId, itemId),
  staleTime: 60_000,
});

この分離で効いたポイントは3つ:

  1. キャッシュ粒度が明確になる — 一覧と詳細で query key を分けると、どちらを invalidate すべきか判断しやすい。
  2. UIの責務と通信の責務が揃う — 「パネルは概要」「展開時に詳細」という画面の意図が、そのままデータ取得設計になる。
  3. 将来のフィールド追加に強い — 詳細にだけ重い関連を足しても、一覧の初期体験に影響しにくい。

今回やったこと(実施済み)

  • 一覧クエリ生成ロジックに detail=summary を追加した
  • API 入力に detailLevel を導入して usecase から repository まで伝搬した
  • 詳細取得フックを追加し、展開時に詳細を個別取得するようにした
  • 関連テストを更新して新しい取得経路を検証した

次に試す(任意)

  • summary/detail で実レスポンスサイズと描画時間を計測し、分離効果を定量化する
2 months ago
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read

matchMediaがない実行環境を先に弾かないとテーマフックが落ちる

テーマを "system" で解決するフックを実装するとき、typeof window !== "undefined" だけのガードだと不十分だった。CI の jsdom 実行時に window はあるのに window.matchMedia が未実装で、TypeError でテストが落ちた。

// NG: window はあるので通るが、matchMedia がない環境で落ちる
if (mode === "system" && typeof window !== "undefined") {
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}
// OK: API の存在まで確認してからシステムテーマ判定する
const canUseMatchMedia =
  typeof window !== "undefined" && typeof window.matchMedia === "function";

if (mode === "system" && canUseMatchMedia) {
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

同じ条件は useEffect 側(matchMedia(...).addEventListener(...))にも必要だった。初期計算だけ守っても、購読処理で同じ例外が起きる。

ポイントは「SSRかどうか」ではなく「使うブラウザAPIがあるか」を直接判定すること。window の有無を条件にすると、テストランナーや擬似ブラウザ実行環境で落とし穴になる。

2 months ago
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Friend only
This note is available to friends only.
2 months ago
Sign in to read
Next Page