strapi-plugin-mcp-chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -0
  3. package/admin/src/components/AdminOverlays.tsx +190 -0
  4. package/admin/src/components/FloatingChat.tsx +370 -0
  5. package/admin/src/components/PreviewPanel.tsx +188 -0
  6. package/admin/src/index.tsx +49 -0
  7. package/admin/src/pages/App.tsx +14 -0
  8. package/admin/src/pages/HomePage.tsx +333 -0
  9. package/admin/src/pages/ProvisionPage.tsx +391 -0
  10. package/admin/src/pluginId.ts +1 -0
  11. package/dist/server/index.js +3511 -0
  12. package/package.json +77 -0
  13. package/server/src/content-tools.ts +520 -0
  14. package/server/src/controllers/audio.ts +45 -0
  15. package/server/src/controllers/chat.ts +22 -0
  16. package/server/src/controllers/frontend.ts +310 -0
  17. package/server/src/index.ts +43 -0
  18. package/server/src/mcp/index.ts +24 -0
  19. package/server/src/mcp/tools/buscar-texto.ts +28 -0
  20. package/server/src/mcp/tools/criar-locale.ts +30 -0
  21. package/server/src/mcp/tools/editar-campo.ts +39 -0
  22. package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
  23. package/server/src/mcp/tools/index.ts +17 -0
  24. package/server/src/mcp/tools/listar-locales.ts +27 -0
  25. package/server/src/mcp/tools/publicar.ts +31 -0
  26. package/server/src/mcp/tools/traduzir.ts +36 -0
  27. package/server/src/mcp/types.ts +11 -0
  28. package/server/src/mcp-client.ts +96 -0
  29. package/server/src/provision/adapters.ts +91 -0
  30. package/server/src/provision/enable-i18n.ts +129 -0
  31. package/server/src/provision/generate.ts +216 -0
  32. package/server/src/provision/infer.ts +495 -0
  33. package/server/src/provision/integrate.ts +963 -0
  34. package/server/src/provision/link.ts +203 -0
  35. package/server/src/provision/manifest.ts +281 -0
  36. package/server/src/provision/orchestrate.ts +236 -0
  37. package/server/src/provision/permissions.ts +58 -0
  38. package/server/src/provision/runner.ts +176 -0
  39. package/server/src/provision/seed.ts +115 -0
  40. package/server/src/provision/translate.ts +153 -0
  41. package/server/src/provision/types-gen.ts +117 -0
  42. package/server/src/provision/write.ts +136 -0
  43. package/server/src/register.ts +17 -0
  44. package/server/src/routes/index.ts +66 -0
  45. package/server/src/services/audio.ts +53 -0
  46. package/server/src/services/chat.ts +263 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Cliente MCP minimalista (HTTP streamable). Por padrão fala com o MCP server
3
+ * NATIVO da Strapi (endpoint /mcp, requer admin token), mas aceita qualquer URL
4
+ * e token no construtor — usado também para o Playwright MCP (controle de browser).
5
+ *
6
+ * O endpoint responde em formato SSE (linhas "data: {json}"), então fazemos
7
+ * o parse manual e mantemos o mcp-session-id entre as chamadas.
8
+ */
9
+
10
+ const MCP_URL = process.env.MCP_URL || 'http://localhost:1337/mcp';
11
+
12
+ const baseHeaders = {
13
+ 'Content-Type': 'application/json',
14
+ Accept: 'application/json, text/event-stream',
15
+ };
16
+
17
+ const parseSse = (text: string): any => {
18
+ const dataLines = text
19
+ .split('\n')
20
+ .filter((l) => l.startsWith('data:'))
21
+ .map((l) => l.slice(5).trim())
22
+ .filter(Boolean);
23
+ const last = dataLines[dataLines.length - 1];
24
+ return last ? JSON.parse(last) : null;
25
+ };
26
+
27
+ export class McpClient {
28
+ private sessionId?: string;
29
+ private url: string;
30
+ private token?: string;
31
+ /** Nome amigável (aparece nos logs). */
32
+ readonly name: string;
33
+
34
+ /**
35
+ * @param url endpoint MCP streamable. Default: o /mcp nativo da Strapi.
36
+ * @param name rótulo p/ logs (ex.: 'strapi', 'playwright').
37
+ * @param token Bearer token (admin token, exigido pelo /mcp nativo).
38
+ */
39
+ constructor(url: string = MCP_URL, name = 'strapi', token?: string) {
40
+ this.url = url;
41
+ this.name = name;
42
+ this.token = token;
43
+ }
44
+
45
+ private headers() {
46
+ const h: Record<string, string> = { ...baseHeaders };
47
+ if (this.token) h['Authorization'] = `Bearer ${this.token}`;
48
+ if (this.sessionId) h['mcp-session-id'] = this.sessionId;
49
+ return h;
50
+ }
51
+
52
+ async init(): Promise<void> {
53
+ const res = await fetch(this.url, {
54
+ method: 'POST',
55
+ headers: this.headers(),
56
+ body: JSON.stringify({
57
+ jsonrpc: '2.0',
58
+ id: 1,
59
+ method: 'initialize',
60
+ params: {
61
+ protocolVersion: '2024-11-05',
62
+ capabilities: {},
63
+ clientInfo: { name: 'mcp-chat-plugin', version: '0.1.0' },
64
+ },
65
+ }),
66
+ });
67
+ this.sessionId = res.headers.get('mcp-session-id') || undefined;
68
+ await res.text();
69
+ // Notifica que o handshake terminou (sem corpo de resposta relevante).
70
+ await fetch(this.url, {
71
+ method: 'POST',
72
+ headers: this.headers(),
73
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
74
+ });
75
+ }
76
+
77
+ private async rpc(method: string, params: any, id: number): Promise<any> {
78
+ const res = await fetch(this.url, {
79
+ method: 'POST',
80
+ headers: this.headers(),
81
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
82
+ });
83
+ const json = parseSse(await res.text());
84
+ if (json?.error) throw new Error(json.error.message || 'Erro MCP');
85
+ return json?.result;
86
+ }
87
+
88
+ async listTools(): Promise<any[]> {
89
+ const result = await this.rpc('tools/list', {}, 2);
90
+ return result?.tools || [];
91
+ }
92
+
93
+ async callTool(name: string, args: Record<string, any>): Promise<any> {
94
+ return this.rpc('tools/call', { name, arguments: args || {} }, 3);
95
+ }
96
+ }
@@ -0,0 +1,91 @@
1
+ import type { Framework, Manifest } from './manifest';
2
+
3
+ /**
4
+ * Adapters de framework.
5
+ *
6
+ * O contrato e o pipeline são idênticos para Next e TanStack; só mudam os
7
+ * detalhes de cada ecossistema (nome do arquivo .env, prefixo das vars que vão
8
+ * pro client, porta padrão). Isolar isso aqui é o que permite suportar os dois
9
+ * sem ifs espalhados pelo código.
10
+ *
11
+ * Regra de segurança das env: a URL do Strapi é pública (prefixo do framework),
12
+ * mas TOKEN e SECRET nunca recebem prefixo público — ficam só no servidor.
13
+ */
14
+
15
+ export interface LinkContext {
16
+ /** URL base do Strapi, ex.: http://localhost:1337 */
17
+ strapiUrl: string;
18
+ /** URL base do frontend, ex.: http://localhost:3000 (default = porta do adapter). */
19
+ frontendUrl?: string;
20
+ /** API token para o client (opcional; pode ser preenchido depois). */
21
+ apiToken?: string;
22
+ /** segredo do preview (draft mode). */
23
+ previewSecret?: string;
24
+ /** locales i18n disponíveis (p/ o seletor de idioma do frontend). */
25
+ locales?: string[];
26
+ }
27
+
28
+ export interface FrameworkAdapter {
29
+ framework: Framework;
30
+ /** arquivo de env que o framework lê. */
31
+ envFileName: string;
32
+ /** porta padrão de dev do framework. */
33
+ defaultPort: number;
34
+ /** monta as variáveis de ambiente do frontend. */
35
+ buildEnv(ctx: LinkContext): Record<string, string>;
36
+ /** dica de onde montar o PreviewBridge (usada na doc/log). */
37
+ previewBridgeHint: string;
38
+ }
39
+
40
+ const nextAdapter: FrameworkAdapter = {
41
+ framework: 'next',
42
+ envFileName: '.env.local',
43
+ defaultPort: 3000,
44
+ buildEnv: ({ strapiUrl, apiToken, previewSecret, locales }) => {
45
+ const env: Record<string, string> = {
46
+ // pública: usada por Server e Client Components
47
+ NEXT_PUBLIC_STRAPI_URL: strapiUrl,
48
+ };
49
+ // pública: lista de idiomas p/ o seletor (CSV)
50
+ if (locales && locales.length) env.NEXT_PUBLIC_LOCALES = locales.join(',');
51
+ // server-only (sem NEXT_PUBLIC_): nunca vai pro bundle do client
52
+ if (apiToken) env.STRAPI_API_TOKEN = apiToken;
53
+ if (previewSecret) env.PREVIEW_SECRET = previewSecret;
54
+ return env;
55
+ },
56
+ previewBridgeHint:
57
+ 'app/_components/PreviewBridge.tsx montado no layout raiz (postMessage para o admin)',
58
+ };
59
+
60
+ const tanstackAdapter: FrameworkAdapter = {
61
+ framework: 'tanstack',
62
+ envFileName: '.env',
63
+ defaultPort: 5173,
64
+ buildEnv: ({ strapiUrl, apiToken, previewSecret, locales }) => {
65
+ const env: Record<string, string> = {
66
+ // pública no Vite/TanStack: exposta via import.meta.env
67
+ VITE_STRAPI_URL: strapiUrl,
68
+ };
69
+ // pública: lista de idiomas p/ o seletor (CSV)
70
+ if (locales && locales.length) env.VITE_LOCALES = locales.join(',');
71
+ // server-only (sem VITE_): só acessível em loaders/server functions
72
+ if (apiToken) env.STRAPI_API_TOKEN = apiToken;
73
+ if (previewSecret) env.PREVIEW_SECRET = previewSecret;
74
+ return env;
75
+ },
76
+ previewBridgeHint:
77
+ 'src/components/PreviewBridge.tsx montado no __root (postMessage para o admin)',
78
+ };
79
+
80
+ const ADAPTERS: Record<Framework, FrameworkAdapter> = {
81
+ next: nextAdapter,
82
+ tanstack: tanstackAdapter,
83
+ };
84
+
85
+ export function getAdapter(framework: Framework): FrameworkAdapter {
86
+ return ADAPTERS[framework];
87
+ }
88
+
89
+ export function adapterForManifest(manifest: Manifest): FrameworkAdapter {
90
+ return getAdapter(manifest.framework);
91
+ }
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Habilita i18n em content-types JÁ existentes, editando o(s) schema.json:
6
+ * adiciona `pluginOptions.i18n.localized:true` no nível da CT e nos campos
7
+ * traduzíveis. Necessário para traduzir conteúdo provisionado sem i18n.
8
+ *
9
+ * `uid` omitido (ou "*") → habilita em TODAS as content-types de src/api de uma
10
+ * vez (um único restart). Senão, só na CT indicada.
11
+ *
12
+ * Travas (mesma filosofia do writer):
13
+ * - DEV-ONLY: só edita em NODE_ENV=development.
14
+ * - ADITIVO: apenas ACRESCENTA pluginOptions; nunca remove campos/altera tipos.
15
+ * - Não chama strapi.reload(): em dev, gravar o schema dispara o watcher.
16
+ */
17
+
18
+ // Campos cujo conteúdo deve passar a variar por locale. Componentes e dynamic
19
+ // zones entram inteiros (o conteúdo textual aninhado segue o atributo de topo).
20
+ const LOCALIZABLE = ['string', 'text', 'richtext', 'component', 'dynamiczone'];
21
+ const isDev = () => process.env.NODE_ENV === 'development';
22
+
23
+ function schemaPathFor(apiRoot: string, uid: string): string | null {
24
+ const m = /^api::([^.]+)\.([^.]+)$/.exec(uid);
25
+ if (!m) return null;
26
+ const [, api, ct] = m;
27
+ return path.join(apiRoot, api, 'content-types', ct, 'schema.json');
28
+ }
29
+
30
+ /** Lista os uids de todas as content-types em src/api (api::<api>.<ct>). */
31
+ function listAllUids(apiRoot: string): string[] {
32
+ const out: string[] = [];
33
+ let apis: string[] = [];
34
+ try {
35
+ apis = fs.readdirSync(apiRoot);
36
+ } catch {
37
+ return out;
38
+ }
39
+ for (const api of apis) {
40
+ const ctDir = path.join(apiRoot, api, 'content-types');
41
+ if (!fs.existsSync(ctDir)) continue;
42
+ for (const ct of fs.readdirSync(ctDir)) {
43
+ if (fs.existsSync(path.join(ctDir, ct, 'schema.json'))) out.push(`api::${api}.${ct}`);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ const withLocalized = (obj: any) => ({
50
+ ...(obj || {}),
51
+ i18n: { ...((obj || {}).i18n || {}), localized: true },
52
+ });
53
+
54
+ /** Aplica localized:true (CT + campos) em UM schema.json. */
55
+ function patchOne(file: string, campos?: string[]): { campos: string[] } | { erro: string } {
56
+ if (!fs.existsSync(file)) return { erro: `schema.json não encontrado (${file})` };
57
+ let schema: any;
58
+ try {
59
+ schema = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ } catch (e: any) {
61
+ return { erro: `schema.json ilegível: ${e?.message ?? e}` };
62
+ }
63
+ schema.pluginOptions = withLocalized(schema.pluginOptions);
64
+ const attrs = schema.attributes || {};
65
+ const alvos =
66
+ campos && campos.length
67
+ ? campos
68
+ : Object.keys(attrs).filter((k) => LOCALIZABLE.includes(attrs[k]?.type));
69
+ const changed: string[] = [];
70
+ for (const name of alvos) {
71
+ if (!attrs[name]) continue;
72
+ attrs[name].pluginOptions = withLocalized(attrs[name].pluginOptions);
73
+ changed.push(name);
74
+ }
75
+ try {
76
+ fs.writeFileSync(file, JSON.stringify(schema, null, 2) + '\n', 'utf8');
77
+ } catch (e: any) {
78
+ return { erro: `falha ao gravar schema.json: ${e?.message ?? e}` };
79
+ }
80
+ return { campos: changed };
81
+ }
82
+
83
+ export interface EnableI18nResult {
84
+ ok?: boolean;
85
+ uid?: string;
86
+ campos?: string[];
87
+ /** quando uid é omitido/"*": resumo por content-type. */
88
+ contentTypes?: { uid: string; campos: string[] }[];
89
+ total?: number;
90
+ restart?: boolean;
91
+ erro?: string;
92
+ }
93
+
94
+ export function enableI18n(opts: {
95
+ strapi: any;
96
+ uid?: string;
97
+ campos?: string[];
98
+ allowOutsideDev?: boolean;
99
+ }): EnableI18nResult {
100
+ const { strapi, uid, campos, allowOutsideDev } = opts;
101
+ if (!allowOutsideDev && !isDev()) {
102
+ return { erro: 'habilitar i18n só é permitido em desenvolvimento (NODE_ENV=development).' };
103
+ }
104
+ const srcDir = strapi?.dirs?.app?.src || path.join(process.cwd(), 'src');
105
+ const apiRoot = path.join(srcDir, 'api');
106
+
107
+ // TODAS as content-types (uid omitido ou "*") — um único restart.
108
+ if (!uid || uid === '*') {
109
+ const uids = listAllUids(apiRoot);
110
+ if (!uids.length) return { erro: `nenhuma content-type encontrada em ${apiRoot}` };
111
+ const done: { uid: string; campos: string[] }[] = [];
112
+ const errors: string[] = [];
113
+ for (const u of uids) {
114
+ const file = schemaPathFor(apiRoot, u)!;
115
+ const r = patchOne(file, campos);
116
+ if ('erro' in r) errors.push(`${u}: ${r.erro}`);
117
+ else done.push({ uid: u, campos: r.campos });
118
+ }
119
+ if (!done.length) return { erro: `nada habilitado. ${errors.join('; ')}` };
120
+ return { ok: true, contentTypes: done, total: done.length, restart: true };
121
+ }
122
+
123
+ // Uma content-type específica.
124
+ const file = schemaPathFor(apiRoot, uid);
125
+ if (!file) return { erro: `uid inválido: "${uid}" (esperado api::x.x)` };
126
+ const r = patchOne(file, campos);
127
+ if ('erro' in r) return { erro: r.erro };
128
+ return { ok: true, uid, campos: r.campos, restart: true };
129
+ }
@@ -0,0 +1,216 @@
1
+ import type { Manifest, ManifestContentType, ManifestAttribute } from './manifest';
2
+
3
+ /**
4
+ * Gerador: manifest -> arquivos que a Strapi 5 espera em src/api/<api>/.
5
+ *
6
+ * Funções PURAS, sem efeito colateral: recebem o manifest já validado e
7
+ * devolvem um mapa { caminho relativo -> conteúdo }. Quem escreve no disco é o
8
+ * writer (separado), o que torna o gerador 100% testável e o dry-run trivial.
9
+ *
10
+ * O formato espelha exatamente o que o Content-Type Builder da Strapi gera
11
+ * (conferido contra schema.json reais), para nunca produzir um schema que a
12
+ * Strapi recuse.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // helpers de nome
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Pluralização simples (en/pt). O manifest pode sobrescrever via pluralName. */
20
+ export function toPlural(singular: string): string {
21
+ if (/[^aeiou]y$/i.test(singular)) return singular.replace(/y$/i, 'ies');
22
+ if (/(s|x|z|ch|sh)$/i.test(singular)) return `${singular}es`;
23
+ return `${singular}s`;
24
+ }
25
+
26
+ /** kebab/hífen -> snake_case (collectionName é snake plural). */
27
+ function toSnake(s: string): string {
28
+ return s.replace(/-/g, '_');
29
+ }
30
+
31
+ /** "post-blog" -> "Post Blog" */
32
+ function toTitle(s: string): string {
33
+ return s
34
+ .split('-')
35
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
36
+ .join(' ');
37
+ }
38
+
39
+ /** UID Strapi de uma content-type pelo singularName: api::produto.produto */
40
+ export function apiUid(singularName: string): string {
41
+ return `api::${singularName}.${singularName}`;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // atributos
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * pluginOptions.i18n.localized:true para um campo, quando o manifest pediu.
50
+ * Formato idêntico ao do Content-Type Builder. Relações e uid são localizados
51
+ * automaticamente pelo i18n, então não recebem este bloco.
52
+ */
53
+ function i18nField(attr: ManifestAttribute): Record<string, any> {
54
+ return (attr as any).localized
55
+ ? { pluginOptions: { i18n: { localized: true } } }
56
+ : {};
57
+ }
58
+
59
+ function buildAttribute(attr: ManifestAttribute): Record<string, any> {
60
+ switch (attr.type) {
61
+ case 'uid':
62
+ return clean({
63
+ type: 'uid',
64
+ targetField: attr.targetField,
65
+ required: attr.required,
66
+ });
67
+
68
+ case 'enumeration':
69
+ return clean({
70
+ type: 'enumeration',
71
+ ...i18nField(attr),
72
+ enum: attr.enum,
73
+ required: attr.required,
74
+ unique: attr.unique,
75
+ private: attr.private,
76
+ default: attr.default,
77
+ });
78
+
79
+ case 'media':
80
+ return clean({
81
+ type: 'media',
82
+ multiple: attr.multiple ?? false,
83
+ required: attr.required,
84
+ allowedTypes: attr.allowedTypes,
85
+ });
86
+
87
+ case 'relation':
88
+ // Unidirecional de propósito: sem mappedBy/inversedBy. Relações bidirecionais
89
+ // exigem o campo-par no outro lado e são a causa nº1 de schema quebrado.
90
+ return {
91
+ type: 'relation',
92
+ relation: attr.relation,
93
+ target: apiUid(attr.target),
94
+ };
95
+
96
+ default:
97
+ // escalares (string, text, integer, boolean, date, json, ...)
98
+ return clean({
99
+ type: attr.type,
100
+ ...i18nField(attr),
101
+ required: (attr as any).required,
102
+ unique: (attr as any).unique,
103
+ private: (attr as any).private,
104
+ default: (attr as any).default,
105
+ });
106
+ }
107
+ }
108
+
109
+ /** remove chaves undefined para o JSON ficar limpo como o da Strapi. */
110
+ function clean<T extends Record<string, any>>(obj: T): T {
111
+ for (const k of Object.keys(obj)) if (obj[k] === undefined) delete obj[k];
112
+ return obj;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // schema.json
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export function buildSchema(ct: ManifestContentType): Record<string, any> {
120
+ const pluralName = ct.pluralName ?? toPlural(ct.singularName);
121
+ const attributes: Record<string, any> = {};
122
+ for (const [key, attr] of Object.entries(ct.attributes)) {
123
+ attributes[key] = buildAttribute(attr);
124
+ }
125
+ return clean({
126
+ kind: ct.kind,
127
+ collectionName: toSnake(pluralName),
128
+ info: {
129
+ singularName: ct.singularName,
130
+ pluralName,
131
+ displayName: ct.displayName ?? toTitle(ct.singularName),
132
+ description: ct.description ?? '',
133
+ },
134
+ options: {
135
+ draftAndPublish: ct.draftAndPublish,
136
+ },
137
+ // Nível CT é obrigatório p/ o i18n reconhecer a content-type como localizada
138
+ // (@strapi/i18n: isLocalizedContentType lê pluginOptions.i18n.localized).
139
+ pluginOptions: (ct as any).localized
140
+ ? { i18n: { localized: true } }
141
+ : undefined,
142
+ attributes,
143
+ });
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // arquivos de fábrica (controller / route / service)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ // O UID é castado para `any` de propósito: no momento em que estes arquivos são
151
+ // gerados e a Strapi reinicia, os tipos gerados (types/generated) ainda podem não
152
+ // conter o novo UID, e o `strapi develop` falharia a compilação TS. O cast desacopla
153
+ // o boot do timing do typegen — "nunca quebra". Em runtime o factory recebe a string
154
+ // normalmente; após o typegen incluir o type, o cast fica inócuo.
155
+ function controllerFile(singular: string): string {
156
+ return `/**
157
+ * ${singular} controller
158
+ */
159
+ import { factories } from '@strapi/strapi';
160
+
161
+ export default factories.createCoreController('${apiUid(singular)}' as any);
162
+ `;
163
+ }
164
+
165
+ function routeFile(singular: string): string {
166
+ return `/**
167
+ * ${singular} router
168
+ */
169
+ import { factories } from '@strapi/strapi';
170
+
171
+ export default factories.createCoreRouter('${apiUid(singular)}' as any);
172
+ `;
173
+ }
174
+
175
+ function serviceFile(singular: string): string {
176
+ return `/**
177
+ * ${singular} service
178
+ */
179
+ import { factories } from '@strapi/strapi';
180
+
181
+ export default factories.createCoreService('${apiUid(singular)}' as any);
182
+ `;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // saída
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export interface GeneratedApi {
190
+ singularName: string;
191
+ uid: string;
192
+ /** caminhos relativos a src/api -> conteúdo do arquivo */
193
+ files: Record<string, string>;
194
+ }
195
+
196
+ /** Gera os 4 arquivos de uma content-type, com caminhos relativos a src/api. */
197
+ export function generateApi(ct: ManifestContentType): GeneratedApi {
198
+ const s = ct.singularName;
199
+ const base = s; // nome da pasta da api = singularName
200
+ return {
201
+ singularName: s,
202
+ uid: apiUid(s),
203
+ files: {
204
+ [`${base}/content-types/${s}/schema.json`]:
205
+ JSON.stringify(buildSchema(ct), null, 2) + '\n',
206
+ [`${base}/controllers/${s}.ts`]: controllerFile(s),
207
+ [`${base}/routes/${s}.ts`]: routeFile(s),
208
+ [`${base}/services/${s}.ts`]: serviceFile(s),
209
+ },
210
+ };
211
+ }
212
+
213
+ /** Gera todas as content-types do manifest. */
214
+ export function generateAll(manifest: Manifest): GeneratedApi[] {
215
+ return manifest.contentTypes.map(generateApi);
216
+ }