apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
description の原文を見る
End-to-end guidance for AppRun apps in TypeScript using MVU including component patterns, event handling, state management (including async generators), routing/navigation with params and guards, and testing with vitest. Use when designing or reviewing AppRun components, wiring routes, managing state flows, or writing AppRun tests.
SKILL.md 本文
AppRun スキル
概要
- TypeScript で MVU (Model-View-Update) パターンを使用して AppRun アプリを構築します。
- テスト性を高めるため、純粋な更新関数を優先します。
- JSX に埋め込まれたコンポーネントには
mounted()を使用します。 - 非同期データを読み込む必要があるトップレベルのルート化されたページには、
state = asyncのみを使用します。
プロジェクトセットアップ
推奨プロジェクト構造
web/ # フロントエンドアプリケーションルート
├── index.html # エントリ HTML ファイル
├── package.json # 依存関係とスクリプト
├── vite.config.js # Vite 設定
├── src/
│ ├── main.tsx # アプリケーションエントリポイント(ルート登録)
│ ├── api.ts # REST API クライアント(オプション)
│ ├── styles.css # アプリケーションスタイル
│ ├── tsconfig.json # TypeScript 設定
│ ├── components/ # 再利用可能な UI コンポーネント
│ │ ├── Layout.tsx # ルートレイアウトコンテナ
│ │ └── ... # その他の再利用可能なコンポーネント
│ ├── domain/ # ビジネスロジックモジュール(オプション)
│ │ └── ... # 純粋関数とビジネスロジック
│ ├── pages/ # トップレベルページコンポーネント
│ │ ├── Home.tsx # 例: ホームページ
│ │ └── ... # その他のルートページ
│ ├── types/ # TypeScript 型定義
│ │ ├── index.ts # 共有型
│ │ └── jsx.d.ts # JSX 型宣言
│ └── utils/ # ユーティリティ関数
└── public/ # 静的アセット(オプション)
Vite 設定
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 8080,
open: true,
historyApiFallback: true, // SPA モード
proxy: {
// API リクエストをバックエンドにプロキシ
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
secure: false
}
}
}
})
Package.json
{
"name": "my-apprun-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "tsc --noEmit"
},
"devDependencies": {
"apprun": "^3.38.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
TypeScript 設定
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react",
"jsxFactory": "app.createElement",
"jsxFragmentFactory": "app.Fragment",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
AppRun の重要な設定:
jsx: "react"- JSX 構文を有効にしますjsxFactory: "app.createElement"- AppRun の JSX ファクトリを使用しますjsxFragmentFactory: "app.Fragment"- AppRun の Fragment サポートを使用しますmoduleResolution: "bundler"- Vite 用に最適化されています
エントリポイント
HTML エントリ (index.html):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My AppRun App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
</body>
</html>
アプリケーションエントリ (src/main.tsx):
import app from 'apprun';
import Layout from './components/Layout';
import Home from './pages/Home';
import About from './pages/About';
import './styles.css';
app.render('#root', <Layout />);
app.addComponents('#pages', {
'/': Home,
'/about': About,
});
レイアウトコンポーネント (src/components/Layout.tsx):
import app from 'apprun';
export default () => (
<div id="app">
<div id="pages"></div>
</div>
);
スタイリングオプション
オプション 1: バニラ CSS
/* src/styles.css */
:root {
--color-primary: #007bff;
--color-text: #333;
--spacing-unit: 8px;
}
body {
font-family: system-ui, -apple-system, sans-serif;
color: var(--color-text);
margin: 0;
padding: 0;
}
オプション 2: Tailwind CSS v4
Tailwind v4 をインストール:
npm install -D tailwindcss@next @tailwindcss/vite@next
vite.config.js を更新:
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
// ... その他の設定
})
src/styles.css にインポート:
@import "tailwindcss";
コンポーネント内で使用:
<div className="flex items-center gap-4 p-4 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold">Hello World</h1>
</div>
オプション 3: CSS Modules
import styles from './MyComponent.module.css';
export default () => (
<div className={styles.container}>
<h1 className={styles.title}>Hello</h1>
</div>
);
API クライアントパターン
// src/api.ts
const API_BASE_URL = '/api';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
async function request<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { params, ...fetchOptions } = options;
let url = `${API_BASE_URL}${endpoint}`;
if (params) {
const query = new URLSearchParams(params).toString();
url += `?${query}`;
}
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
...fetchOptions,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, data?: unknown) =>
request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
}),
put: <T>(endpoint: string, data?: unknown) =>
request<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
};
export default api;
クイックスタート
# 1. プロジェクト作成
npm create vite@latest my-apprun-app -- --template vanilla-ts
cd my-apprun-app
# 2. AppRun をインストール
npm install
npm install -D apprun
# 3. TypeScript を設定(上記の設定で tsconfig.json を更新)
# 4. エントリファイルをリネーム
mv src/main.ts src/main.tsx
# 5. 基本的なアプリ構造を作成
# (上記で示した Layout、ページ、コンポーネントを追加)
# 6. 開発サーバーを実行
npm run dev
# 7. 本番用にビルド
npm run build
npm run preview
Vite + AppRun を選ぶ理由
Vite を選ぶ理由:
- 高速な開発と即座な HMR(ホットモジュールリプレイスメント)
- Rollup による最適化ビルド
- ファーストクラスの TypeScript サポート
- 最小限の設定
AppRun を選ぶ理由:
- 軽量(gzip で約 7KB)
- シンプルな MVU パターン
- 直接的な DOM 更新(仮想 DOM なし)
- 完全な TypeScript サポート
- ビルトインルーティング
コンポーネントパターン - デシジョンツリー
- 状態を管理 + ユーザー操作を処理? → ステートフルクラスコンポーネント
- ポップアップ/モーダル/オーバーレイ? → モーダルコンポーネント(
mounted()を使用) - props から表示のみ? → 関数型コンポーネント
- 10 以上のイベント、型安全性が必要? → 型付きイベントパターン
ステートフルクラスコンポーネント
構造順序: インポート → インターフェース → ヘルパー → アクション → コンポーネント
import { app, Component } from 'apprun';
interface Props { data?: any; }
export interface State {
loading: boolean;
error: string | null;
successMessage?: string;
// ... 特定のフィールド
}
const getStateFromProps = (props: Props): State => ({ /* ... */ });
export const saveData = async function* (state: State): AsyncGenerator<State> {
// バリデーション
if (!state.data.name.trim()) {
yield { ...state, error: '名前が必須です' };
return;
}
// ローディング
yield { ...state, loading: true, error: null };
// API コール
try {
await api.save(state.data);
yield { ...state, loading: false, successMessage: '保存されました!' };
app.run('data-saved');
} catch (error: any) {
yield { ...state, loading: false, error: error.message };
}
};
export default class MyComponent extends Component<State> {
declare props: Readonly<Props>;
mounted = (props: Props): State => getStateFromProps(props);
view = (state: State) => {
if (state.loading) return <div>読み込み中...</div>;
if (state.error) return <div className="error">{state.error}</div>;
return (
<form>
<input $bind="data.name" />
<button $onclick={[saveData]} disabled={state.loading}>保存</button>
</form>
);
};
}
ビューパターン: ガード句 → 早期リターン → メインコンテンツ
モーダルコンポーネント
重要: state = async ではなく、mounted() を使用する必要があります(JSX に埋め込まれている場合)
export default class Modal extends Component<State> {
declare props: Readonly<Props>;
mounted = (props: Props): State => getStateFromProps(props);
view = (state: State) => (
<div className="modal-backdrop" onclick={closeModal}>
<div className="modal-content" onclick={(e) => e.stopPropagation()}>
<button onclick={closeModal}>×</button>
{/* コンテンツ */}
</div>
</div>
);
}
要件: クローズボタン + バックドロッククリック + stopPropagation
関数型コンポーネント
export interface Props {
data: DataType[];
onItemClick?: (item: DataType) => void;
}
export default function DisplayComponent({ data, onItemClick }: Props) {
if (!data?.length) return <div>アイテムなし</div>;
return (
<ul>
{data.map(item => (
<li onclick={() => onItemClick?.(item)}>{item.name}</li>
))}
</ul>
);
}
パターン: 分割代入 → ガード句 → メインレンダー
型付きイベントパターン
ペイロード規則:
- 単一値 →
payload: string| 呼び出し:$onclick={['delete', id]} - 複数値 →
payload: { id: string; name: string }| 呼び出し:$onclick={['edit', { id, name }]} - ペイロードなし →
payload: void| 呼び出し:$onclick="save" - 入力イベント →
payload: { target: { value: string } }
// types/events.ts
export type MyEvents =
| { name: 'save'; payload: void }
| { name: 'delete'; payload: string }
| { name: 'edit'; payload: { id: string; name: string } };
export type MyEventName = MyEvents['name'];
// コンポーネント
class MyComponent extends Component<State, MyEventName> {
override update = myHandlers;
}
// ハンドラー(配列形式ではなくオブジェクト形式)
export const myHandlers: Update<State, MyEventName> = {
save: (state): State => ({ ...state, saved: true }),
delete: (state, id: string): State => ({
...state,
items: state.items.filter(i => i.id !== id)
}),
edit: (state, { id, name }: { id: string; name: string }): State => ({
...state,
editing: { id, name }
})
};
stopPropagation: イベントを最後のパラメータとして追加
'click-item': (state, id: string, e?: Event): State => {
e?.stopPropagation();
return { ...state, selected: id };
}
イベントディレクティブ
AppRun ディレクティブ(更新ハンドラーをトリガー)
| ディレクティブ | ユースケース | 例 |
|---|---|---|
$bind="field" | 双方向バインディング(フォーム用推奨) | <input $bind="name" /> |
$bind="nested.field" | ネストされたプロパティ | <input $bind="user.profile.name" /> |
$onclick="action" | 文字列アクション | <button $onclick="save" /> |
$onclick={['action', data]} | パラメータ付きアクション | <button $onclick={['delete', id]} /> |
$onclick={[func]} | 直接関数 | <button $onclick={[saveData]} /> |
$oninput="handler" | カスタム入力処理 | <input $oninput="validate" /> |
その他のディレクティブ: $onchange、$onsubmit、$onfocus、$onblur、$onkeydown
標準 HTML イベント(DOM 操作)
DOM 操作のみに onclick、oninput などを使用します:
<div onclick={(e) => e.stopPropagation()}>コンテンツ</div>
使い分け
- ✅
$bind- シンプルなフォームフィールド(ハンドラー不要) - ✅
$oninput- バリデーション、変換、デバウンス - ✅
$onclick- 更新ハンドラーをトリガー - ❌ しないこと -
$onclick={() => app.run('action')}
バリデーション例:
$oninput="validate-email"
'validate-email': (state, e: Event) => {
const email = (e.target as HTMLInputElement).value;
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return { ...state, email, emailError: valid ? null : '無効です' };
}
更新ハンドラー
同期: 新しい状態を返す
'increment': (state) => ({ ...state, count: state.count + 1 })
非同期: async を使用
'load': async (state) => {
this.setState({ ...state, loading: true });
const data = await api.fetch();
return { ...state, data, loading: false };
}
ジェネレーター: 複数ステップで中間レンダー(複雑なフローに推奨)
'save': async function* (state) {
yield { ...state, loading: true };
await api.save(state.data);
yield { ...state, loading: false, success: true };
}
サイドエフェクト: リターンなし = 再レンダーなし
'navigate': (state) => {
window.location.href = '/path';
// リターンなし - 再レンダーなし
}
コンポーネント通信
| パターン | ユースケース | 実装 |
|---|---|---|
| Props | 親 → 子 | props 経由でデータを渡す |
| コールバック | 子 → 親 | props 経由で関数を渡す |
| グローバルイベント | いずれか → いずれか | is_global_event = () => true |
グローバルイベント:
// モーダルコンポーネント
class Modal extends Component {
is_global_event = () => true;
update = {
'open-modal': (state, data) => ({ ...state, visible: true, data }),
'close-modal': (state) => ({ ...state, visible: false })
};
}
// どのコンポーネントからでもトリガーできます
<button onclick={() => app.run('open-modal', data)}>開く</button>
重要なルール
状態初期化
| コンポーネントタイプ | 使用 | 例 |
|---|---|---|
| JSX 埋め込み | mounted() | mounted = (props) => getStateFromProps(props) |
| トップレベルルート化 | state = async | state = async () => { const data = await api.fetch(); return { data }; } |
❌ 両方を混在させるのは厳禁 mounted() と state = async
状態更新
状態を返すと再レンダーがトリガーされます:
- イミュータブル(推奨):
return { ...state, field: value } - ミュータブル(許可):
state.field = value; return state - サイドエフェクトのみ: リターンなし(再レンダーなし)
必須の状態プロパティ
interface State {
loading: boolean; // 非同期操作用
error: string | null; // エラーメッセージ用
successMessage?: string; // 成功フィードバック用
// ... 特定のフィールド
}
ディープクローン
// ネストされたオブジェクト更新
return {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
name
}
}
};
アンチパターン
❌ しないこと:
// app.run を呼び出すアロー関数で $onclick を使わない
$onclick={() => app.run('action')}
// 非同期で エラーハンドリングを忘れない
async function save() { await api.save(); } // try-catch がない!
// $bind が利用可能な場合、手動入力を使わない
$oninput={(e) => setState({ ...state, field: e.target.value })}
// JSX に埋め込まれたコンポーネントに state = async を使わない
class Modal extends Component {
state = async () => { /* 間違い */ };
}
// 防御的なプログラミングを忘れない
messages.map() // messages は未定義かもしれません - messages?.map() を使用
// 更新ハンドラーに配列形式を使わない
update = [['event', handler]] // 間違い - オブジェクト形式を使用
// 状態を直接ミューテートしない
state.count++; // 間違い
ルーティング、リンク、コンポーネント登録
このセクションでは、AppRun アプリケーションがルーティング、ページナビゲーション、コンポーネント登録をどのように処理するかについて説明します。
概要
このアプリは、外部ルーター ライブラリなしで AppRun のビルトインルーティングシステムを使用します。ルートは宣言的に定義され、ナビゲーションには標準の HTML アンカータグまたはプログラマティックメソッドを使用します。
1. コンポーネント登録
ルートは main.tsx の中で一元的に登録されます:
import app from 'apprun';
import Layout from './components/Layout';
import Home from './pages/Home';
import World from './pages/World';
app.render('#root', <Layout />);
app.addComponents('#pages', {
'/': Home,
'/World': World,
// '/Agent': Agent, // コメントアウト
// '/Settings': Settings, // コメントアウト
});
動作方法:
app.render('#root', <Layout />): トップレベルの Layout コンポーネントを#rootDOM 要素にレンダーしますapp.addComponents('#pages', {...}): ルートからコンポーネントへのマッピングを登録します- キー: ルートパス(例:
'/'、'/World') - 値: コンポーネントクラス(例:
Home、World) - コンポーネントは Layout で定義された
#pagesコンテナにレンダーされます
- キー: ルートパス(例:
2. レイアウトコンテナ
Layout コンポーネントは、ルート化されたページのレンダーコンテナを提供します:
// web/src/components/Layout.tsx
export default () => <div id="main" className="w-full min-h-screen">
<div id="pages"></div>
</div>
- 全幅、全高さのコンテナを持つ最小限のラッパー
#pagesdiv は、ルートコンポーネントが動的にレンダーされる場所です- AppRun は現在のルートに基づいてコンポーネントを自動的に切り替えます
3. ページリンク(宣言型ナビゲーション)
このアプリは、ナビゲーションに標準 HTML アンカータグを使用します:
ホームコンポーネントからの例:
// 特定のワールドに移動
<a href={'/World/' + worldName}>
<button className="btn btn-primary">
{worldName} に入る
</button>
</a>
ワールドコンポーネントからの例:
// ホームに戻る
<a href="/">
<button className="back-button" title="ワールド選択に戻る">
<span className="world-back-icon">←</span>
</button>
</a>
動作方法:
- 標準の
<a href="">リンクは AppRun のルーティングをトリガーします - AppRun はリンククリックをインターセプトし、フルページリロードなしでルートを更新します
- ルートパラメーター(ワールド名など)は URL パスに含まれます
- 特別な Link コンポーネントは必要ありません—プレーン HTML だけで十分です
4. プログラマティックナビゲーション
コンポーネントは window.location.href を使用してプログラマティックにナビゲートできます:
ホームコンポーネントの更新ハンドラーからの例:
update = {
'enter-world': (state: HomeState, world: World): void => {
// ワールドページに移動
window.location.href = '/World/' + world.name;
}
}
使用時機:
- イベントハンドラー内でロジック後にナビゲートする必要がある場合
- ナビゲーションがサイドエフェクトである場合(新しい状態の代わりに
voidを返す) - ユーザーアクションに基づく条件付きナビゲーション
5. ルートパラメーター
ルートはパスに動的パラメーターを含めることができます:
URL パターン:
/World/:worldName
パラメーターの解析:
コンポーネントは URL からルートパラメーターにアクセスできます:
// 例: /World/MyWorld
const worldName = window.location.pathname.split('/')[2]; // "MyWorld"
ルートハンドラーパターン:
update = {
'/World': async (state, worldName: string) => {
// worldName は URL から解析されます
return {
...state,
worldName,
// ... ワールドデータを読み込む
};
}
}
6. コンポーネントアーキテクチャ(MVU パターン)
ページコンポーネントは AppRun の Model-View-Update パターンに従います:
export default class PageComponent extends Component<StateType> {
// 1. 状態: 初期データと読み込み状態
state = {
loading: true,
data: null,
// ...
};
// 2. ビュー: 状態を JSX に変換するレンダー関数
view = (state: StateType) => {
return <div>
{/* JSX マークアップ */}
</div>;
};
// 3. 更新: イベントハンドラー
update = {
'event-name': (state, payload) => {
// 新しい状態を返して再レンダーをトリガー
return { ...state, newData: payload };
},
'navigation-event': (state) => {
// サイドエフェクト用に void を返す(再レンダーなし)
window.location.href = '/path';
}
};
}
主要原則:
- 状態: コンポーネントデータを持つプレーンオブジェクト
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- yysun
- リポジトリ
- yysun/apprun
- ライセンス
- MIT
- 最終更新
- 2026/4/3
Source: https://github.com/yysun/apprun / ライセンス: MIT