Skip to content

Instantly share code, notes, and snippets.

@umeyuki
Last active January 18, 2026 14:58
Show Gist options
  • Select an option

  • Save umeyuki/57c5ef44ae232d9e890dd907758bd577 to your computer and use it in GitHub Desktop.

Select an option

Save umeyuki/57c5ef44ae232d9e890dd907758bd577 to your computer and use it in GitHub Desktop.
Flops 技術再利用ガイド - Parser & OGP画像生成の設計・実装ドキュメント

Flops 技術再利用ガイド

他プロダクトでの応用を目的とした設計・実装ドキュメント

概要

本ドキュメントは、Flopsプロジェクトで実装した以下の2つの主要機能について、設計思想、利用ライブラリ、失敗談、ベストプラクティスをまとめたものです:

  1. 独自記法のParser&再生機能 - テキストベースの記法をパースしてインタラクティブに描画・再生
  2. OGP画像生成機能 - 動的にOpen Graph画像を生成してSNS共有を最適化

第1章: 独自記法Parser&再生システム

1.1 設計思想

Single Source of Truth (SSOT)

テキスト入力が正規データ源という設計原則を採用しています。

``` ユーザー入力テキスト → Parser → 内部状態 → UI表示 ↑ ↓ └─────── テキスト生成 ←─────┘ ```

利点:

  • 状態の整合性保証(UIからの直接変更を禁止)
  • デバッグが容易(テキストを見れば状態がわかる)
  • 共有・保存が簡単(テキストをそのまま保存)
  • バージョン管理と差分表示が可能

Immutable State Transitions

すべての状態遷移は新しいオブジェクトを生成します。既存状態を変更しません。

```typescript // ❌ 悪い例:直接変更 state.players.push(newPlayer); state.pot += betAmount;

// ✅ 良い例:新しいオブジェクト生成 const newState = { ...state, players: [...state.players, newPlayer], pot: state.pot + betAmount, }; ```

利点:

  • Undo/Redoの実装が容易(状態のスナップショット保持)
  • バグの原因特定が容易(状態変更の追跡可能)
  • Reactivity(Svelte/React等)との相性が良い

1.2 アーキテクチャ

パッケージ構成

``` packages/ ├── domain/ # 型定義・スキーマ(依存なし) ├── engine/ # ビジネスロジック(domain依存) ├── parser/ # テキスト解析(domain, engine依存) └── utils/ # 共有ユーティリティ

apps/ ├── frontend/ # SvelteKit UI └── backend/ # Hono API ```

依存関係の方向: `domain ← engine ← parser ← frontend`

循環依存を防ぐため、下位パッケージは上位パッケージを参照しません。

Parserパイプライン

``` 入力テキスト ↓ ┌─────────────────┐ │ Lexer │ トークン分割 └─────────────────┘ ↓ ┌─────────────────┐ │ Pattern Matchers│ 優先度付きマッチング └─────────────────┘ ↓ ┌─────────────────┐ │ GameStateBuilder│ 状態オブジェクト構築 └─────────────────┘ ↓ ┌─────────────────┐ │ValidationPipeline│ セマンティック検証 └─────────────────┘ ↓ GameState ```

1.3 実装パターン

Pattern Matcher(優先度付きマッチング)

```typescript // 各マッチャーは優先度順に試行される const matchers = [ { name: 'game-format', match: matchGameFormat, priority: 1 }, { name: 'board', match: matchBoard, priority: 2 }, { name: 'position-action', match: matchPositionAction, priority: 3 }, { name: 'stack', match: matchStack, priority: 4 }, { name: 'comment', match: matchComment, priority: 5 }, ];

// マッチ結果の型 interface MatchResult { matched: boolean; type: 'game-format' | 'board' | 'action' | ...; data: ParsedData; remaining: string; // 未消費の入力 } ```

ポイント:

  • 各マッチャーは純粋関数として実装
  • 優先度を明確に定義し、曖昧さを排除
  • 部分マッチ時は`remaining`で残りを返す

GameStateBuilder(インクリメンタル更新)

```typescript // リアルタイム編集対応のため、行単位で状態更新可能 export function updateGameStateFromParsedLine( currentState: GameState, parsedLine: ParsedLineResult, lineNumber: number ): GameState { // 1. 既存行の検証 // 2. 差分計算 // 3. 新しいGameState生成 return { ...currentState, /* 更新内容 */ }; } ```

1.4 利用ライブラリ

ライブラリ 用途 選定理由
Zod スキーマ定義・バリデーション TypeScript統合、エラーメッセージのカスタマイズ性
Svelte Store 状態管理(フロントエンド) Svelteとの相性、シンプルなAPI

1.5 失敗談と教訓

失敗1: 型定義の重複

問題: Parser、Engine、Frontend で別々のGameState定義が存在し、型変換が必要だった

```typescript // ❌ 問題のあった構造 packages/parser/src/types/game-state.ts // ParserGameState packages/engine/src/types/game-state.ts // EngineGameState apps/frontend/src/lib/types.ts // UIGameState ```

教訓:

  • `@flops/domain`パッケージに型を一元化
  • 変換アダプター層を排除し、単一の型を全レイヤーで共有

失敗2: クラスベース実装の混在

問題: 一部のServiceがクラスで実装され、関数型コードと混在

教訓:

  • 全ロジックを純粋関数で実装
  • 依存性はカリー化またはパラメータ渡しで解決

```typescript // ✅ 関数型アプローチ const createAuthService = (db: Database) => ({ login: async (email: string) => { ... }, }); ```

失敗3: Step再生でのアクション表示スキップ

問題: 最終アクションと完了フラグを同じステップに設定したため、最終アクションが表示されずにショーダウンに遷移

教訓:

  • アクションステップと完了ステップは分離する
  • 状態遷移の単位を明確に定義

```typescript // ✅ 修正後 // アクションステップ(全アクション) const actionSteps = actions.map(a => ({ type: 'action', action: a })); // 完了ステップ(最後に追加) if (isComplete) { actionSteps.push({ type: 'completion', outcome }); } ```

失敗4: 2ウェイデータバインディングの過度な使用

問題: UI入力とパース結果の双方向バインディングで状態の整合性が崩れた

教訓:

  • 単方向データフローを徹底
  • UIはテキスト入力のみを受け付け、状態はParserからのみ更新

1.6 再利用のためのチェックリスト

  • ドメイン型を独立パッケージに分離
  • Parser → State → UI の単方向フロー設計
  • Immutable更新パターンの採用
  • Pattern Matcherの優先度定義
  • インクリメンタル更新対応(リアルタイム編集向け)
  • 検証エラーの蓄積と詳細メッセージ

第2章: OGP画像生成システム

2.1 設計思想

Cloudflare Workers対応

WASM前提の技術選定を行いました。Node.js依存のライブラリ(Sharp等)はWorkers環境で動作しないため、WASMベースの代替を選択。

Dynamic Import パターン

Vite SSRビルドでWASMモジュールが問題を起こすため、dynamic importで遅延読み込みします。

```typescript // ✅ Vite SSRビルド対応 // WASMモジュールはdynamic importで読み込む const { Resvg } = await import('@cf-wasm/resvg/workerd');

const resvg = await Resvg.create(svg, { fitTo: { mode: 'width', value: 1200 }, }); const pngData = resvg.render(); return pngData.asPng(); ```

ポイント:

  • `@cf-wasm/resvg`はCloudflare Workers専用パッケージ
  • `/workerd`サブパスでWorkers最適化版を読み込み
  • static importを避けることでViteビルドエラーを回避

2.2 技術スタック

``` データ抽出 → HTMLテンプレート → Satori(SVG) → @cf-wasm/resvg(PNG) ```

ステップ ライブラリ 役割
HTML→SVG satori ReactスタイルのHTMLをSVGに変換
HTML解析 satori-html HTML文字列をSatori互換形式に変換
SVG→PNG @cf-wasm/resvg Cloudflare Workers専用SVGレンダラー
フォント subset-font フォントサブセット化(ビルド時)

2.3 Satoriの制約と対応策

Satoriは純粋なFlexbox実装であり、以下の制約があります:

制約 対応策
Gridレイアウト非対応 Flexboxでネストして対応
インラインスタイルのみ テンプレート関数でスタイル生成
CSSクラス非対応 全てstyle属性で記述
position: absolute制限 Flexboxのgap/paddingで調整
transform制限 サイズ計算で代替
外部画像制限 Base64エンコードまたはfetch

Flexbox設計パターン

```typescript // ✅ 2行レイアウト(Hero vs Villain + Board)

${renderHeroSection()}
${renderBoardSection()}
\`\`\`

2.4 テンプレート設計パターン

サイズ定数の一元管理

```typescript // カードサイズ定数 const CARD_SIZES = { large: { width: 64, height: 88, fontSize: 28, suitSize: 24 }, medium: { width: 56, height: 76, fontSize: 24, suitSize: 20 }, small: { width: 48, height: 66, fontSize: 20, suitSize: 18 }, };

// ポジションバッジサイズ const BADGE_SIZES = { large: { fontSize: '20px', padding: '6px 8px', width: '72px' }, // 固定幅 normal: { fontSize: '16px', padding: '5px 10px', minWidth: '40px' }, small: { fontSize: '15px', padding: '5px 9px', minWidth: '40px' }, }; ```

ポイント:

  • ポジションバッジは固定幅にすることで、アクションテキストの開始位置を揃える
  • Hero/Villainで同じサイズ定数を使用し、視覚的統一感を確保

フォントサイズ階層

```typescript // 視覚的階層(大→小) const FONT_HIERARCHY = { action: '32px', // メインコンテンツ(アクションテキスト) streetHeader: '28px', // セクション見出し(PREFLOP, FLOP等) vs: '24px', // 接続テキスト(vs, Board) pot: '22px', // 補足情報(ポットサイズ) badge: '20px', // ラベル(ポジションバッジ) footer: '18px', // フッター(Made by...) }; ```

教訓: 見出しとコンテンツのサイズ差が大きすぎると統一感がなくなる(28px:32px = 1.14倍が適切)

アクション間隔の最適化

```typescript // アクションリストコンテナ

\`\`\`

教訓: SNSプレビュー(特にLINE)では表示領域が限られるため、gapとpaddingの最適化が重要

2.5 APIエンドポイント設計

```typescript // apps/frontend/src/routes/api/og/[code]/+server.ts

export const GET: RequestHandler = async ({ params, fetch, url }) => { const code = params.code?.replace(/\.png$/, '');

// 1. バックエンドAPIからデータ取得 const handData = await fetchHandData(code);

// 2. テキストをパースしてGameState取得 const gameState = await parseTextToGameState(handData.content);

// 3. OGP用データ抽出 const ogpOptions = extractOGPOptions(gameState);

// 4. フォント読み込み(キャッシュ済み) const fontData = await loadFont(fetch, url.origin);

// 5. 画像生成(dynamic importでWASM読み込み) const pngBytes = await generateOGPImage(ogpOptions, fontData, { fetch, origin: url.origin });

// 6. キャッシュヘッダー付きで返却 return new Response(pngBytes, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', }, }); }; ```

ポイント:

  • `[code].png`形式でURLをSNSフレンドリーに
  • 生成済み画像は1年間キャッシュ(`immutable`)

2.6 キャッシュ戦略

本番キャッシュ

```typescript headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', 'CDN-Cache-Control': 'max-age=31536000', } ```

開発時のキャッシュバイパス

```bash

クエリパラメータでキャッシュをバイパス

https://flops.dev/api/og/example-mtt-8max.png?v=2.79 ```

教訓: OGP開発ではブラウザ/CDNキャッシュが邪魔になる。バージョンパラメータを付けてバイパス

2.7 フォント最適化

問題: Noto Sans JP全体は4.44MBで、Workers環境では読み込みが遅い

解決策: `subset-font`でポーカー表示に必要な文字のみ抽出

```javascript // ビルドスクリプト const subset = await subsetFont(fullFont, { targetChars: [ // 英数字 ...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', // カードスート ...'♠♥♦♣', // 記号 ...'.,!?-—:;()[]{}', ], });

// 結果: 4.44MB → 50.71KB (98.9%削減) ```

2.8 失敗談と教訓

失敗1: Node.js依存ライブラリの選択

問題: 最初にSharpを選択したが、Cloudflare Workersで動作しない

教訓:

  • Edge/Workers環境を意識したライブラリ選定
  • WASM版があるかを事前確認
  • `@cf-wasm/resvg`はCloudflare Workers専用で最適化済み

失敗2: Static importによるViteビルドエラー

問題: WASMモジュールをstatic importすると、Vite SSRビルドで失敗

```typescript // ❌ ビルドエラーになる import { Resvg } from '@cf-wasm/resvg/workerd'; ```

教訓:

  • WASMモジュールはdynamic importで読み込む
  • サーバーサイドでのみ使用するモジュールは遅延読み込み

失敗3: Hero/Villainのサイズ不統一

問題: Heroポジションバッジを'large'、Villainを'normal'で描画し、視覚的不統一に

教訓:

  • 同じ役割の要素は同じサイズ定数を使用
  • コードレビューでサイズ定数の一貫性を確認

失敗4: テキストサイズの階層設計なし

問題: "vs"、"Board"、"Made by..."のテキストが小さすぎて読みにくい

教訓:

  • フォントサイズ階層を事前設計
  • メインコンテンツ → 見出し → 接続テキスト → 補足情報の順で視認性を確保

失敗5: アクションが見切れる

問題: 3つ目のアクションがストリートタブの下端で見切れる

教訓:

  • SNSプレビューサイズ(1200x630)の制約を意識
  • gap/paddingを調整して表示領域を最大化
  • 実際のSNS共有でプレビューを確認

失敗6: Canvas専用レンダラーによるGIF出力の試み

問題: GIF/PNG export用にCanvas専用レンダラーを実装したが、レイアウトの動的複雑性により二重管理が非現実的に

教訓:

  • 既存UIコンポーネントを画像化するアプローチを優先
  • Satori + resvg-wasmならHTMLテンプレートをそのまま画像化可能
  • Canvas専用レンダラーは「最終手段」として検討

2.9 パフォーマンス最適化

最適化項目 効果
フォントサブセット化 4.44MB → 50.71KB (98.9%削減)
Dynamic import Viteビルド対応 + 遅延読み込み
キャッシュヘッダー CDN活用(1年キャッシュ)
@cf-wasm/resvg Workers最適化済みWASM
gap/padding最適化 表示領域最大化

2.10 再利用のためのチェックリスト

  • Edge/Workers環境対応ライブラリの選定(WASM版確認)
  • WASMモジュールはdynamic importで読み込み
  • フォントサブセット化(必要文字のみ抽出)
  • HTMLテンプレートはFlexbox + インラインスタイルのみ
  • サイズ定数を一元管理(カード、バッジ、フォント)
  • フォントサイズ階層を設計
  • キャッシュ戦略の設計(immutableヘッダー + 開発時バイパス)
  • 実際のSNSプレビューで表示確認

第3章: 共通の学び

3.1 モノレポ構成のベストプラクティス

```yaml

pnpm-workspace.yaml

packages:

  • 'packages/*'
  • 'apps/*'

バージョン管理は catalog で一元化

catalog: typescript: ^5.0.0 svelte: ^5.0.0 zod: 4.1.5 ```

利点:

  • 依存バージョンの一元管理
  • パッケージ間の型共有が容易
  • ビルド順序の自動解決(pnpm + tsup)

3.2 関数型プログラミング原則

  1. クラス禁止 - 全ロジックを純粋関数で実装
  2. Immutable更新 - スプレッド演算子による新規オブジェクト生成
  3. Result型 - 例外の代わりに`{ success, data, error }`パターン
  4. Pipe関数 - 処理の合成と可読性向上

3.3 テスト戦略

```typescript // AAA (Arrange-Act-Assert) パターン describe('parseFlopsNotation', () => { it('should parse basic preflop action', () => { // Arrange const input = 'BTN r2.5 AhKs';

// Act
const result = parseFlopsNotation(input);

// Assert
expect(result.success).toBe(true);
expect(result.data.action.type).toBe('raise');

}); }); ```

3.4 デプロイメント構成

``` Frontend: Cloudflare Pages ├── SvelteKit + adapter-cloudflare └── OGP画像生成(サーバーサイド)

Backend: Cloudflare Workers ├── Hono (軽量HTTPフレームワーク) ├── D1 (SQLiteベースDB) └── Drizzle ORM (型安全) ```


付録A: 利用ライブラリ一覧

Parser関連

ライブラリ バージョン 用途
zod 4.1.5 スキーマ定義・バリデーション
typescript ^5.0.0 型システム

OGP関連

ライブラリ バージョン 用途
satori ^0.12.0 HTML→SVG変換
satori-html ^0.3.0 HTML解析
@cf-wasm/resvg ^1.0.0 Cloudflare Workers専用SVG→PNG
subset-font ^2.4.0 フォントサブセット化

Note: `@resvg/resvg-wasm`ではなく`@cf-wasm/resvg`を使用。Cloudflare Workers環境に最適化されている。

フロントエンド

ライブラリ バージョン 用途
svelte ^5.0.0 UIフレームワーク
@sveltejs/kit ^2.22.0 フルスタックフレームワーク
tailwindcss ^4.0.0 スタイリング

バックエンド

ライブラリ バージョン 用途
hono ~4.9.9 HTTPフレームワーク
drizzle-orm ~0.44.5 ORM

付録B: OGP画像レイアウト仕様

画像サイズ

  • 標準OGPサイズ: 1200 x 630 px

レイアウト構成

``` ┌──────────────────────────────────────────────────────────────────────┐ │ [BTN] A♠K♣ vs [UTG] K♣K♦ [BB] ?? │ ← Hero vs Opponents (Row 1) │ Board Q♥7♦2♣K♠3♥ │ ← Board (Row 2, centered) ├──────────────────────────────────────────────────────────────────────┤ │ PREFLOP │ FLOP │ TURN │ RIVER │ ← Street Headers (28px) │ 2.5bb │ 51.5bb │ 51.5bb │ 201.5bb │ ← Pot Size (22px) ├────────────────┼──────────────┼──────────────┼──────────────────────┤ │ [UTG+1] Raise 3│ [UTG+1] Bet │ [UTG+1] AI │ — │ │ [SB ] Raise 9│ [SB ] Call │ [SB ] Call │ │ ← Actions (32px) │ [UTG+1] R 25 │ │ │ │ │ [SB ] Call │ │ │ │ └────────────────┴──────────────┴──────────────┴──────────────────────┘ │ Made by flopsapp.com │ ← Footer (18px) └──────────────────────────────────────────────────────────────────────┘ ```

サイズ定数

要素 サイズ 備考
カード (large) 64 x 88 px Hero, Villain, Board共通
ポジションバッジ (large) width: 72px, font: 20px 固定幅で位置揃え
アクションテキスト 32px メインコンテンツ
Street見出し 28px セクションヘッダー
"vs" / "Board" 24px 接続テキスト
Potサイズ 22px 補足情報
Footer 18px ブランディング
アクション間隔 gap: 4px 表示領域最大化

更新日: 2025-01-18 Flops Project - Technical Reuse Guide v2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment