Markdown Converter
Agent skill for markdown-converter
Sign in to like and favorite skills
あなたはVue 3 + TypeScriptプロジェクトのAIアシスタントです。
単独での実行、他のSubagentからの呼び出し、どちらのケースでも適切に動作し、明確な結果を返します。
このガイドラインは、AI実装で発生しがちな問題パターンとその解決方法をまとめたものです。
このプロジェクト(pedaru-vue)は、React/Next.jsベースのPDFビューワーアプリ「pedaru」をVue3/Nuxtに移植するものです。
目的:
技術スタック:
<script setup>)| React | Vue 3 | 備考 |
|---|---|---|
| / | プリミティブはref、オブジェクトはreactive |
| / / | 依存配列の有無で使い分け |
| 通常の関数 | Vueでは不要(必要に応じてcomputed) |
| | |
| / | DOM参照はuseTemplateRef |
| / または Pinia | グローバルはPinia推奨 |
| Pinia store |
React (useState + useEffect):
const [count, setCount] = useState(0); const [doubled, setDoubled] = useState(0); useEffect(() => { setDoubled(count * 2); }, [count]);
Vue3 (ref + computed):
const count = ref(0); const doubled = computed(() => count.value * 2);
依存配列なし(マウント時のみ):
// React useEffect(() => { console.log('mounted'); }, []);
// Vue3 onMounted(() => { console.log('mounted'); });
依存配列あり(値の変更を監視):
// React useEffect(() => { fetchData(id); }, [id]);
// Vue3 watch(() => id.value, (newId) => { fetchData(newId); }, { immediate: true });
クリーンアップあり:
// React useEffect(() => { const timer = setInterval(() => {}, 1000); return () => clearInterval(timer); }, []);
// Vue3 onMounted(() => { const timer = setInterval(() => {}, 1000); onUnmounted(() => clearInterval(timer)); });
以下に該当するものは移植対象外とする:
原則: pages/配下のコンポーネントを修正する際は、Atomic Designの考えに基づき、新規コンポーネントを作成してロジックを分離する。
pedaru-vue/ ├── components/ │ ├── atoms/ # 基本的なUI要素 │ ├── molecules/ # 複合コンポーネント │ └── organisms/ # 複雑な機能 ├── composables/ # ビジネスロジック(型定義も同じファイルに配置) ├── pages/ # ルートコンポーネント(薄く保つ) └── stores/ # Pinia stores
ガイドライン:
use*で始まる)❌ Bad: 複数の責任を持つ巨大なComposable
// useVideoManagement.ts(悪い例) export function useVideoManagement() { // ポーリング、時間計算、セッション記録、Zoom SDK操作が混在 // 100行以上の複雑なロジック... }
✅ Good: 責任を分離
// useVideoStatus.ts - ビデオステータスのポーリング専用 export function useVideoStatus() { const videoStatus = ref<VideoStageStatusResponse | null>(null); const fetchVideoStatus = async (id: number) => { /* ... */ }; const startPolling = (id: number) => { /* ... */ }; const stopPolling = () => { /* ... */ }; return { videoStatus, fetchVideoStatus, startPolling, stopPolling }; } // useSessionElapsedTime.ts - 時間計算専用 export function useSessionElapsedTime(sessionStartTime: Ref<string | null>) { const elapsedTime = computed(() => { /* ... */ }); return { elapsedTime }; }
✅ Good: 複数のComposableを組み合わせる(Compose)
重要な判断基準:責任範囲の正しい分離
例:症状選択機能で「選択」と「送信履歴管理」を1つのcomposableに混在させてはいけません。
// ❌ Bad: 責任範囲が混在 // useSymptomSelection.ts export function useSymptomSelection() { // 症状選択の責任 const toggleSymptomSelection = (key: SymptomItemKeyType) => { /* ... */ }; // 送信履歴管理の責任(別の関心事!) const markAsSent = (key: SymptomItemKeyType) => { /* ... */ }; const onSendSuccess = () => { /* 混在している */ }; return { toggleSymptomSelection, markAsSent, onSendSuccess }; }
正しい設計:各関心事を独立したcomposableに分離し、組み合わせて使用
// useSymptomSelection.ts - 症状選択のみに集中 export function useSymptomSelection() { const selectedSymptomItems = ref<Set<SymptomItemKeyType>>(new Set()); const toggleSymptomSelection = (key: SymptomItemKeyType) => { /* ... */ }; const resetSelectedSymptoms = () => { /* ... */ }; const selectedSymptoms = computed(() => { /* ... */ }); return { selectedSymptomItems, selectedSymptoms, toggleSymptomSelection, resetSelectedSymptoms }; } // useSymptomSendHistory.ts - 送信履歴管理専用(新規ファイル) export function useSymptomSendHistory() { const sentSymptomItems = ref<Set<SymptomItemKeyType>>(new Set()); const markAsSent = (keys: SymptomItemKeyType[]) => { keys.forEach(key => sentSymptomItems.value.add(key)); }; const isSent = (key: SymptomItemKeyType): boolean => { return sentSymptomItems.value.has(key); }; return { sentSymptomItems, markAsSent, isSent }; } // useChatMessageGenerator.ts - メッセージ生成専用(新規ファイル) export function useChatMessageGenerator() { const generateSymptomMessage = (symptoms: SymptomItem[]): string => { const mainMessage = '症状に合わせたホームケアのPDFをお送りします。'; const contents = symptoms.map((s) => `・${s.title}\n${s.url}\n`).join('\n'); return `${mainMessage}\n${contents}`; }; return { generateSymptomMessage }; } // ChatAttachedPdfSelect.vue - 複数のcomposableを組み合わせる(Compose) const { selectedSymptoms, resetSelectedSymptoms } = useSymptomSelection(); const { markAsSent } = useSymptomSendHistory(); const { generateSymptomMessage } = useChatMessageGenerator(); const handleSendChat = async () => { const message = generateSymptomMessage(selectedSymptoms.value); const success = await props.onSendChat(message); if (success) { markAsSent(selectedSymptoms.value.map(s => s.key)); resetSelectedSymptoms(); await showToast(); } };
メリット:
ガイドライン:
// NOTE: Piniaにアクセスしないなど)❌ Bad: 技術的な詳細とビジネスロジックが混在
// useZoomVideoSession.ts(悪い例) export function useZoomVideoSession() { const startSession = async () => { // Zoom SDKの初期化 zoomClient.value = ZoomVideo.createClient(); await zoomClient.value.init('ja-JP', 'Global'); // ビジネスロジック(DB記録)が混在 await videoStageRepository.create({ status: 'active' }); // Piniaへのアクションも混在 pdfStore.updateStatus('active'); }; }
✅ Good: レイヤーを明確に分離
// useZoomVideo.ts - 技術層(Zoom SDK操作のみ) export function useZoomVideo() { const createSession = async (sessionId: string) => { /* Zoom SDKのみ */ }; const joinSession = async (sessionId: string) => { /* Zoom SDKのみ */ }; // NOTE: Piniaにアクセスしない return { createSession, joinSession }; } // useOnlineReservationVideoSession.ts - アプリケーション層 export function useOnlineReservationVideoSession() { const { createSession, joinSession } = useZoomVideo(); const startTalking = async (sessionId: string, id: number) => { await createSession(sessionId); // 1. Zoom SDK await videoStageRepository.create({ id }); // 2. DB記録 pdfStore.updateStatus({ ... }); // 3. Pinia await joinSession(sessionId); // 4. Zoom SDK }; return { startTalking }; }
ガイドライン:
❌ Bad: グローバルなステート共有(シングルトン)
// composable外でstateを定義 const isVideoSessionActive = ref(false); export function useVideoStatus() { // 複数のコンポーネントで同じインスタンスを共有 return { isVideoSessionActive }; }
✅ Good: composable内でstateを定義(個別インスタンス)
export function useVideoStatus() { // composable内でstateを定義(呼び出しごとに新しいインスタンス) const isVideoSessionActive = ref(false); const preparationState = reactive({ onlineReservationId: null as number | null, }); return { isVideoSessionActive, preparationState }; }
ガイドライン:
getCurrentInstance()でコンポーネント外での使用を考慮onUnmountedでリソース解放を保証❌ Bad: クリーンアップ処理の欠如
export function useVideoStatus() { let pollingTimer: number | null = null; const startPolling = (id: number) => { pollingTimer = window.setInterval(() => { /* ... */ }, 10000); }; // クリーンアップ処理がない! return { startPolling }; }
✅ Good: 適切なクリーンアップ処理
export function useVideoStatus() { let pollingTimer: number | null = null; const startPolling = (id: number) => { if (pollingTimer !== null) return; // 重複防止 pollingTimer = window.setInterval(() => { /* ... */ }, 10000); }; const stopPolling = () => { if (pollingTimer !== null) { clearInterval(pollingTimer); pollingTimer = null; } }; // コンポーネント外で使用される可能性を考慮 const instance = getCurrentInstance(); if (instance) { onUnmounted(() => stopPolling()); } return { startPolling, stopPolling }; }
ガイドライン:
as constで定義し、型推論を活用map/filterを使用)❌ Bad: UIとロジックが密結合
export function useBadSymptomSelection() { const selectedSymptoms = ref<string[]>([]); // 症状ごとにメソッドを追加する必要がある const addCough = () => { selectedSymptoms.value.push('咳'); }; const addFever = () => { selectedSymptoms.value.push('発熱'); }; return { selectedSymptoms, addCough, addFever }; }
✅ Good: マスターデータから自動生成
// 1. マスターデータの定義(as constで型推論) const symptomItems = { seki: { title: '咳', category: '咳' }, netsu_jyunyu: { title: '発熱(授乳期)', category: '発熱' }, hanamizu: { title: '鼻水', category: '鼻水' }, } as const; // 2. 型の自動生成 export type SymptomItemKeyType = keyof typeof symptomItems; // 3. データからUI構造を自動生成 const symptoms = categories.map((category) => ({ category, items: Object.entries(symptomItems) .filter(([, item]) => item.category === category) .map(([key, { title }]) => ({ key: key as SymptomItemKeyType, title, url: generateUrl(key as SymptomItemKeyType), })), })); // 4. 汎用的なビジネスロジック export const toggleSymptomSelection = (key: SymptomItemKeyType) => { if (selectedSymptomItems.value.has(key)) { selectedSymptomItems.value.delete(key); } else { selectedSymptomItems.value.add(key); } };
拡張性の実例:
// ✅ 新しい症状を追加(データ定義のみ、1箇所の変更) const symptomItems = { // ... 既存の定義 atopy: { title: 'アトピー性皮膚炎', category: '皮膚トラブル' }, // 追加 } as const; // → UIは自動的に更新される
ガイドライン:
| 項目 | Bad Pattern | Good Pattern |
|---|---|---|
| 親コンポーネントの変更行数 | 300行以上 | 10行以内 |
| 新規Import | なし(全て親に実装) | 1行のみ |
| 既存メソッドの変更 | 複数のメソッド修正 | 変更なし(再利用) |
| 新規dataの追加 | 5個以上 | 0個 |
| テスト対象 | 親コンポーネント全体 | 新規コンポーネントのみ |
interface Props { onSendChat: (message: string) => void; // コールバック関数 isDoctorPage: boolean; // 表示制御フラグ }
なぜEmitではなくコールバック関数を使うのか:
// ❌ Bad: Emitを使う場合(親側の変更が必要) // 親コンポーネント(新規メソッドが必要) <ChatAttachedPdfSelect @send-chat="handleSymptomChatSend" /> methods: { handleSymptomChatSend(message) { this.handleChatSend(message); // 既存メソッドを呼ぶだけ } } // ✅ Good: コールバック関数を使う場合(親側の変更不要) // 親コンポーネント(既存メソッドをそのまま渡す) <ChatAttachedPdfSelect :onSendChat="handleChatSend" /> // 新規メソッド不要!
ガイドライン:
ディレクトリ構造:
pedaru-vue/ ├── pages/ │ └── index.vue # 薄いルートコンポーネント(50行以内) ├── components/ │ ├── organisms/ │ │ └── PdfViewer.vue # PDFビューワー全体 │ ├── molecules/ │ │ ├── PdfToolbar.vue # ツールバー │ │ └── PdfPageNav.vue # ページナビゲーション │ └── atoms/ │ ├── BaseButton.vue # ボタン │ └── BaseIcon.vue # アイコン ├── composables/ │ ├── usePdfViewer.ts # 型定義もこのファイル内に配置 │ └── usePdfNavigation.ts └── stores/ └── pdf.ts # Pinia store(型定義も同じファイル内)
ガイドライン:
?を使う)✅ Good: 型安全なProps/Emits定義
<script setup lang="ts"> // Props定義をinterfaceで明示 interface Props { isOnCamera: boolean; isOnAudio: boolean; nurseName: string; patientName?: string; // オプショナルは明示的に } const props = defineProps<Props>(); // Emits定義も型安全に interface Emits { (e: 'update:modelValue', value: boolean): void; (e: 'leave', reason: 'user-action' | 'timeout'): void; } const emit = defineEmits<Emits>(); </script>
コールバック vs Emit の使い分け:
<!-- パターン1: Emitを使う(シンプルな通知) --> <script setup lang="ts"> interface Emits { (e: 'close'): void; (e: 'submit', data: FormData): void; } const emit = defineEmits<Emits>(); </script> <!-- パターン2: コールバック関数を使う(複雑な処理フロー) --> <script setup lang="ts"> interface Props { isDisplayVideoWindow: boolean; leaveSession: () => Promise<void>; // 関数を直接渡す } const props = defineProps<Props>(); const onLeaveClick = async () => { // NOTE: 親コンポーネントで定義した処理を利用する必要がある // 理由:親のVideoStatusPanelで表示制御のフラグ更新とZoomのビデオ退出を行う await props.leaveSession(); }; </script>
ガイドライン:
ガイドライン:
types/ディレクトリは作らない)❌ Bad: 別ディレクトリに型定義を分離
composables/ └── usePdfViewer.ts types/ └── pdf.ts # 型定義が離れている
✅ Good: ロジックと型定義を同じファイルに配置
// composables/usePdfViewer.ts // 型定義(このcomposableで使用する型) export interface PdfViewerState { currentPage: number; totalPages: number; scale: number; } export type PdfLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'; // ロジック export function usePdfViewer() { const state = reactive<PdfViewerState>({ currentPage: 1, totalPages: 0, scale: 1.0, }); const status = ref<PdfLoadStatus>('idle'); // ... return { state, status }; }
メリット:
ガイドライン:
❌ Bad: 文字列リテラルを直接使用
const status = ref<string>('notYetStarted'); const updateStatus = (newStatus: string) => { status.value = newStatus; // 任意の文字列を許容してしまう }; updateStatus('typo-status'); // コンパイルエラーにならない
✅ Good: as constとUnion型の活用
// 定数オブジェクトをas constで定義 export const OnlineReservationVideoSessionStatus = { notYetStarted: 'notYetStarted', sessionCreating: 'sessionCreating', sessionCreated: 'sessionCreated', sessionStarted: 'sessionStarted', } as const; // Union型を自動生成 export type OnlineReservationVideoSessionStatusType = (typeof OnlineReservationVideoSessionStatus)[keyof typeof OnlineReservationVideoSessionStatus]; // 使用例 const status = ref<OnlineReservationVideoSessionStatusType>( OnlineReservationVideoSessionStatus.notYetStarted ); updateStatus(OnlineReservationVideoSessionStatus.sessionStarted); // ✅ OK updateStatus('typo-status'); // ❌ コンパイルエラー
Template Literal Typeの活用:
// 命名規則を型レベルで表現 type ZoomRoomNameType = `online_reservation_${number}`; const createRoomName = (id: number): ZoomRoomNameType => { return `online_reservation_${id}`; };
ガイドライン:
is演算子を使って定義❌ Bad: unknownをanyにキャスト
const onErrorOccur = (e: unknown) => { const error = e as any; // anyにキャストして型チェックを回避 if (error.errorCode) { Sentry.captureMessage(`Error code: ${error.errorCode}`); } };
✅ Good: Type Guardで安全に型を絞り込む
// Type Guardの定義 interface ZoomErrorObject { type?: string; reason?: string; errorCode?: number; } export const isZoomErrorObject = (error: unknown): error is ZoomErrorObject => { return ( error !== null && typeof error === 'object' && ('type' in error || 'reason' in error || 'errorCode' in error) ); }; // 使用例 const onErrorOccur = (e: unknown) => { if (isZoomErrorObject(e)) { // この中ではeはZoomErrorObject型として扱える Sentry.captureMessage(`Zoom Error: ${e.errorCode}`); } else { Sentry.captureException(e); } };
ガイドライン:
❌ Bad: 実装の詳細をテスト
describe('useVideoStatus', () => { it('videoStatusはrefである', () => { const { videoStatus } = useVideoStatus(); expect(isRef(videoStatus)).toBe(true); // 価値が低い }); it('isLoadingの初期値はfalseである', () => { const { isLoading } = useVideoStatus(); expect(isLoading.value).toBe(false); // 価値が低い }); });
✅ Good: 振る舞いをテスト
describe('useVideoStatus', () => { it('API から VideoStage 情報を取得して videoStatus に設定する', async () => { const mockResponse = { data: { video_stages: [{ id: 1, status: 'active' }] } }; mockVideoStageRepository.fetchStatus.mockResolvedValue(mockResponse); const { videoStatus, fetchVideoStatus } = useVideoStatus(); await fetchVideoStatus(123); expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledWith(123); expect(videoStatus.value).toMatchObject({ valid_status: true }); }); it('API エラー時に error メッセージを設定する', async () => { mockVideoStageRepository.fetchStatus.mockRejectedValue(new Error('Network error')); const { error, fetchVideoStatus } = useVideoStatus(); await fetchVideoStatus(123); expect(error.value).toBe('状態の取得に失敗しました'); }); });
ガイドライン:
vi.useFakeTimers()でタイマーを制御可能にvi.advanceTimersByTimeAsync()で時間を進めるSettings.nowも設定vi.useRealTimers()を呼ぶ❌ Bad: 実際の時間を待つ
it('1秒後に経過時間が更新される', async () => { const { elapsedTime } = useSessionElapsedTime(sessionStartTime); await new Promise(resolve => setTimeout(resolve, 1000)); // テストが遅い expect(elapsedTime.value).toBe('00:00:01'); });
✅ Good: Fake Timersを使用
describe('useSessionElapsedTime', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); Settings.now = () => Date.now(); // Luxonの時刻もリセット }); it('HH:mm:ss形式で経過時間を返す', () => { const now = new Date('2025-01-07T10:30:00'); vi.setSystemTime(now); Settings.now = () => now.getTime(); const startTime = DateTime.fromISO('2025-01-07T09:00:00'); const sessionStartTime = ref(startTime.toISO()); const { elapsedTime } = useSessionElapsedTime(sessionStartTime); expect(elapsedTime.value).toBe('01:30:00'); }); }); describe('startPolling', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('10秒間隔でポーリングが実行される', async () => { mockVideoStageRepository.fetchStatus.mockResolvedValue({ data: {} }); const { startPolling } = useVideoStatus(); startPolling(123); await vi.advanceTimersByTimeAsync(0); expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(10000); // 10秒後 expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledTimes(2); }); });
新しい機能を実装する際は、以下をチェックしてください:
<script setup>とTypeScriptを使用しているか?types/ディレクトリは作らない)as constで定義しているか?getCurrentInstance()でコンポーネント外での使用を考慮したか?AIに実装を依頼する際は、以下を意識してください:
<script setup>を使用as constで型推論、1箇所で管理これらの原則に従うことで、保守性が高く、テストしやすく、拡張性のある、品質の高いVue/TypeScriptコードを実装できます。