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,963 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import type { Manifest } from './manifest';
5
+ import { apiUid } from './generate';
6
+
7
+ /**
8
+ * Integração por "snapshot do módulo de dados".
9
+ *
10
+ * Em vez de reescrever os componentes (frágil), regeneramos o(s) arquivo(s) de
11
+ * dados do frontend (ex.: src/data/site.ts) trocando os VALORES pelos do Strapi —
12
+ * mantendo imports, tipos e nomes de export idênticos, e PRESERVANDO os campos de
13
+ * imagem originais (casados por slug/title). Assim nada nos componentes muda e
14
+ * tudo continua funcionando. É um snapshot: re-rode para sincronizar de novo.
15
+ *
16
+ * A regeneração de cada arquivo é feita pela IA, mas a tarefa é bem restrita
17
+ * (mesmo arquivo, mesma estrutura, só troca os dados) — bem mais confiável que
18
+ * reescrever código arbitrário. Original é salvo como .bak antes de gravar.
19
+ */
20
+
21
+ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
22
+ const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
23
+
24
+ const SKIP_DIRS = new Set([
25
+ 'node_modules', '.git', 'dist', '.next', '.output', '.vinxi', '.tanstack',
26
+ 'build', 'coverage', '.turbo', '.cache', 'public',
27
+ ]);
28
+ const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']);
29
+ const MAX_DATA_FILES = 3;
30
+ const MAX_FILE_CHARS = 16000;
31
+
32
+ export interface IntegrateResult {
33
+ ok: boolean;
34
+ filesRewritten: string[];
35
+ contentTypesFetched: { uid: string; count: number }[];
36
+ warnings: string[];
37
+ errors: string[];
38
+ }
39
+
40
+ function score(rel: string): number {
41
+ const p = rel.toLowerCase();
42
+ let s = 0;
43
+ if (/(^|\/)(data|content|mocks?|seeds?|fixtures?)(\/|\.)/.test(p)) s += 10;
44
+ if (/(site|config|constants|catalog|products?|services?|posts?|items?)/.test(p)) s += 4;
45
+ if (p.startsWith('src/')) s += 2;
46
+ if (p.endsWith('.tsx') || p.endsWith('.jsx')) s -= 3; // queremos arquivos de DADOS, não componentes
47
+ return s;
48
+ }
49
+
50
+ function walk(dir: string, base: string, out: string[]) {
51
+ let entries: fs.Dirent[];
52
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
53
+ for (const e of entries) {
54
+ if (e.name.startsWith('.')) continue;
55
+ const full = path.join(dir, e.name);
56
+ if (e.isDirectory()) {
57
+ if (SKIP_DIRS.has(e.name)) continue;
58
+ walk(full, base, out);
59
+ } else if (CODE_EXT.has(path.extname(e.name))) {
60
+ out.push(path.relative(base, full));
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Acha os arquivos de DADOS (exportam arrays/objetos de conteúdo). */
66
+ function findDataFiles(frontendDir: string): string[] {
67
+ const all: string[] = [];
68
+ walk(frontendDir, frontendDir, all);
69
+ return all
70
+ .map((rel) => ({ rel, s: score(rel) }))
71
+ .filter((x) => x.s > 0)
72
+ .filter(({ rel }) => {
73
+ try {
74
+ const c = fs.readFileSync(path.join(frontendDir, rel), 'utf8');
75
+ // precisa exportar dados (array/objeto), não só componentes/funções
76
+ return /export\s+const\s+\w+\s*[:=]\s*(\[|\{)/.test(c);
77
+ } catch {
78
+ return false;
79
+ }
80
+ })
81
+ .sort((a, b) => b.s - a.s)
82
+ .slice(0, MAX_DATA_FILES)
83
+ .map((x) => x.rel);
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // busca os dados do Strapi
88
+ // ---------------------------------------------------------------------------
89
+
90
+ async function fetchStrapiData(
91
+ strapi: any,
92
+ manifest: Manifest,
93
+ locale?: string
94
+ ): Promise<{ data: Record<string, any>; counts: { uid: string; count: number }[] }> {
95
+ const data: Record<string, any> = {};
96
+ const counts: { uid: string; count: number }[] = [];
97
+ const loc = locale ? { locale } : {};
98
+ for (const ct of manifest.contentTypes) {
99
+ const uid = apiUid(ct.singularName);
100
+ try {
101
+ if (ct.kind === 'singleType') {
102
+ const doc = await strapi.documents(uid).findFirst({ status: 'published', populate: '*', ...loc });
103
+ data[ct.singularName] = doc ?? null;
104
+ counts.push({ uid, count: doc ? 1 : 0 });
105
+ } else {
106
+ const docs = await strapi.documents(uid).findMany({ status: 'published', populate: '*', ...loc });
107
+ data[ct.singularName] = docs ?? [];
108
+ counts.push({ uid, count: Array.isArray(docs) ? docs.length : 0 });
109
+ }
110
+ } catch {
111
+ data[ct.singularName] = ct.kind === 'singleType' ? null : [];
112
+ counts.push({ uid, count: 0 });
113
+ }
114
+ }
115
+ return { data, counts };
116
+ }
117
+
118
+ /** Locales configurados no Strapi: { codes, def }. Vazio se i18n off. */
119
+ async function getLocales(strapi: any): Promise<{ codes: string[]; def: string }> {
120
+ try {
121
+ const svc = strapi.plugin('i18n').service('locales');
122
+ const all = (await svc.find()) || [];
123
+ const def = (await svc.getDefaultLocale()) || 'en';
124
+ const codes = all.map((l: any) => l.code);
125
+ return { codes, def };
126
+ } catch {
127
+ return { codes: [], def: 'en' };
128
+ }
129
+ }
130
+
131
+ /** Extrai o valor (expressão) de cada `export const NAME = <valor>;` do arquivo,
132
+ * respeitando chaves/colchetes/parênteses e strings (inclui template literals). */
133
+ export function findExportValues(src: string, names: string[]): Record<string, string> {
134
+ const out: Record<string, string> = {};
135
+ for (const name of names) {
136
+ const re = new RegExp(`export\\s+const\\s+${name}\\b[^=]*=\\s*`);
137
+ const m = re.exec(src);
138
+ if (!m) continue;
139
+ let i = m.index + m[0].length;
140
+ const start = i;
141
+ let depth = 0;
142
+ let inStr: string | null = null;
143
+ let esc = false;
144
+ for (; i < src.length; i++) {
145
+ const c = src[i];
146
+ if (inStr) {
147
+ if (esc) esc = false;
148
+ else if (c === '\\') esc = true;
149
+ else if (c === inStr) inStr = null;
150
+ continue;
151
+ }
152
+ if (c === '"' || c === "'" || c === '`') inStr = c;
153
+ else if (c === '{' || c === '[' || c === '(') depth++;
154
+ else if (c === '}' || c === ']' || c === ')') depth--;
155
+ else if (c === ';' && depth === 0) break;
156
+ }
157
+ out[name] = src.slice(start, i).trim();
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /** Linhas de import do topo do arquivo (assets etc., iguais em todos os locales). */
163
+ export function extractImports(src: string): string {
164
+ return src
165
+ .split('\n')
166
+ .filter((l) => /^\s*import\s.+from\s+['"].+['"];?\s*$/.test(l))
167
+ .join('\n');
168
+ }
169
+
170
+ /** Nomes dos exports `export const X` do arquivo. */
171
+ export function exportNames(src: string): string[] {
172
+ const names: string[] = [];
173
+ const re = /export\s+const\s+(\w+)\b/g;
174
+ let m: RegExpExecArray | null;
175
+ while ((m = re.exec(src))) names.push(m[1]);
176
+ return names;
177
+ }
178
+
179
+ /**
180
+ * Monta o módulo de dados MULTI-LOCALE a partir dos arquivos regenerados por
181
+ * locale. Os exports viram "live" (Proxy) e seguem o locale ativo, definido
182
+ * isomórficamente por `__setLocale` (chamado no beforeLoad do root). Assim os
183
+ * componentes continuam fazendo `import { site } from "@/data/site"` sem mudança.
184
+ */
185
+ export function buildMultiLocaleModule(
186
+ perLocale: Record<string, string>, // locale -> conteúdo do arquivo regenerado
187
+ defLocale: string
188
+ ): string {
189
+ const def = perLocale[defLocale];
190
+ const names = exportNames(def);
191
+ const imports = extractImports(def);
192
+ const blocks: string[] = [];
193
+ for (const [loc, content] of Object.entries(perLocale)) {
194
+ const vals = findExportValues(content, names);
195
+ const entries = names.map((n) => ` ${JSON.stringify(n)}: ${vals[n] ?? 'undefined'}`).join(',\n');
196
+ blocks.push(` ${JSON.stringify(loc)}: {\n${entries}\n }`);
197
+ }
198
+ const locales = Object.keys(perLocale);
199
+ const liveExports = names.map((n) => `export const ${n}: any = __live(${JSON.stringify(n)});`).join('\n');
200
+ return `// Snapshot from Strapi (multi-locale — gerado pelo mcp-chat)
201
+ ${imports}
202
+
203
+ const __data: Record<string, any> = {
204
+ ${blocks.join(',\n')}
205
+ };
206
+
207
+ export const __availableLocales = ${JSON.stringify(locales)};
208
+ export const __defaultLocale = ${JSON.stringify(defLocale)};
209
+ let __locale = __defaultLocale;
210
+ export function __setLocale(l?: string | null) { if (l && __data[l]) __locale = l; }
211
+ export function __getLocale() { return __locale; }
212
+
213
+ // Auto-inicialização no CLIENTE: define o locale a partir de ?locale/cookie no
214
+ // momento em que o módulo carrega — ANTES de qualquer render. Garante que a
215
+ // hidratação use o mesmo locale do SSR (evita "voltar pro inglês"), mesmo que o
216
+ // beforeLoad do root não rode na hidratação.
217
+ if (typeof window !== "undefined") {
218
+ try {
219
+ let __l = new URL(window.location.href).searchParams.get("locale");
220
+ if (!__l) { const __m = document.cookie.match(/(?:^|;\\s*)site-locale=([^;]+)/); if (__m) __l = decodeURIComponent(__m[1]); }
221
+ if (__l) __setLocale(__l);
222
+ } catch {}
223
+ }
224
+
225
+ // Exports "vivos": seguem o locale ativo sem precisar mudar os componentes.
226
+ function __live(key: string): any {
227
+ return new Proxy(Array.isArray(__data[__defaultLocale]?.[key]) ? [] : {}, {
228
+ get(_t, p) {
229
+ const v = __data[__locale]?.[key];
230
+ const r = v == null ? v : (v as any)[p as any];
231
+ return typeof r === 'function' ? r.bind(v) : r;
232
+ },
233
+ has(_t, p) { const v = __data[__locale]?.[key]; return v != null && (p in (v as any)); },
234
+ ownKeys() { const v = __data[__locale]?.[key]; return v ? Reflect.ownKeys(v as any) : []; },
235
+ getOwnPropertyDescriptor(_t, p) {
236
+ const v = __data[__locale]?.[key]; if (v == null) return undefined;
237
+ const d = Object.getOwnPropertyDescriptor(v as any, p);
238
+ if (d) (d as any).configurable = true;
239
+ return d;
240
+ },
241
+ });
242
+ }
243
+
244
+ ${liveExports}
245
+ `;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // regeneração via IA (1 arquivo)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function stripFence(s: string): string {
253
+ // remove cerca de markdown com QUALQUER tag de linguagem (ts/tsx/js/jsx/
254
+ // javascript/typescript…) — senão o nome da linguagem vaza pro início do código.
255
+ const m = s.match(/```[a-zA-Z]*[ \t]*\r?\n([\s\S]*?)```/);
256
+ let out = (m ? m[1] : s).trim();
257
+ // defesa extra: linha 1 sendo só a tag de linguagem.
258
+ out = out.replace(/^\s*(?:javascript|typescript|tsx?|jsx?)\s*\n/i, '');
259
+ return out.trim();
260
+ }
261
+
262
+ async function regenFile(
263
+ apiKey: string,
264
+ original: string,
265
+ rel: string,
266
+ strapiData: Record<string, any>
267
+ ): Promise<string> {
268
+ const prompt = `Você vai REGENERAR um arquivo de dados de um frontend, trocando os valores hardcoded pelos dados vindos do Strapi (um snapshot).
269
+
270
+ REGRAS ESTRITAS:
271
+ - Mantenha EXATAMENTE os mesmos: imports, declarações de type/interface e NOMES de export.
272
+ - Para cada export que corresponde a um conteúdo do Strapi, substitua o valor pelos dados do Strapi.
273
+ - Campos de IMAGEM/asset (ex.: image, cover, gallery, before, after): se o Strapi tiver uma URL, use-a; SENÃO, mantenha o valor ORIGINAL do arquivo (casando os itens por "slug" ou "title"). Nunca deixe a imagem quebrada.
274
+ - Não invente conteúdo. Se um export não tiver correspondência no Strapi, mantenha o valor original.
275
+ - Mantenha o arquivo válido em TypeScript e compatível com os tipos existentes.
276
+ - Responda APENAS com o conteúdo final do arquivo (sem markdown, sem comentários extras além de um cabeçalho curto indicando que é um snapshot do Strapi).
277
+
278
+ ARQUIVO ATUAL (${rel}):
279
+ ${original.length > MAX_FILE_CHARS ? original.slice(0, MAX_FILE_CHARS) + '\n/* …truncado… */' : original}
280
+
281
+ DADOS DO STRAPI (JSON, por content-type singularName):
282
+ ${JSON.stringify(strapiData, null, 2).slice(0, 24000)}`;
283
+
284
+ const res = await fetch(OPENAI_URL, {
285
+ method: 'POST',
286
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
287
+ body: JSON.stringify({
288
+ model: MODEL,
289
+ temperature: 0,
290
+ messages: [
291
+ { role: 'system', content: 'Você regenera arquivos de dados TS preservando estrutura, só trocando os valores. Responde só com o código.' },
292
+ { role: 'user', content: prompt },
293
+ ],
294
+ }),
295
+ });
296
+ if (!res.ok) throw new Error(await res.text());
297
+ const json = (await res.json()) as any;
298
+ return stripFence(json.choices?.[0]?.message?.content ?? '');
299
+ }
300
+
301
+ const LANG_NAMES: Record<string, string> = {
302
+ en: 'English', 'pt-BR': 'Brazilian Portuguese', pt: 'Portuguese', es: 'Spanish',
303
+ fr: 'French', de: 'German', it: 'Italian', nl: 'Dutch', ja: 'Japanese',
304
+ ko: 'Korean', ru: 'Russian', ar: 'Arabic', 'zh-Hans': 'Simplified Chinese', zh: 'Chinese',
305
+ };
306
+
307
+ /**
308
+ * Traduz os VALORES de texto exibível de um arquivo de dados já estruturado para
309
+ * outro idioma, preservando tudo o mais (imports, chaves, identificadores,
310
+ * números, URLs, e-mails, telefones, SLUGS e refs de imagem). Prompt pequeno
311
+ * (só o arquivo) → confiável e sem truncar.
312
+ */
313
+ async function translateDataFile(
314
+ apiKey: string,
315
+ fileContent: string,
316
+ targetLang: string
317
+ ): Promise<string> {
318
+ const prompt = `Traduza para ${targetLang} APENAS os textos exibíveis (títulos, descrições, taglines, rótulos, benefícios, nomes de exibição, etc.) deste arquivo de dados TypeScript.
319
+
320
+ REGRAS ESTRITAS:
321
+ - NÃO altere: imports, nomes de chaves, identificadores, números, URLs, e-mails, telefones, refs de imagem (variáveis importadas) e valores de "slug" (mantenha os slugs EXATAMENTE iguais — são usados em rotas).
322
+ - Mantenha a estrutura/sintaxe TypeScript idêntica; só troque o conteúdo dos textos por humanos.
323
+ - Não traduza marcas/nomes próprios óbvios (ex.: nome da empresa) a menos que façam sentido.
324
+ - Responda APENAS com o arquivo final (sem markdown).
325
+
326
+ ARQUIVO:
327
+ ${fileContent}`;
328
+ const res = await fetch(OPENAI_URL, {
329
+ method: 'POST',
330
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({
332
+ model: MODEL,
333
+ temperature: 0,
334
+ messages: [
335
+ { role: 'system', content: 'Você traduz apenas os textos exibíveis de arquivos de dados TS, preservando estrutura, identificadores e slugs. Responde só com o código.' },
336
+ { role: 'user', content: prompt },
337
+ ],
338
+ }),
339
+ });
340
+ if (!res.ok) throw new Error(await res.text());
341
+ const json = (await res.json()) as any;
342
+ return stripFence(json.choices?.[0]?.message?.content ?? '');
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // orquestração
347
+ // ---------------------------------------------------------------------------
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // PADRÃO OFICIAL: consumo ao vivo da Content API via @strapi/client
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /** Client oficial (@strapi/client). URL pública do env do framework; token é
354
+ * server-only e opcional (a leitura é pública via permissions.grantPublicRead). */
355
+ const STRAPI_CLIENT_TS = `// Gerado pelo mcp-chat — client oficial @strapi/client.
356
+ import { strapi } from "@strapi/client";
357
+
358
+ function baseUrl(): string {
359
+ // Vite (TanStack) expõe via import.meta.env; Next via process.env.
360
+ try {
361
+ // @ts-ignore
362
+ const v = import.meta?.env?.VITE_STRAPI_URL;
363
+ if (v) return v;
364
+ } catch {}
365
+ if (typeof process !== "undefined") {
366
+ const p = process.env.NEXT_PUBLIC_STRAPI_URL || process.env.STRAPI_URL;
367
+ if (p) return p;
368
+ }
369
+ return "http://localhost:1337";
370
+ }
371
+ const token = typeof process !== "undefined" ? process.env.STRAPI_API_TOKEN : undefined;
372
+
373
+ const client = strapi({ baseURL: baseUrl().replace(/\\/$/, "") + "/api", ...(token ? { auth: token } : {}) });
374
+
375
+ export type FetchOpts = { locale?: string; status?: "draft" | "published" };
376
+ const params = (o: FetchOpts) => ({ populate: "*" as const, ...(o.locale ? { locale: o.locale } : {}), ...(o.status ? { status: o.status } : {}) });
377
+
378
+ /** Coleção (usa o pluralName). Retorna o array de documentos (shape flat v5). */
379
+ export async function fetchCollection(plural: string, o: FetchOpts = {}): Promise<any[]> {
380
+ const r = await client.collection(plural).find(params(o));
381
+ return (r as any).data ?? [];
382
+ }
383
+ /** Single type (usa o singularName). Retorna o documento. */
384
+ export async function fetchSingle(singular: string, o: FetchOpts = {}): Promise<any> {
385
+ const r = await client.single(singular).find(params(o));
386
+ return (r as any).data ?? null;
387
+ }
388
+ `;
389
+
390
+ /** Garante que `@strapi/client` está instalado no frontend (o módulo ao vivo o
391
+ * importa). Adiciona à package.json e instala se faltar. Best-effort. */
392
+ async function ensureClientDep(frontendDir: string, warnings: string[]): Promise<void> {
393
+ if (fs.existsSync(path.join(frontendDir, 'node_modules', '@strapi', 'client'))) return;
394
+ // declara na package.json
395
+ try {
396
+ const pkgPath = path.join(frontendDir, 'package.json');
397
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
398
+ pkg.dependencies = pkg.dependencies || {};
399
+ if (!pkg.dependencies['@strapi/client']) {
400
+ pkg.dependencies['@strapi/client'] = '^1.6.2';
401
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
402
+ }
403
+ } catch (e: any) {
404
+ warnings.push(`package.json do frontend: ${e?.message ?? e}`);
405
+ }
406
+ // detecta o PM e instala só o @strapi/client
407
+ const pm = fs.existsSync(path.join(frontendDir, 'bun.lock')) || fs.existsSync(path.join(frontendDir, 'bun.lockb'))
408
+ ? 'bun'
409
+ : fs.existsSync(path.join(frontendDir, 'pnpm-lock.yaml'))
410
+ ? 'pnpm'
411
+ : fs.existsSync(path.join(frontendDir, 'yarn.lock'))
412
+ ? 'yarn'
413
+ : 'npm';
414
+ const args = pm === 'npm' ? ['install', '@strapi/client', '--no-audit', '--no-fund'] : ['add', '@strapi/client'];
415
+ await new Promise<void>((resolve) => {
416
+ try {
417
+ const c = spawn(pm, args, { cwd: frontendDir, stdio: 'ignore' });
418
+ c.on('exit', () => resolve());
419
+ c.on('error', () => { warnings.push(`não consegui instalar @strapi/client (${pm}); instale manualmente.`); resolve(); });
420
+ } catch {
421
+ warnings.push('falha ao instalar @strapi/client; instale manualmente.');
422
+ resolve();
423
+ }
424
+ });
425
+ }
426
+
427
+ /** Busca uma AMOSTRA real (1 doc por content-type) p/ a IA gerar o mapeador. */
428
+ async function fetchSample(
429
+ strapi: any,
430
+ cts: { singularName: string; kind: string }[]
431
+ ): Promise<Record<string, any>> {
432
+ const sample: Record<string, any> = {};
433
+ for (const ct of cts) {
434
+ const uid = apiUid(ct.singularName);
435
+ try {
436
+ if (ct.kind === 'singleType') {
437
+ sample[ct.singularName] = await strapi.documents(uid).findFirst({ status: 'published', populate: '*' });
438
+ } else {
439
+ const docs = await strapi.documents(uid).findMany({ status: 'published', populate: '*', limit: 2 });
440
+ sample[ct.singularName] = Array.isArray(docs) ? docs : [];
441
+ }
442
+ } catch {
443
+ sample[ct.singularName] = ct.kind === 'singleType' ? null : [];
444
+ }
445
+ }
446
+ return sample;
447
+ }
448
+
449
+ /**
450
+ * Gera (1 chamada de IA) uma FUNÇÃO PURA `mapStrapiToData(raw)` que converte a
451
+ * resposta da Content API (raw, por singularName) no shape exato dos exports do
452
+ * arquivo de dados original (.bak). Imagens: usa a URL de mídia do Strapi quando
453
+ * houver; senão mantém o asset importado original (fallback). Determinística e
454
+ * reutilizável em runtime (não roda por locale).
455
+ */
456
+ async function generateMapper(
457
+ apiKey: string,
458
+ baseSrc: string,
459
+ sample: Record<string, any>,
460
+ assetIds: string[]
461
+ ): Promise<string> {
462
+ const prompt = `Gere uma FUNÇÃO TypeScript pura chamada exatamente \`mapStrapiToData(raw: Record<string, any>)\` que recebe os dados da Content API do Strapi e retorna um objeto com EXATAMENTE os mesmos exports (mesmas chaves e MESMO shape) do arquivo de dados abaixo.
463
+
464
+ REGRAS:
465
+ - A função retorna um objeto cujas chaves são os nomes dos \`export const\` do arquivo (ex.: site, services, projects…), cada um no MESMO formato que o arquivo original.
466
+ - \`raw\` tem uma chave por content-type (singularName): coleções são arrays de documentos; single types são um objeto. Documentos vêm no shape FLAT do Strapi 5 (campos no topo: documentId, e os atributos diretamente).
467
+ - Mapeie os campos do Strapi para os campos esperados pelo arquivo (casando por nome/significado). Para listas, use \`(raw.x ?? []).map(...)\`.
468
+ - IMAGENS: o campo pode ser objeto de mídia com \`.url\`, uma string, ou null. Use \`(doc?.campo?.url ?? doc?.campo)\` quando houver; SENÃO use o asset importado original como fallback. Assets importados disponíveis (use como variáveis): ${assetIds.join(', ') || '(nenhum)'}.
469
+ - DEFENSIVO (OBRIGATÓRIO — o SSR não pode quebrar): use SEMPRE optional chaining \`?.\` e defaults \`??\`. NUNCA chame métodos (\`.replace\`, \`.map\`, \`.split\`, etc.) em valores que possam ser null/undefined — guarde antes: \`(x ?? "").replace(...)\`, \`(arr ?? []).map(...)\`. Todo acesso a sub-campo deve tolerar ausência.
470
+ - Não invente conteúdo; campo ausente → default sensato (string vazia, array vazio, ou o fallback de imagem).
471
+ - ISOLAMENTO POR EXPORT (OBRIGATÓRIO): construa o resultado com CADA export no seu próprio try/catch, para que uma falha num export NÃO derrube os outros. Padrão EXATO:
472
+ \`function mapStrapiToData(raw) { const out = {}; try { out.site = (/* ... */); } catch { out.site = {}; } try { out.services = (raw.service ?? []).map((s) => (/* ... */)); } catch { out.services = []; } /* ...um try/catch por export... */ return out; }\`
473
+ - Responda APENAS com o código da função (sem imports, sem markdown, sem exports — só \`function mapStrapiToData(raw) { ... }\`).
474
+
475
+ ARQUIVO DE DADOS ORIGINAL (shape alvo):
476
+ ${baseSrc.length > MAX_FILE_CHARS ? baseSrc.slice(0, MAX_FILE_CHARS) : baseSrc}
477
+
478
+ AMOSTRA REAL DA RESPOSTA DO STRAPI (JSON, por singularName):
479
+ ${JSON.stringify(sample, null, 1).slice(0, 18000)}`;
480
+ const res = await fetch(OPENAI_URL, {
481
+ method: 'POST',
482
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
483
+ body: JSON.stringify({
484
+ model: MODEL,
485
+ temperature: 0,
486
+ messages: [
487
+ { role: 'system', content: 'Você gera uma função pura de mapeamento Strapi→shape do frontend. Responde só com a função.' },
488
+ { role: 'user', content: prompt },
489
+ ],
490
+ }),
491
+ });
492
+ if (!res.ok) throw new Error(await res.text());
493
+ const json = (await res.json()) as any;
494
+ return stripFence(json.choices?.[0]?.message?.content ?? '');
495
+ }
496
+
497
+ /** Identificadores de assets importados (p/ fallback de imagem no mapeador). */
498
+ export function assetImportIds(src: string): string[] {
499
+ const ids: string[] = [];
500
+ const re = /import\s+(\w+)\s+from\s+['"][^'"]+['"]/g;
501
+ let m: RegExpExecArray | null;
502
+ while ((m = re.exec(src))) ids.push(m[1]);
503
+ return ids;
504
+ }
505
+
506
+ /**
507
+ * Monta o módulo de dados AO VIVO: imports de assets (fallback) + client oficial
508
+ * + mapeador + store + exports "vivos" (Proxy) + loadAllData/hydrate. Os
509
+ * componentes seguem fazendo `import { site } from "@/data/site"` sem mudança; o
510
+ * loader do root chama loadAllData({locale,status}) e hidrata antes do render.
511
+ */
512
+ export function buildLiveDataModule(
513
+ baseSrc: string,
514
+ mapperCode: string,
515
+ cts: { singularName: string; pluralName: string; kind: string }[],
516
+ locales: string[],
517
+ defLocale: string
518
+ ): string {
519
+ const imports = extractImports(baseSrc).split('\n').filter((l) => /@\/assets|\.(png|jpe?g|svg|webp|gif)/i.test(l)).join('\n');
520
+ const names = exportNames(baseSrc);
521
+ const ctMeta = JSON.stringify(cts.map((c) => ({ s: c.singularName, p: c.pluralName, k: c.kind })));
522
+ // single types (ex.: home-content) viram exports camelCase (homeContent) com o
523
+ // documento bruto traduzido — usados pelos componentes religados ao CMS.
524
+ const camel = (s: string) => s.replace(/-+([a-zA-Z0-9])/g, (_m, c) => c.toUpperCase());
525
+ const singleMap: Record<string, string> = {};
526
+ // single types viram exports camelCase, EXCETO os que colidem com um export já
527
+ // existente no data file (ex.: o single type `site` vs o export `site`) — esses
528
+ // o mapeador já trata.
529
+ for (const c of cts) {
530
+ if (c.kind !== 'singleType') continue;
531
+ const e = camel(c.singularName);
532
+ if (names.includes(e)) continue;
533
+ singleMap[c.singularName] = e;
534
+ }
535
+ const pageExports = Object.values(singleMap)
536
+ .map((e) => `export const ${e}: any = __live(${JSON.stringify(e)});`)
537
+ .join('\n');
538
+ const liveExports =
539
+ names.map((n) => `export const ${n}: any = __live(${JSON.stringify(n)});`).join('\n') +
540
+ (pageExports ? '\n' + pageExports : '');
541
+ return `// Live data from Strapi Content API (gerado pelo mcp-chat)
542
+ ${imports}
543
+ import { fetchCollection, fetchSingle } from "./strapi-client";
544
+
545
+ const __cts = ${ctMeta};
546
+ const __single: Record<string, string> = ${JSON.stringify(singleMap)};
547
+ export const __availableLocales = ${JSON.stringify(locales)};
548
+ export const __defaultLocale = ${JSON.stringify(defLocale)};
549
+ /** Locale ativo a partir de ?locale/cookie (cliente) — p/ o seletor. */
550
+ export function __getLocale(): string {
551
+ try {
552
+ if (typeof window !== "undefined") {
553
+ const u = new URL(window.location.href).searchParams.get("locale");
554
+ if (u) return u;
555
+ const m = document.cookie.match(/(?:^|;\\s*)site-locale=([^;]+)/);
556
+ if (m) return decodeURIComponent(m[1]);
557
+ }
558
+ } catch {}
559
+ return __defaultLocale;
560
+ }
561
+
562
+ ${mapperCode}
563
+
564
+ const __store: Record<string, any> = {};
565
+ export function hydrate(d: any) { if (d) for (const k of Object.keys(d)) __store[k] = d[k]; }
566
+
567
+ export async function loadAllData(opts: { locale?: string; status?: "draft" | "published" } = {}) {
568
+ const raw: Record<string, any> = {};
569
+ await Promise.all(
570
+ __cts.map(async (c: any) => {
571
+ try {
572
+ raw[c.s] = c.k === "singleType" ? await fetchSingle(c.s, opts) : await fetchCollection(c.p, opts);
573
+ } catch {
574
+ raw[c.s] = c.k === "singleType" ? null : [];
575
+ }
576
+ })
577
+ );
578
+ let data: any = {};
579
+ try { data = mapStrapiToData(raw) || {}; }
580
+ catch (e) { if (typeof console !== "undefined") console.error("[mcp-chat] mapStrapiToData falhou:", e); }
581
+ hydrate(data);
582
+ // expõe o conteúdo de página (single types) bruto/traduzido p/ os componentes.
583
+ for (const s of Object.keys(__single)) __store[__single[s]] = raw[s] || {};
584
+ return data;
585
+ }
586
+
587
+ // Exports "vivos": leem o store hidratado pelo loader (sem mudar os componentes).
588
+ function __live(key: string): any {
589
+ return new Proxy(function () {} as any, {
590
+ get(_t, p) {
591
+ const v = __store[key];
592
+ const r = v == null ? (Array.isArray(__fallback(key)) ? [] : undefined) : (v as any)[p as any];
593
+ return typeof r === "function" ? r.bind(v) : r;
594
+ },
595
+ has(_t, p) { const v = __store[key]; return v != null && (p in (v as any)); },
596
+ ownKeys() { const v = __store[key]; return v ? Reflect.ownKeys(v as any) : []; },
597
+ getOwnPropertyDescriptor(_t, p) {
598
+ const v = __store[key]; if (v == null) return undefined;
599
+ const d = Object.getOwnPropertyDescriptor(v as any, p);
600
+ if (d) (d as any).configurable = true;
601
+ return d;
602
+ },
603
+ });
604
+ }
605
+ function __fallback(_k: string): any { return []; }
606
+
607
+ ${liveExports}
608
+ `;
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // injeção do seletor de idioma (LanguageSwitcher) + resolução isomórfica
613
+ // ---------------------------------------------------------------------------
614
+
615
+ /** Componente self-contained (sem libs externas) que troca o locale e recarrega. */
616
+ const switcherTsx = (dataImport: string) => `// Gerado pelo mcp-chat — seletor de idioma.
617
+ import { __availableLocales, __getLocale } from "${dataImport}";
618
+
619
+ const LABELS: Record<string, string> = {
620
+ en: "EN", "pt-BR": "PT", pt: "PT", es: "ES", fr: "FR", de: "DE", it: "IT",
621
+ nl: "NL", ja: "JA", ko: "KO", ru: "RU", ar: "AR", "zh-Hans": "ZH", zh: "ZH",
622
+ };
623
+
624
+ export function LanguageSwitcher() {
625
+ if (!__availableLocales || __availableLocales.length < 2) return null;
626
+ const current = __getLocale();
627
+ const onChange = (e: any) => {
628
+ const loc = e.target.value;
629
+ try { document.cookie = "site-locale=" + loc + ";path=/;max-age=31536000"; } catch {}
630
+ const u = new URL(window.location.href);
631
+ u.searchParams.set("locale", loc);
632
+ window.location.href = u.toString();
633
+ };
634
+ return (
635
+ <select
636
+ aria-label="Language"
637
+ value={current}
638
+ onChange={onChange}
639
+ style={{
640
+ border: "1px solid rgba(0,0,0,.15)", borderRadius: 9999, padding: "4px 10px",
641
+ fontSize: 13, fontWeight: 600, background: "transparent", cursor: "pointer",
642
+ }}
643
+ >
644
+ {__availableLocales.map((l: string) => (
645
+ <option key={l} value={l}>{LABELS[l] || l.toUpperCase()}</option>
646
+ ))}
647
+ </select>
648
+ );
649
+ }
650
+
651
+ export default LanguageSwitcher;
652
+ `;
653
+
654
+ /**
655
+ * Liga o consumo ao vivo: no __root injeta um loader que chama loadAllData (busca
656
+ * da Content API por locale/status e hidrata o store ANTES do render — isomórfico,
657
+ * sem hydration mismatch) e renderiza o <LanguageSwitcher/> no Header.
658
+ * `dataImport` é o specifier do módulo de dados (ex.: "@/data/site").
659
+ */
660
+ function injectSwitcher(frontendDir: string, warnings: string[], dataImport = '@/data/site'): void {
661
+ const compDir = path.join(frontendDir, 'src', 'components');
662
+ fs.mkdirSync(compDir, { recursive: true });
663
+ fs.writeFileSync(path.join(compDir, 'LanguageSwitcher.tsx'), switcherTsx(dataImport), 'utf8');
664
+
665
+ // 1) __root: loader isomórfico que carrega os dados (locale/status) antes do render.
666
+ const rootRel = ['src/routes/__root.tsx', 'src/routes/__root.jsx'].find((r) =>
667
+ fs.existsSync(path.join(frontendDir, r))
668
+ );
669
+ if (rootRel) {
670
+ const abs = path.join(frontendDir, rootRel);
671
+ let src = fs.readFileSync(abs, 'utf8');
672
+ if (!src.includes('loadAllData')) {
673
+ src = `import { loadAllData } from "${dataImport}";\n` + src;
674
+ const m = src.match(/createRootRoute\w*\s*(?:<[\s\S]*?>)?\s*\([\s\S]*?\)\s*\(\s*\{/);
675
+ if (m) {
676
+ const at = m.index! + m[0].length;
677
+ src =
678
+ src.slice(0, at) +
679
+ `\n validateSearch: (s: Record<string, unknown>) => ({ locale: typeof s.locale === "string" ? s.locale : undefined }),` +
680
+ `\n loaderDeps: ({ search }: any) => ({ locale: search.locale }),` +
681
+ `\n loader: async ({ deps }: any) => { const data = await loadAllData({ locale: deps?.locale }); return { data }; },` +
682
+ src.slice(at);
683
+ } else {
684
+ warnings.push('não consegui injetar o loader no __root (padrão não encontrado).');
685
+ }
686
+ fs.writeFileSync(abs, src, 'utf8');
687
+ }
688
+ } else {
689
+ warnings.push('__root não encontrado — dados ao vivo não ligados ao SSR.');
690
+ }
691
+
692
+ // 2) Header: renderizar o <LanguageSwitcher/>.
693
+ const headerRel = ['src/components/Header.tsx', 'src/components/Header.jsx'].find((r) =>
694
+ fs.existsSync(path.join(frontendDir, r))
695
+ );
696
+ if (headerRel) {
697
+ const abs = path.join(frontendDir, headerRel);
698
+ let src = fs.readFileSync(abs, 'utf8');
699
+ if (!src.includes('LanguageSwitcher')) {
700
+ src = `import { LanguageSwitcher } from "@/components/LanguageSwitcher";\n` + src;
701
+ if (/<button[^>]*lg:hidden/.test(src)) {
702
+ src = src.replace(/(\s*)(<button[^>]*lg:hidden)/, `$1<LanguageSwitcher />$1$2`);
703
+ } else {
704
+ src = src.replace(/<\/header>/, ` <LanguageSwitcher />\n </header>`);
705
+ }
706
+ fs.writeFileSync(abs, src, 'utf8');
707
+ }
708
+ } else {
709
+ warnings.push('Header não encontrado — adicione <LanguageSwitcher/> manualmente.');
710
+ }
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // religar componentes ao CMS (texto hardcoded → conteúdo do Strapi por locale)
715
+ // ---------------------------------------------------------------------------
716
+
717
+ const SYS_FIELDS = new Set(['id', 'documentId', 'createdAt', 'updatedAt', 'publishedAt', 'locale', 'slug', 'url']);
718
+
719
+ /** Mapa: valor-de-texto-EN → expressão JS (lê do export de conteúdo + fallback). */
720
+ function buildValueMap(
721
+ sample: Record<string, any>,
722
+ cts: { singularName: string; kind: string }[]
723
+ ): Record<string, string> {
724
+ const camel = (s: string) => s.replace(/-+([a-zA-Z0-9])/g, (_m, c) => c.toUpperCase());
725
+ const map: Record<string, string> = {};
726
+ for (const ct of cts) {
727
+ if (ct.kind !== 'singleType') continue;
728
+ const doc = sample[ct.singularName];
729
+ if (!doc || typeof doc !== 'object') continue;
730
+ const exp = camel(ct.singularName);
731
+ for (const [field, val] of Object.entries(doc)) {
732
+ if (SYS_FIELDS.has(field)) continue;
733
+ if (typeof val !== 'string') continue;
734
+ const v = val.trim();
735
+ if (v.length < 4) continue; // muito curto/ambíguo
736
+ if (/^https?:\/\//.test(v)) continue; // URLs
737
+ if (map[v]) continue; // 1ª ocorrência vence
738
+ map[v] = `${exp}?.${field} ?? ${JSON.stringify(v)}`;
739
+ }
740
+ }
741
+ return map;
742
+ }
743
+
744
+ /** Religa UM componente: a IA troca os textos hardcoded pelas expressões do CMS. */
745
+ async function generateRewire(
746
+ apiKey: string,
747
+ src: string,
748
+ subset: Record<string, string>,
749
+ dataImport: string
750
+ ): Promise<string> {
751
+ const pairs = Object.entries(subset)
752
+ .map(([v, expr]) => `- ${JSON.stringify(v)} → {${expr}}`)
753
+ .join('\n');
754
+ const prompt = `Religue este componente React/JSX ao CMS, trocando textos hardcoded por expressões que leem do Strapi (já localizadas).
755
+
756
+ MAPA (texto exato no arquivo → expressão a usar):
757
+ ${pairs}
758
+
759
+ REGRAS ESTRITAS:
760
+ - Para CADA ocorrência EXATA de um texto do mapa, substitua pela expressão, no formato correto do contexto:
761
+ • nó de texto JSX: Texto → {EXPR}
762
+ • atributo string: attr="Texto" → attr={EXPR}
763
+ • string JS usada como conteúdo: "Texto" → (EXPR)
764
+ - A EXPR já tem fallback (?? "texto original"); use-a como veio (entre {} no JSX).
765
+ - Adicione os imports necessários no topo: importe os símbolos usados (ex.: ${dataImport.includes('~') ? '~' : '@'}/data/site). Os exports de conteúdo são camelCase (ex.: homeContent).
766
+ - NÃO altere mais NADA: estrutura, classes, lógica, imports existentes, nada fora do mapa.
767
+ - Responda APENAS com o arquivo final (sem markdown).
768
+
769
+ ARQUIVO:
770
+ ${src.length > MAX_FILE_CHARS ? src.slice(0, MAX_FILE_CHARS) : src}`;
771
+ const res = await fetch(OPENAI_URL, {
772
+ method: 'POST',
773
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
774
+ body: JSON.stringify({
775
+ model: MODEL,
776
+ temperature: 0,
777
+ messages: [
778
+ { role: 'system', content: 'Você religa componentes ao CMS trocando só os textos do mapa por expressões. Responde só com o código.' },
779
+ { role: 'user', content: prompt },
780
+ ],
781
+ }),
782
+ });
783
+ if (!res.ok) throw new Error(await res.text());
784
+ const json = (await res.json()) as any;
785
+ return stripFence(json.choices?.[0]?.message?.content ?? '');
786
+ }
787
+
788
+ /** Checa sintaxe de um .tsx/.ts. Usa esbuild se disponível; senão cai num
789
+ * heurístico de balanceamento (delimitadores + presença de export/JSX). */
790
+ async function syntaxOk(code: string): Promise<boolean> {
791
+ let esbuild: any;
792
+ try { esbuild = require('esbuild'); } catch { esbuild = null; }
793
+ if (esbuild?.transform) {
794
+ try { await esbuild.transform(code, { loader: 'tsx' }); return true; }
795
+ catch { return false; }
796
+ }
797
+ // fallback: balanceamento de () {} [] fora de strings/comentários
798
+ let depthC = 0, depthB = 0, depthP = 0, inStr: string | null = null, esc = false;
799
+ for (let i = 0; i < code.length; i++) {
800
+ const c = code[i];
801
+ if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === inStr) inStr = null; continue; }
802
+ if (c === '"' || c === "'" || c === '`') inStr = c;
803
+ else if (c === '{') depthC++; else if (c === '}') depthC--;
804
+ else if (c === '[') depthB++; else if (c === ']') depthB--;
805
+ else if (c === '(') depthP++; else if (c === ')') depthP--;
806
+ if (depthC < 0 || depthB < 0 || depthP < 0) return false;
807
+ }
808
+ return depthC === 0 && depthB === 0 && depthP === 0 && /export\s/.test(code);
809
+ }
810
+
811
+ /** Consolida QUALQUER import de `<base>/...` (ex.: @/data/site, @/data/homeContent)
812
+ * num único `import { ...todos... } from "<dataImport>"`. Corrige a IA quando ela
813
+ * trata o nome do export como caminho de módulo (import inválido). */
814
+ function normalizeDataImports(code: string, dataImport: string): string {
815
+ const base = dataImport.replace(/\/[^/]+$/, ''); // ex.: @/data
816
+ const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
817
+ const re = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*["']${esc}/[^"']*["'];?[ \\t]*\\n?`, 'g');
818
+ const specs = new Set<string>();
819
+ let m: RegExpExecArray | null;
820
+ while ((m = re.exec(code))) {
821
+ for (const s of m[1].split(',')) {
822
+ const t = s.trim();
823
+ if (t) specs.add(t);
824
+ }
825
+ }
826
+ if (!specs.size) return code;
827
+ const out = code.replace(re, '');
828
+ return `import { ${[...specs].join(', ')} } from "${dataImport}";\n` + out;
829
+ }
830
+
831
+ /** Religa todos os componentes que contêm textos do CMS. */
832
+ async function rewireComponents(
833
+ strapi: any,
834
+ opts: { frontendDir: string; sample: Record<string, any>; cts: any[]; apiKey: string; dataImport: string },
835
+ warnings: string[]
836
+ ): Promise<string[]> {
837
+ const map = buildValueMap(opts.sample, opts.cts);
838
+ if (!Object.keys(map).length) return [];
839
+ const rewired: string[] = [];
840
+ const files: string[] = [];
841
+ walk(opts.frontendDir, opts.frontendDir, files);
842
+ const targets = files.filter(
843
+ (rel) =>
844
+ /\.(tsx|jsx)$/.test(rel) &&
845
+ !/__root|routeTree\.gen|\/api\/|LanguageSwitcher|PreviewBridge|\/ui\//.test(rel)
846
+ );
847
+ for (const rel of targets) {
848
+ const abs = path.join(opts.frontendDir, rel);
849
+ let src: string;
850
+ try { src = fs.readFileSync(abs, 'utf8'); } catch { continue; }
851
+ const subset: Record<string, string> = {};
852
+ for (const [v, expr] of Object.entries(map)) if (src.includes(v)) subset[v] = expr;
853
+ if (!Object.keys(subset).length) continue; // nada do CMS aqui
854
+ try {
855
+ let out = await generateRewire(opts.apiKey, src, subset, opts.dataImport);
856
+ if (!out || out.length < 30) { warnings.push(`${rel}: rewire vazio, pulado.`); continue; }
857
+ out = normalizeDataImports(out, opts.dataImport); // corrige imports de dados
858
+ if (!(await syntaxOk(out))) { warnings.push(`${rel}: rewire com erro de sintaxe, mantido original.`); continue; }
859
+ const bak = abs + '.bak';
860
+ if (!fs.existsSync(bak)) fs.writeFileSync(bak, src, 'utf8');
861
+ fs.writeFileSync(abs, out, 'utf8');
862
+ rewired.push(rel);
863
+ } catch (e: any) {
864
+ warnings.push(`${rel}: rewire falhou (${e?.message ?? e}).`);
865
+ }
866
+ }
867
+ return rewired;
868
+ }
869
+
870
+ export async function integrateFrontend(
871
+ strapi: any,
872
+ opts: { frontendDir: string; manifest: Manifest }
873
+ ): Promise<IntegrateResult> {
874
+ const result: IntegrateResult = {
875
+ ok: false,
876
+ filesRewritten: [],
877
+ contentTypesFetched: [],
878
+ warnings: [],
879
+ errors: [],
880
+ };
881
+
882
+ const apiKey = process.env.OPENAI_API_KEY;
883
+ if (!apiKey) {
884
+ result.errors.push('OPENAI_API_KEY não configurada no .env do Strapi.');
885
+ return result;
886
+ }
887
+
888
+ const dataFiles = findDataFiles(opts.frontendDir);
889
+ if (dataFiles.length === 0) {
890
+ result.errors.push('Não encontrei um arquivo de dados para sincronizar (ex.: src/data/site.ts).');
891
+ return result;
892
+ }
893
+
894
+ const { codes, def } = await getLocales(strapi);
895
+ const locales = codes.length ? codes : [def];
896
+
897
+ // metadados das content-types (singular/plural/kind) p/ o client oficial.
898
+ const ctMeta = opts.manifest.contentTypes.map((ct) => ({
899
+ singularName: ct.singularName,
900
+ pluralName:
901
+ strapi?.contentTypes?.[apiUid(ct.singularName)]?.info?.pluralName || `${ct.singularName}s`,
902
+ kind: ct.kind,
903
+ }));
904
+
905
+ // amostra real (default) p/ a IA gerar o mapeador; também serve de contagem.
906
+ const sample = await fetchSample(strapi, ctMeta);
907
+ result.contentTypesFetched = ctMeta.map((c) => ({
908
+ uid: apiUid(c.singularName),
909
+ count: Array.isArray(sample[c.singularName]) ? sample[c.singularName].length : sample[c.singularName] ? 1 : 0,
910
+ }));
911
+
912
+ for (const rel of dataFiles) {
913
+ const abs = path.join(opts.frontendDir, rel);
914
+ try {
915
+ const original = fs.readFileSync(abs, 'utf8');
916
+ const bak = abs + '.bak';
917
+ if (!fs.existsSync(bak)) fs.writeFileSync(bak, original, 'utf8');
918
+ // estrutura-alvo SEMPRE do .bak (em re-runs o arquivo atual já é gerado).
919
+ const baseSrc = fs.existsSync(bak) ? fs.readFileSync(bak, 'utf8') : original;
920
+
921
+ // PADRÃO OFICIAL: gera o mapeador (Strapi→shape) 1x e monta o módulo que
922
+ // consome a Content API ao vivo (@strapi/client) por locale/status.
923
+ const mapper = await generateMapper(apiKey, baseSrc, sample, assetImportIds(baseSrc));
924
+ if (!mapper || !/mapStrapiToData/.test(mapper)) {
925
+ result.warnings.push(`${rel}: mapeador inválido, pulado.`);
926
+ continue;
927
+ }
928
+ const moduleSrc = buildLiveDataModule(baseSrc, mapper, ctMeta, locales, def);
929
+ fs.writeFileSync(abs, moduleSrc, 'utf8');
930
+ result.filesRewritten.push(rel);
931
+ } catch (e: any) {
932
+ result.errors.push(`${rel}: ${e?.message ?? e}`);
933
+ }
934
+ }
935
+
936
+ // client oficial + loader isomórfico (loadAllData) + seletor de idioma.
937
+ if (result.filesRewritten.length > 0) {
938
+ try {
939
+ const rel0 = result.filesRewritten[0];
940
+ const dataDir = path.dirname(path.join(opts.frontendDir, rel0));
941
+ fs.writeFileSync(path.join(dataDir, 'strapi-client.ts'), STRAPI_CLIENT_TS, 'utf8');
942
+ const dataImport = '@/' + rel0.replace(/^src\//, '').replace(/\.(tsx?|jsx?)$/, '');
943
+ injectSwitcher(opts.frontendDir, result.warnings, dataImport);
944
+ await ensureClientDep(opts.frontendDir, result.warnings);
945
+ // religa os componentes ao CMS (texto hardcoded → conteúdo do Strapi por locale)
946
+ try {
947
+ const rewired = await rewireComponents(
948
+ strapi,
949
+ { frontendDir: opts.frontendDir, sample, cts: ctMeta, apiKey, dataImport },
950
+ result.warnings
951
+ );
952
+ if (rewired.length) result.filesRewritten.push(...rewired);
953
+ } catch (e: any) {
954
+ result.warnings.push(`rewire: ${e?.message ?? e}`);
955
+ }
956
+ } catch (e: any) {
957
+ result.warnings.push(`wiring: ${e?.message ?? e}`);
958
+ }
959
+ }
960
+
961
+ result.ok = result.filesRewritten.length > 0;
962
+ return result;
963
+ }