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.
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/admin/src/components/AdminOverlays.tsx +190 -0
- package/admin/src/components/FloatingChat.tsx +370 -0
- package/admin/src/components/PreviewPanel.tsx +188 -0
- package/admin/src/index.tsx +49 -0
- package/admin/src/pages/App.tsx +14 -0
- package/admin/src/pages/HomePage.tsx +333 -0
- package/admin/src/pages/ProvisionPage.tsx +391 -0
- package/admin/src/pluginId.ts +1 -0
- package/dist/server/index.js +3511 -0
- package/package.json +77 -0
- package/server/src/content-tools.ts +520 -0
- package/server/src/controllers/audio.ts +45 -0
- package/server/src/controllers/chat.ts +22 -0
- package/server/src/controllers/frontend.ts +310 -0
- package/server/src/index.ts +43 -0
- package/server/src/mcp/index.ts +24 -0
- package/server/src/mcp/tools/buscar-texto.ts +28 -0
- package/server/src/mcp/tools/criar-locale.ts +30 -0
- package/server/src/mcp/tools/editar-campo.ts +39 -0
- package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
- package/server/src/mcp/tools/index.ts +17 -0
- package/server/src/mcp/tools/listar-locales.ts +27 -0
- package/server/src/mcp/tools/publicar.ts +31 -0
- package/server/src/mcp/tools/traduzir.ts +36 -0
- package/server/src/mcp/types.ts +11 -0
- package/server/src/mcp-client.ts +96 -0
- package/server/src/provision/adapters.ts +91 -0
- package/server/src/provision/enable-i18n.ts +129 -0
- package/server/src/provision/generate.ts +216 -0
- package/server/src/provision/infer.ts +495 -0
- package/server/src/provision/integrate.ts +963 -0
- package/server/src/provision/link.ts +203 -0
- package/server/src/provision/manifest.ts +281 -0
- package/server/src/provision/orchestrate.ts +236 -0
- package/server/src/provision/permissions.ts +58 -0
- package/server/src/provision/runner.ts +176 -0
- package/server/src/provision/seed.ts +115 -0
- package/server/src/provision/translate.ts +153 -0
- package/server/src/provision/types-gen.ts +117 -0
- package/server/src/provision/write.ts +136 -0
- package/server/src/register.ts +17 -0
- package/server/src/routes/index.ts +66 -0
- package/server/src/services/audio.ts +53 -0
- 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
|
+
}
|