Agent Skills by ALSEL
汎用ソフトウェア開発⭐ リポ 1,175品質スコア 100/100

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 サポート
  • ビルトインルーティング

コンポーネントパターン - デシジョンツリー

  1. 状態を管理 + ユーザー操作を処理? → ステートフルクラスコンポーネント
  2. ポップアップ/モーダル/オーバーレイ? → モーダルコンポーネント(mounted() を使用)
  3. props から表示のみ? → 関数型コンポーネント
  4. 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>
  );
}

パターン: 分割代入 → ガード句 → メインレンダー

型付きイベントパターン

ペイロード規則:

  1. 単一値 → payload: string | 呼び出し: $onclick={['delete', id]}
  2. 複数値 → payload: { id: string; name: string } | 呼び出し: $onclick={['edit', { id, name }]}
  3. ペイロードなし → payload: void | 呼び出し: $onclick="save"
  4. 入力イベント → 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 操作のみに onclickoninput などを使用します:

<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 = asyncstate = 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 コンポーネントを #root DOM 要素にレンダーします
  • app.addComponents('#pages', {...}): ルートからコンポーネントへのマッピングを登録します
    • キー: ルートパス(例:'/''/World'
    • 値: コンポーネントクラス(例:HomeWorld
    • コンポーネントは 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>
  • 全幅、全高さのコンテナを持つ最小限のラッパー
  • #pages div は、ルートコンポーネントが動的にレンダーされる場所です
  • 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

本サイトは GitHub 上で公開されているオープンソースの SKILL.md ファイルをクロール・インデックス化したものです。 各スキルの著作権は原作者に帰属します。掲載に問題がある場合は info@alsel.co.jp または /takedown フォームよりご連絡ください。
原作者: yysun · yysun/apprun · ライセンス: MIT