mcpField

strapi-expert

github.com/mkshahzad77開発1.1k 回インストール
リポジトリ

strapi-expert について

Strapi v5のプラグイン、API、CMS機能を開発する人向けのリファレンススキルです。Document Service APIの利用を重視し、サーバーと管理画面の構成、ルート、コントローラー、サービスの実装パターンを示します。

主な機能

  1. 01Strapi v5 Document Serviceの解説
  2. 02プラグイン構成と登録パターン
  3. 03管理画面とContent APIのルート例
  4. 04コントローラーとサービスの例
  5. 05よくある問題とトラブルシューティング

活用シーン

  1. 01Strapi v5プラグインの開発
  2. 02カスタムAPIルートとコントローラーの追加
  3. 03プラグイン管理画面コンポーネントの設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mkshahzad77/claude-skill-strapi-expert --skill strapi-expert

インストール後、Claude Code で/strapi-expertを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

CONTRIBUTING.md
.gitignore
README.md
examples.md
patterns.md
LICENSE

詳細

Strapi v5 エキスパート

プラグイン開発、カスタム API、CMS アーキテクチャを専門とする、Strapi v5 のエキスパート開発者です。公式の規約とベストプラクティスに従って、本番環境に対応した Strapi v5 コードを作成することが使命です。

基本方針:まず Document Service API を使用する

Strapi v5 では、すべてのデータ操作に必ず Document Service API(strapi.documents)を使用してください。v4 の Entity Service API は非推奨です。

Document Service と Entity Service の比較
操作Document Service(v5)Entity Service(非推奨)
複数件を検索strapi.documents('api::article.article').findMany()strapi.entityService.findMany()
1 件を検索strapi.documents(uid).findOne({ documentId })strapi.entityService.findOne()
作成strapi.documents(uid).create({ data })strapi.entityService.create()
更新strapi.documents(uid).update({ documentId, data })strapi.entityService.update()
削除strapi.documents(uid).delete({ documentId })strapi.entityService.delete()
公開strapi.documents(uid).publish({ documentId })該当なし
公開解除strapi.documents(uid).unpublish({ documentId })該当なし
Document Service の基本的な使い方
// In a service or controller
const articles = await strapi.documents('api::article.article').findMany({
  filters: { publishedAt: { $notNull: true } },
  populate: ['author', 'categories'],
  locale: 'en',
  status: 'published', // 'draft' | 'published'
});

// Create with draft/publish support
const newArticle = await strapi.documents('api::article.article').create({
  data: {
    title: 'My Article',
    content: 'Content here...',
  },
  status: 'draft', // Creates as draft
});

// Publish a draft
await strapi.documents('api::article.article').publish({
  documentId: newArticle.documentId,
});

プラグインの構成

Strapi v5 のプラグインは、次の構成に従います。

my-plugin/
├── package.json          # Must have strapi.kind: "plugin"
├── strapi-server.js      # Server entry point
├── strapi-admin.js       # Admin entry point
├── server/
│   └── src/
│       ├── index.ts          # Main server export
│       ├── register.ts       # Plugin registration
│       ├── bootstrap.ts      # Bootstrap logic
│       ├── destroy.ts        # Cleanup logic
│       ├── config/
│       │   └── index.ts      # Default config
│       ├── content-types/
│       │   └── my-type/
│       │       └── schema.json
│       ├── controllers/
│       │   └── index.ts
│       ├── routes/
│       │   └── index.ts
│       ├── services/
│       │   └── index.ts
│       ├── policies/
│       │   └── index.ts
│       └── middlewares/
│           └── index.ts
└── admin/
    └── src/
        ├── index.tsx         # Admin entry
        ├── pages/
        ├── components/
        └── translations/
Package.json の要件
{
  "name": "my-plugin",
  "version": "1.0.0",
  "strapi": {
    "kind": "plugin",
    "name": "my-plugin",
    "displayName": "My Plugin"
  }
}

ルート定義

Content API ルート(公開/認証済み)
// server/src/routes/index.ts
export default {
  'content-api': {
    type: 'content-api',
    routes: [
      {
        method: 'GET',
        path: '/items',
        handler: 'item.findMany',
        config: {
          policies: [],
          auth: false, // Public access
        },
      },
      {
        method: 'POST',
        path: '/items',
        handler: 'item.create',
        config: {
          policies: ['is-owner'],
        },
      },
    ],
  },
};
Admin API ルート(管理パネル専用)
export default {
  admin: {
    type: 'admin',
    routes: [
      {
        method: 'GET',
        path: '/settings',
        handler: 'settings.getSettings',
        config: {
          policies: ['admin::isAuthenticatedAdmin'],
        },
      },
    ],
  },
};

コントローラー

// server/src/controllers/item.ts
import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  async findMany(ctx) {
    const items = await strapi
      .documents('plugin::my-plugin.item')
      .findMany({
        filters: ctx.query.filters,
        populate: ctx.query.populate,
      });

    return { data: items };
  },

  async create(ctx) {
    const { data } = ctx.request.body;

    const item = await strapi
      .documents('plugin::my-plugin.item')
      .create({ data });

    return { data: item };
  },
});

export default controller;

サービス

// server/src/services/item.ts
import type { Core } from '@strapi/strapi';

const service = ({ strapi }: { strapi: Core.Strapi }) => ({
  async findPublished(locale = 'en') {
    return strapi.documents('plugin::my-plugin.item').findMany({
      status: 'published',
      locale,
    });
  },

  async publishItem(documentId: string) {
    return strapi.documents('plugin::my-plugin.item').publish({
      documentId,
    });
  },
});

export default service;

コンテンツタイプのスキーマ

{
  "kind": "collectionType",
  "collectionName": "items",
  "info": {
    "singularName": "item",
    "pluralName": "items",
    "displayName": "Item"
  },
  "options": {
    "draftAndPublish": true
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "slug": {
      "type": "uid",
      "targetField": "title"
    },
    "content": {
      "type": "richtext"
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "plugin::users-permissions.user"
    }
  }
}

コンテンツタイプ UID の形式

常に正しい UID 形式を使用してください。

種類形式例
API コンテンツタイプapi::singular.singularapi::article.article
プラグインのコンテンツタイプplugin::plugin-name.typeplugin::my-plugin.item
ユーザーplugin::users-permissions.user-

Admin Panel のコンポーネント

基本的な管理ページ
// admin/src/pages/HomePage.tsx
import { Main, Typography, Box } from '@strapi/design-system';
import { useIntl } from 'react-intl';

const HomePage = () => {
  const { formatMessage } = useIntl();

  return (
    <Main>
      <Box padding={8}>
        <Typography variant="alpha">
          {formatMessage({ id: 'my-plugin.title', defaultMessage: 'My Plugin' })}
        </Typography>
      </Box>
    </Main>
  );
};

export default HomePage;
プラグインの登録
// admin/src/index.tsx
import { getTranslation } from './utils/getTranslation';
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';

export default {
  register(app: any) {
    app.addMenuLink({
      to: `plugins/${PLUGIN_ID}`,
      icon: PluginIcon,
      intlLabel: {
        id: `${PLUGIN_ID}.plugin.name`,
        defaultMessage: 'My Plugin',
      },
      Component: async () => import('./pages/App'),
    });

    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(`./translations/${locale}.json`);
          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};

ポリシー

// server/src/policies/is-owner.ts
export default (policyContext, config, { strapi }) => {
  const { user } = policyContext.state;

  if (!user) {
    return false;
  }

  // Custom ownership logic
  return true;
};

避けるべき一般的なアンチパターン

アンチパターン正しい方法
Entity Service の使用Document Service API を使用する
CRUD に strapi.query() を使用するstrapi.documents() を使用する
UID のハードコード定数または設定を使用する
コントローラーにエラーハンドリングがないtry-catch で囲み、ctx.throw を使用する
データベースへの直接クエリフィルターを指定して Document Service を使用する
ポリシーを省略する必ず認可を実装する

トラブルシューティングガイド

問題解決方法
プラグインが読み込まれないpackage.json に strapi.kind: "plugin" があることを確認する
ルートが 404 を返すルートの種類(content-api と admin)とハンドラーパスを確認する
アクセスが拒否されるSettings > Roles で権限を設定する
Admin Panel が空白になるadmin/src/index.tsx のエクスポートと React のエラーを確認する
TypeScript エラーstrapi ts:generate-types を実行する
ビルドに失敗するプラグイン内で npm run build を実行し、インポートエラーがないか確認する

開発コマンド

# Create new plugin
npx @strapi/sdk-plugin@latest init my-plugin

# Build plugin
cd my-plugin && npm run build

# Watch mode for development
npm run watch

# Link plugin for local development
npm run watch:link

# Verify plugin structure
npx @strapi/sdk-plugin@latest verify

プラグインアーキテクチャのベストプラクティス

Strapi Community の plugin-todo のリファレンス実装に基づいています。

設計原則
  1. ファクトリーパターン:標準的な CRUD 操作には、Strapi の factories.createCoreService()、factories.createCoreController()、factories.createCoreRouter() を使用します。
  2. サービスレイヤーパターン:ビジネスロジックはサービスに実装し、コントローラーからサービスに処理を委譲します。
  3. Admin と Content API の分離:ルートを管理パネルと公開 API に分けます。
  4. Content Manager との統合:インジェクションゾーンを使用して、既存のコンテンツマネージャービューに UI を追加します。
  5. データ取得には React Query:管理パネルでのデータ取得と変更には @tanstack/react-query を使用します。
推奨されるプラグイン構成(plugin-todo パターン)
plugin-name/
├── package.json                 # Plugin metadata with exports
├── admin/
│   └── src/
│       ├── index.ts             # Admin registration & bootstrap
│       ├── pluginId.ts          # Plugin ID constant
│       ├── components/
│       │   ├── Initializer.tsx  # Plugin initialization
│       │   └── [Component].tsx  # UI components
│       ├── utils/               # Helper utilities
│       └── translations/
│           └── en.json
└── server/
    └── src/
        ├── index.ts             # Server exports aggregator
        ├── content-types/
        │   ├── index.ts
        │   └── [type-name]/
        │       ├── index.ts
        │       └── schema.json
        ├── controllers/
        │   ├── index.ts
        │   └── [name].ts
        ├── services/
        │   ├── index.ts
        │   └── [name].ts
        └── routes/
            ├── index.ts         # Route aggregator
            ├── admin/
            │   ├── index.ts     # Admin routes with custom endpoints
            │   └── [name].ts    # Core router for CRUD
            └── content-api/
                └── index.ts     # Public API routes
モダンな Exports を備えた Package.json
{
  "name": "@strapi-community/plugin-todo",
  "version": "1.0.0",
  "description": "Keep track of your content management with todo lists",
  "strapi": {
    "kind": "plugin",
    "name": "todo",
    "displayName": "Todo"
  },
  "exports": {
    "./strapi-admin": {
      "source": "./admin/src/index.ts",
      "import": "./dist/admin/index.mjs",
      "require": "./dist/admin/index.js"
    },
    "./strapi-server": {
      "source": "./server/src/index.ts",
      "import": "./dist/server/index.mjs",
      "require": "./dist/server/index.js"
    }
  },
  "dependencies": {
    "@tanstack/react-query": "^5.0.0"
  },
  "peerDependencies": {
    "@strapi/strapi": "^5.0.0",
    "@strapi/design-system": "^2.0.0",
    "react": "^17.0.0 || ^18.0.0"
  }
}
Server Index パターン
// server/src/index.ts
import controllers from './controllers';
import routes from './routes';
import services from './services';
import contentTypes from './content-types';

export default {
  controllers,
  routes,
  services,
  contentTypes,
};
ファクトリーベースのサービス
// server/src/services/task.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('plugin::todo.task', ({ strapi }) => ({
  // Custom method extending core service
  async findRelatedTasks(relatedId: string, relatedType: string) {
    // Query junction table for polymorphic relation
    const relatedTasks = await strapi.db
      .query('tasks_related_mph')
      .findMany({
        where: { related_id: relatedId, related_type: relatedType },
      });

    const taskIds = relatedTasks.map((t) => t.task_id);

    // Fetch full task documents
    return strapi.documents('plugin::todo.task').findMany({
      filters: { id: { $in: taskIds } },
    });
  },
}));
ファクトリーベースのコントローラー
// server/src/controllers/task.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('plugin::todo.task', ({ strapi }) => ({
  // Custom endpoint handler
  async findRelatedTasks(ctx) {
    const { relatedId, relatedType } = ctx.params;

    const tasks = await strapi
      .service('plugin::todo.task')
      .findRelatedTasks(relatedId, relatedType);

    ctx.body = tasks;
  },
}));
コアルーターを使用したルートの構成
// server/src/routes/index.ts
import contentAPIRoutes from './content-api';
import adminAPIRoutes from './admin';

const routes = {
  'content-api': contentAPIRoutes,
  admin: adminAPIRoutes,
};

export default routes;
// server/src/routes/admin/task.ts - Core CRUD routes
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('plugin::todo.task');
// server/src/routes/admin/index.ts - Custom + Core routes
import task from './task';

export default () => ({
  type: 'admin',
  routes: [
    // Spread core CRUD routes
    ...task.routes,
    // Add custom endpoints
    {
      method: 'GET',
      path: '/tasks/related/:relatedType/:relatedId',
      handler: 'task.findRelatedTasks',
    },
  ],
});
非表示のプラグインコンテンツタイプ(内部使用)
{
  "kind": "collectionType",
  "collectionName": "tasks",
  "info": {
    "singularName": "task",
    "pluralName": "tasks",
    "displayName": "Task"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {
    "content-manager": { "visible": false },
    "content-type-builder": { "visible": false }
  },
  "attributes": {
    "name": { "type": "text" },
    "done": { "type": "boolean" },
    "related": {
      "type": "relation",
      "relation": "morphToMany"
    }
  }
}
コンテンツマネージャーと連携する管理パネル
// admin/src/index.ts
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { TodoPanel } from './components/TodoPanel';

export default {
  register(app: any) {
    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
    });
  },

  bootstrap(app: any) {
    // Inject panel into Content Manager edit view
    app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
      name: 'todo-panel',
      Component: TodoPanel,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(`./translations/${locale}.json`);
          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};
管理コンポーネント向けの React Query パターン
// admin/src/components/TodoPanel.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
import { TextButton, Plus } from '@strapi/design-system';
import { TaskList } from './TaskList';
import { TodoModal } from './TodoModal';

const queryClient = new QueryClient();

export const TodoPanel = () => {
  const [modalOpen, setModalOpen] = useState(false);
  const { id } = useContentManagerContext();

  return (
    <QueryClientProvider client={queryClient}>
      <TextButton
        startIcon={<Plus />}
        onClick={() => setModalOpen(true)}
        disabled={!id}
      >
        Add todo
      </TextButton>

      {id && (
        <>
          <TodoModal open={modalOpen} setOpen={setModalOpen} />
          <TaskList />
        </>
      )}
    </QueryClientProvider>
  );
};
useFetchClient を使用したデータ取得
// admin/src/components/TaskList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Checkbox } from '@strapi/design-system';

export const TaskList = () => {
  const { get, put } = useFetchClient();
  const { slug, id } = unstable_useContentManagerContext();
  const queryClient = useQueryClient();

  const { data: tasks } = useQuery({
    queryKey: ['tasks', slug, id],
    queryFn: () => get(`/todo/tasks/related/${slug}/${id}`).then((res) => res.data),
  });

  const toggleMutation = useMutation({
    mutationFn: (task: any) =>
      put(`/todo/tasks/${task.documentId}`, { data: { done: !task.done } }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', slug, id] }),
  });

  return (
    <ul>
      {tasks?.map((task: any) => (
        <li key={task.id}>
          <Checkbox
            checked={task.done}
            onCheckedChange={() => toggleMutation.mutate(task)}
          >
            {task.name}
          </Checkbox>
        </li>
      ))}
    </ul>
  );
};
ベストプラクティスのチェックリスト

サーバー:

  • 標準的な CRUD には factories.createCoreService() を使用します
  • カスタムメソッドには factories.createCoreController() を使用します
  • CRUD ルートの自動生成には factories.createCoreRouter() を使用します
  • ルートを admin/ ディレクトリと content-api/ ディレクトリに分けます
  • 内部コンテンツタイプをコンテンツマネージャー UI から非表示にします

管理パネル:

  • React Query のコンテキストには QueryClientProvider を使用します
  • API 呼び出しには useFetchClient() を使用します
  • 現在のエンティティ情報には unstable_useContentManagerContext() を使用します
  • CM との連携には app.getPlugin('content-manager').injectComponent() を使用します
  • registerTrads() で翻訳に対応します

コンテンツタイプ:

  • ポリモーフィックなリレーションには morphToMany を使用します
  • 内部タイプには pluginOptions.content-manager.visible: false を設定します
  • 単数形の名前を使用します(tasks ではなく task)

詳細なパターンについては、patterns.md をご覧ください。 実際の例については、examples.md をご覧ください。

コメント