primocode 9.8.0-beta.4 → 9.8.0-beta.6
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/README.md +15 -4
- package/app/ansi.js +55 -0
- package/app/ide.css +345 -0
- package/app/ide.html +183 -0
- package/app/ide.js +639 -0
- package/bin/primocode.js +315 -83
- package/kits/acolhedor/index.html +130 -0
- package/kits/acolhedor/script.js +25 -0
- package/kits/acolhedor/style.css +146 -0
- package/kits/editorial/index.html +115 -0
- package/kits/editorial/script.js +19 -0
- package/kits/editorial/style.css +112 -0
- package/kits/premium/index.html +83 -0
- package/kits/premium/script.js +11 -0
- package/kits/premium/style.css +87 -0
- package/kits/vitrine/index.html +108 -0
- package/kits/vitrine/script.js +20 -0
- package/kits/vitrine/style.css +117 -0
- package/lib/app.js +41 -6
- package/lib/browser.js +14 -1
- package/lib/checkpoint-git.js +218 -0
- package/lib/checkpoint.js +159 -44
- package/lib/comandos-extra.js +481 -0
- package/lib/config.js +5 -2
- package/lib/ide.js +337 -0
- package/lib/kits.js +165 -54
- package/lib/skills.js +6 -1
- package/lib/ui.js +171 -17
- package/package.json +2 -2
- package/skills/apresentacao-pptx/SKILL.md +52 -0
- package/skills/cartaz-de-arte/LICENSE.txt +202 -0
- package/skills/cartaz-de-arte/SKILL.md +47 -0
- package/skills/criar-skill/LICENSE.txt +202 -0
- package/skills/criar-skill/SKILL.md +49 -0
- package/skills/depurar-de-verdade/LICENSE.txt +21 -0
- package/skills/depurar-de-verdade/SKILL.md +14 -5
- package/skills/documento-word/SKILL.md +57 -0
- package/skills/interface-revisada/SKILL.md +47 -0
- package/skills/me-ensina/LICENSE.txt +21 -0
- package/skills/me-ensina/SKILL.md +27 -0
- package/skills/me-entrevista/LICENSE.txt +21 -0
- package/skills/me-entrevista/SKILL.md +27 -0
- package/skills/pdf-bonito/SKILL.md +48 -0
- package/skills/planilha-excel/SKILL.md +47 -0
- package/skills/react-sem-dor/SKILL.md +48 -0
- package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
- package/skills/temas-prontos/LICENSE.txt +202 -0
- package/skills/temas-prontos/SKILL.md +50 -0
- package/skills/testar-o-site/LICENSE.txt +202 -0
- package/skills/testar-o-site/SKILL.md +46 -0
- package/skills/video-no-remotion/SKILL.md +48 -0
package/lib/ide.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ide.js — a PrimoCode IDE: árvore, editor, prévia e chat, na mesma janela.
|
|
3
|
+
*
|
|
4
|
+
* "Um app ao invés de CLI, aí fica uma IDE e uma CLI."
|
|
5
|
+
*
|
|
6
|
+
* ── POR QUE MORA DENTRO DO SERVIDOR DO /rc ───────────────────────────────
|
|
7
|
+
* A janela do /rc já resolveu o difícil: servidor em 127.0.0.1 com chave
|
|
8
|
+
* sorteada, fluxo de eventos (SSE) ao vivo e o espelho da saída do terminal.
|
|
9
|
+
* Uma IDE com servidor próprio seria uma segunda chave, uma segunda porta e um
|
|
10
|
+
* segundo fluxo — e as duas janelas mostrariam agentes que parecem diferentes
|
|
11
|
+
* sendo o mesmo. Aqui só entram as rotas que a IDE precisa a mais (árvore,
|
|
12
|
+
* ler, salvar, prévia) e o vigia da pasta, que empurra eventos pelo MESMO
|
|
13
|
+
* fluxo do /rc.
|
|
14
|
+
*
|
|
15
|
+
* ── SEGURANÇA ────────────────────────────────────────────────────────────
|
|
16
|
+
* Estas rotas leem e ESCREVEM no disco do usuário. Três travas:
|
|
17
|
+
* · a chave do app.js vale aqui também (tudo mora em /api/);
|
|
18
|
+
* · todo caminho é resolvido DENTRO da pasta do projeto — "../", caminho
|
|
19
|
+
* absoluto e link simbólico que aponta para fora são recusados;
|
|
20
|
+
* · a prévia roda num iframe sandbox SEM allow-same-origin, e o servidor
|
|
21
|
+
* manda o mesmo sandbox no cabeçalho. O código da prévia foi escrito pelo
|
|
22
|
+
* agente (ou pela pessoa); se ele herdasse a origem da IDE, poderia chamar
|
|
23
|
+
* /api/enviar e dar ordens ao agente. Com origem opaca, o fetch dele sai
|
|
24
|
+
* com `Origin: null` e o app.js recusa.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
/* O que a árvore não mostra e o vigia não escuta. node_modules sozinho tem
|
|
33
|
+
dezenas de milhares de arquivos: listar travaria a janela, e vigiar faria
|
|
34
|
+
cada `npm install` virar uma avalanche de eventos. */
|
|
35
|
+
const IGNORADOS = new Set(['node_modules', '.git', '.DS_Store', '.next', '.cache',
|
|
36
|
+
'__pycache__', '.venv', 'venv', '.turbo', '.parcel-cache']);
|
|
37
|
+
const MAX_ITENS = 4000;
|
|
38
|
+
const MAX_PROFUNDIDADE = 12;
|
|
39
|
+
// 1 MB: acima disso um <textarea> engasga a cada tecla, e arquivo desse
|
|
40
|
+
// tamanho num projeto de iniciante é quase sempre gerado (bundle, dado).
|
|
41
|
+
const MAX_EDITOR = 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
// ── caminhos ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/* Resolve `relativo` dentro de `raiz`, ou devolve null.
|
|
46
|
+
*
|
|
47
|
+
* `path.relative` pega o "../" e o caminho absoluto; o `realpath` pega o link
|
|
48
|
+
* simbólico, que passa pelo primeiro teste com cara de arquivo de dentro e
|
|
49
|
+
* aponta para ~/.ssh. Arquivo que ainda não existe (salvar um novo) é checado
|
|
50
|
+
* pela pasta onde vai nascer. */
|
|
51
|
+
function dentro(raiz, relativo) {
|
|
52
|
+
if (typeof relativo !== 'string' || relativo.includes('\0')) return null;
|
|
53
|
+
// Absoluto é recusado de cara, e não "reinterpretado" como relativo:
|
|
54
|
+
// quem manda /etc/hosts quer o /etc/hosts, e salvar em <projeto>/etc/hosts
|
|
55
|
+
// no lugar seria uma surpresa. `C:\` e `\\servidor` também contam.
|
|
56
|
+
if (path.isAbsolute(relativo) || path.win32.isAbsolute(relativo)) return null;
|
|
57
|
+
const base = path.resolve(raiz);
|
|
58
|
+
const alvo = path.resolve(base, relativo);
|
|
59
|
+
if (!contido(base, alvo)) return null;
|
|
60
|
+
let real = base;
|
|
61
|
+
try { real = fs.realpathSync(base); } catch { return null; }
|
|
62
|
+
let existente = alvo;
|
|
63
|
+
while (!fs.existsSync(existente) && existente !== base) existente = path.dirname(existente);
|
|
64
|
+
try {
|
|
65
|
+
if (!contido(real, fs.realpathSync(existente))) return null;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
return alvo;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function contido(base, alvo) {
|
|
71
|
+
const rel = path.relative(base, alvo);
|
|
72
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function relativoDe(raiz, abs) {
|
|
76
|
+
return path.relative(raiz, abs).split(path.sep).join('/');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ignorado(rel) {
|
|
80
|
+
return rel.split(/[/\\]/).some((p) => IGNORADOS.has(p));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── árvore ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/* Lista plana, pastas antes de arquivos, em ordem alfabética. Plana porque a
|
|
86
|
+
página monta a árvore dela do jeito que quiser, e o teste confere sem ter de
|
|
87
|
+
descer em objetos aninhados. */
|
|
88
|
+
function arvore(raiz) {
|
|
89
|
+
const itens = [];
|
|
90
|
+
let cortada = false;
|
|
91
|
+
function andar(dir, prof) {
|
|
92
|
+
if (prof > MAX_PROFUNDIDADE || cortada) return;
|
|
93
|
+
let entradas;
|
|
94
|
+
try { entradas = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
95
|
+
entradas = entradas.filter((e) => !IGNORADOS.has(e.name));
|
|
96
|
+
entradas.sort((a, b) => (b.isDirectory() - a.isDirectory())
|
|
97
|
+
|| a.name.localeCompare(b.name, 'pt-BR', { sensitivity: 'base' }));
|
|
98
|
+
for (const e of entradas) {
|
|
99
|
+
if (itens.length >= MAX_ITENS) { cortada = true; return; }
|
|
100
|
+
const abs = path.join(dir, e.name);
|
|
101
|
+
const rel = relativoDe(raiz, abs);
|
|
102
|
+
if (e.isDirectory()) {
|
|
103
|
+
itens.push({ caminho: rel, tipo: 'pasta' });
|
|
104
|
+
andar(abs, prof + 1);
|
|
105
|
+
} else if (e.isFile()) {
|
|
106
|
+
let tamanho = 0;
|
|
107
|
+
try { tamanho = fs.statSync(abs).size; } catch {}
|
|
108
|
+
itens.push({ caminho: rel, tipo: 'arquivo', tamanho });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
andar(raiz, 0);
|
|
113
|
+
return { itens, cortada };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── ler e salvar ─────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/* Binário é o que tem byte zero nos primeiros 8 KB — o mesmo teste do git.
|
|
119
|
+
Mostrar um PNG como texto no editor e deixar a pessoa salvar por cima
|
|
120
|
+
destruiria a imagem sem aviso. */
|
|
121
|
+
function ehBinario(buf) {
|
|
122
|
+
const n = Math.min(buf.length, 8000);
|
|
123
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ler(raiz, rel) {
|
|
128
|
+
const abs = dentro(raiz, rel);
|
|
129
|
+
if (!abs) return { status: 403, corpo: { ok: false, error: 'fora da pasta do projeto' } };
|
|
130
|
+
let st;
|
|
131
|
+
try { st = fs.statSync(abs); } catch { return { status: 404, corpo: { ok: false, error: 'não existe' } }; }
|
|
132
|
+
if (!st.isFile()) return { status: 400, corpo: { ok: false, error: 'não é arquivo' } };
|
|
133
|
+
const base = { ok: true, caminho: relativoDe(raiz, abs), tamanho: st.size, mtime: st.mtimeMs };
|
|
134
|
+
if (st.size > MAX_EDITOR) return { status: 200, corpo: Object.assign(base, { grande: true }) };
|
|
135
|
+
const buf = fs.readFileSync(abs);
|
|
136
|
+
if (ehBinario(buf)) return { status: 200, corpo: Object.assign(base, { binario: true }) };
|
|
137
|
+
return { status: 200, corpo: Object.assign(base, { conteudo: buf.toString('utf8') }) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* `mtimeBase` é o mtime que a página viu ao abrir o arquivo. Se o disco está
|
|
141
|
+
mais novo, alguém (quase sempre o agente) mexeu no meio — e salvar por cima
|
|
142
|
+
apagaria o trabalho dele sem ninguém perceber. A página decide: recarregar
|
|
143
|
+
ou mandar de novo com `forcar`. */
|
|
144
|
+
function salvar(raiz, dados) {
|
|
145
|
+
const rel = dados && dados.caminho;
|
|
146
|
+
const conteudo = dados && dados.conteudo;
|
|
147
|
+
if (typeof conteudo !== 'string') return { status: 400, corpo: { ok: false, error: 'conteúdo faltando' } };
|
|
148
|
+
if (Buffer.byteLength(conteudo) > MAX_EDITOR) return { status: 413, corpo: { ok: false, error: 'maior que 1 MB' } };
|
|
149
|
+
const abs = dentro(raiz, rel);
|
|
150
|
+
if (!abs || abs === path.resolve(raiz)) return { status: 403, corpo: { ok: false, error: 'fora da pasta do projeto' } };
|
|
151
|
+
if (ignorado(relativoDe(raiz, abs))) return { status: 403, corpo: { ok: false, error: 'pasta protegida' } };
|
|
152
|
+
let st = null;
|
|
153
|
+
try { st = fs.statSync(abs); } catch { /* arquivo novo */ }
|
|
154
|
+
if (st && !st.isFile()) return { status: 400, corpo: { ok: false, error: 'não é arquivo' } };
|
|
155
|
+
if (st && dados.mtimeBase && !dados.forcar && st.mtimeMs > Number(dados.mtimeBase) + 1) {
|
|
156
|
+
return { status: 409, corpo: { ok: false, conflito: true, error: 'mudou no disco depois que você abriu', mtime: st.mtimeMs } };
|
|
157
|
+
}
|
|
158
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
159
|
+
fs.writeFileSync(abs, conteudo);
|
|
160
|
+
return { status: 200, corpo: { ok: true, caminho: relativoDe(raiz, abs), mtime: fs.statSync(abs).mtimeMs } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── prévia ───────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const TIPOS = {
|
|
166
|
+
'.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
|
|
167
|
+
'.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8',
|
|
168
|
+
'.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
|
169
|
+
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
170
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8',
|
|
171
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
172
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.md': 'text/plain; charset=utf-8',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/* Onde está a página de entrada do projeto. index.html na raiz é o caso de
|
|
176
|
+
quase todo pedido ("crie um site"); os kits de Vite e afins põem em public/
|
|
177
|
+
ou src/. Sem nenhum, a prévia diz isso em vez de mostrar um 404. */
|
|
178
|
+
function entradaDaPrevia(raiz) {
|
|
179
|
+
for (const c of ['index.html', 'public/index.html', 'src/index.html', 'dist/index.html']) {
|
|
180
|
+
if (fs.existsSync(path.join(raiz, c))) return c;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function servirPrevia(raiz, rel, res) {
|
|
186
|
+
const abs = dentro(raiz, decodeURIComponent(rel || '') || 'index.html');
|
|
187
|
+
const alvo = abs && fs.existsSync(abs) && fs.statSync(abs).isDirectory() ? path.join(abs, 'index.html') : abs;
|
|
188
|
+
if (!alvo || !fs.existsSync(alvo)) {
|
|
189
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
190
|
+
return res.end('não encontrado');
|
|
191
|
+
}
|
|
192
|
+
res.writeHead(200, {
|
|
193
|
+
'Content-Type': TIPOS[path.extname(alvo).toLowerCase()] || 'application/octet-stream',
|
|
194
|
+
'Cache-Control': 'no-store',
|
|
195
|
+
// O mesmo sandbox do iframe, dito pelo servidor: vale até se alguém
|
|
196
|
+
// abrir o endereço da prévia fora da IDE.
|
|
197
|
+
'Content-Security-Policy': 'sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock',
|
|
198
|
+
});
|
|
199
|
+
fs.createReadStream(alvo).pipe(res);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── vigia ────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/* fs.watch recursivo existe no Mac e no Windows desde sempre e no Linux desde
|
|
205
|
+
o Node 20. Onde não existe, vigia só a raiz — a prévia de um index.html
|
|
206
|
+
continua acompanhando, que é o que mais importa.
|
|
207
|
+
|
|
208
|
+
Os eventos vêm em rajada (um write vira 2–3 avisos, um `npm run build` vira
|
|
209
|
+
centenas), então juntam-se por 150 ms e saem numa mensagem só. */
|
|
210
|
+
function vigiar(raiz, aoMudar, espera = 150) {
|
|
211
|
+
const pendentes = new Set();
|
|
212
|
+
let timer = null;
|
|
213
|
+
const soltar = () => {
|
|
214
|
+
timer = null;
|
|
215
|
+
const lista = [...pendentes];
|
|
216
|
+
pendentes.clear();
|
|
217
|
+
if (lista.length) aoMudar(lista);
|
|
218
|
+
};
|
|
219
|
+
const anotar = (_tipo, nome) => {
|
|
220
|
+
if (!nome) return;
|
|
221
|
+
const rel = String(nome).split(path.sep).join('/');
|
|
222
|
+
if (ignorado(rel)) return;
|
|
223
|
+
pendentes.add(rel);
|
|
224
|
+
if (!timer) timer = setTimeout(soltar, espera);
|
|
225
|
+
};
|
|
226
|
+
let vigia;
|
|
227
|
+
try { vigia = fs.watch(raiz, { recursive: true }, anotar); } catch {
|
|
228
|
+
try { vigia = fs.watch(raiz, anotar); } catch { return () => {}; }
|
|
229
|
+
}
|
|
230
|
+
vigia.on('error', () => {});
|
|
231
|
+
return function parar() {
|
|
232
|
+
try { vigia.close(); } catch {}
|
|
233
|
+
if (timer) clearTimeout(timer);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── as rotas ─────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Monta a IDE num servidor do app.js.
|
|
241
|
+
*
|
|
242
|
+
* @param {object} op
|
|
243
|
+
* @param {()=>string} op.pasta a pasta do projeto AGORA (o /dir muda ela)
|
|
244
|
+
* @param {(evento:string, dados:any)=>void} op.emitir o fluxo SSE do app.js
|
|
245
|
+
* @returns {{atender:Function, fechar:Function, conferirPasta:Function}}
|
|
246
|
+
*/
|
|
247
|
+
function montar(op) {
|
|
248
|
+
let pastaVigiada = null;
|
|
249
|
+
let pararVigia = () => {};
|
|
250
|
+
// O que a própria IDE acabou de salvar, para a página não pintar como
|
|
251
|
+
// "o agente mexeu" a mudança que a pessoa fez.
|
|
252
|
+
const salvosAgora = new Map();
|
|
253
|
+
|
|
254
|
+
function conferirPasta() {
|
|
255
|
+
const atual = op.pasta && op.pasta();
|
|
256
|
+
if (!atual || atual === pastaVigiada) return atual;
|
|
257
|
+
pararVigia();
|
|
258
|
+
pastaVigiada = atual;
|
|
259
|
+
if (!fs.existsSync(atual)) { pararVigia = () => {}; return atual; }
|
|
260
|
+
pararVigia = vigiar(atual, (mudados) => {
|
|
261
|
+
const agora = Date.now();
|
|
262
|
+
const deVoce = mudados.filter((m) => (salvosAgora.get(m) || 0) > agora - 1500);
|
|
263
|
+
op.emitir('arquivos', { pasta: atual, mudados, deVoce });
|
|
264
|
+
});
|
|
265
|
+
op.emitir('arquivos', { pasta: atual, mudados: [], trocou: true });
|
|
266
|
+
return atual;
|
|
267
|
+
}
|
|
268
|
+
// O /dir troca a pasta pelo terminal, sem passar por aqui: sem esta volta
|
|
269
|
+
// a IDE continuaria vigiando o projeto anterior.
|
|
270
|
+
/* E só depois que a IDE foi aberta de verdade: quem abriu só o chat do
|
|
271
|
+
/rc não precisa de ninguém vigiando o ~/primocode inteiro — no Linux
|
|
272
|
+
o fs.watch recursivo custa um aviso do sistema por pasta. */
|
|
273
|
+
let usada = false;
|
|
274
|
+
const ronda = setInterval(() => { if (usada) conferirPasta(); }, 2000);
|
|
275
|
+
if (ronda.unref) ronda.unref();
|
|
276
|
+
|
|
277
|
+
function json(res, status, corpo) {
|
|
278
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
279
|
+
res.end(JSON.stringify(corpo));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function lerCorpo(req) {
|
|
283
|
+
return new Promise((ok) => {
|
|
284
|
+
let corpo = '';
|
|
285
|
+
let grande = false;
|
|
286
|
+
req.on('data', (c) => { corpo += c; if (corpo.length > MAX_EDITOR * 2) { grande = true; req.destroy(); } });
|
|
287
|
+
req.on('end', () => { try { ok(grande ? null : JSON.parse(corpo)); } catch { ok(null); } });
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Devolve true se a rota era da IDE. A chave já foi conferida pelo
|
|
292
|
+
app.js antes de chegar aqui — só as rotas com /api/ passam por ela, e
|
|
293
|
+
por isso a prévia também mora debaixo de /api/. */
|
|
294
|
+
async function atender(req, res, url) {
|
|
295
|
+
const rota = url.pathname;
|
|
296
|
+
if (!rota.startsWith('/api/ide/')) return false;
|
|
297
|
+
usada = true;
|
|
298
|
+
const raiz = conferirPasta();
|
|
299
|
+
if (!raiz || !fs.existsSync(raiz)) { json(res, 404, { ok: false, error: 'sem pasta de projeto' }); return true; }
|
|
300
|
+
|
|
301
|
+
if (rota === '/api/ide/arvore') {
|
|
302
|
+
const a = arvore(raiz);
|
|
303
|
+
json(res, 200, { ok: true, pasta: raiz, nome: path.basename(raiz), itens: a.itens,
|
|
304
|
+
cortada: a.cortada, entrada: entradaDaPrevia(raiz) });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (rota === '/api/ide/arquivo') {
|
|
308
|
+
const r = ler(raiz, url.searchParams.get('p') || '');
|
|
309
|
+
json(res, r.status, r.corpo);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (rota === '/api/ide/salvar' && req.method === 'POST') {
|
|
313
|
+
const dados = await lerCorpo(req);
|
|
314
|
+
if (!dados) { json(res, 400, { ok: false, error: 'pedido ilegível ou maior que 1 MB' }); return true; }
|
|
315
|
+
const r = salvar(raiz, dados);
|
|
316
|
+
if (r.corpo.ok) salvosAgora.set(r.corpo.caminho, Date.now());
|
|
317
|
+
json(res, r.status, r.corpo);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
/* /api/ide/previa/<chave>/<caminho>: a chave vai no CAMINHO, não na
|
|
321
|
+
query, porque a página da prévia pede style.css e script.js por
|
|
322
|
+
endereço relativo — e o relativo herda o caminho, não a query. */
|
|
323
|
+
const m = rota.match(/^\/api\/ide\/previa\/[0-9a-f]+\/?(.*)$/);
|
|
324
|
+
if (m) { servirPrevia(raiz, m[1], res); return true; }
|
|
325
|
+
|
|
326
|
+
json(res, 404, { ok: false, error: 'rota desconhecida' });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
atender,
|
|
332
|
+
conferirPasta,
|
|
333
|
+
fechar() { clearInterval(ronda); pararVigia(); },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = { montar, dentro, arvore, ler, salvar, vigiar, ehBinario, entradaDaPrevia, MAX_EDITOR };
|
package/lib/kits.js
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* kits.js —
|
|
2
|
+
* kits.js — a IA recebe uma direção visual para se INSPIRAR, e não um molde.
|
|
3
3
|
*
|
|
4
4
|
* "A IA free fez essa página, ficou boa, mas quero que ela seja mais
|
|
5
|
-
* rápida." · "
|
|
6
|
-
*
|
|
5
|
+
* rápida." · "Mas os sites vão ficar todos iguais?" · "Eu quero que tenha
|
|
6
|
+
* modelo pra IA só se inspirar, não pra ela mudar só texto." (24/09/2026)
|
|
7
7
|
*
|
|
8
|
-
* ── A
|
|
9
|
-
*
|
|
10
|
-
* plano grátis (
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* ── A HISTÓRIA, EM TRÊS PASSOS ───────────────────────────────────────────
|
|
9
|
+
* 1. Primeiro o kit era COPIADO para a pasta e o agente só trocava texto e
|
|
10
|
+
* cor: rápido no plano grátis (1–2 mil tokens de saída em vez de ~9 mil),
|
|
11
|
+
* mas todo site saía com o mesmo esqueleto.
|
|
12
|
+
* 2. Viraram cinco direções com estruturas diferentes. Ainda eram moldes.
|
|
13
|
+
* 3. Agora é INSPIRAÇÃO: nada é copiado. O agente recebe a ficha da direção
|
|
14
|
+
* que combina com o negócio (paleta, fontes, layout, o elemento marcante,
|
|
15
|
+
* o que evitar) e escreve a página dele. Custa mais saída que trocar
|
|
16
|
+
* texto — o preço de cada site sair com a cara do seu negócio — e a
|
|
17
|
+
* instrução manda escrever o CSS em partes para cada resposta caber na
|
|
18
|
+
* cota.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* edit_file: ~1–2 mil tokens de saída em vez de 9 mil. Mais rápido E melhor,
|
|
18
|
-
* pela mesma razão — o trabalho difícil já foi feito uma vez, por quem faz
|
|
19
|
-
* bem.
|
|
20
|
-
*
|
|
21
|
-
* Só entra em pasta NOVA (ou vazia): num projeto que já tem página, copiar
|
|
22
|
-
* um kit por cima seria apagar o trabalho da pessoa.
|
|
20
|
+
* As páginas completas de kits/<nome>/ ficam como a prova de que cada
|
|
21
|
+
* direção funciona: foram renderizadas e conferidas, e as fichas saíram delas.
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
24
|
'use strict';
|
|
@@ -35,22 +34,153 @@ const RE_LANDING = /\b(landing|lading|p[áa]gina (de vendas|de captura|inicial|i
|
|
|
35
34
|
// Pedido que não quer a cara do kit: nada de puxar o kit por cima disso.
|
|
36
35
|
const RE_SEM_KIT = /\b(do zero|sem modelo|sem template|sem kit|react|next(\.?js)?|vue|svelte|angular|tailwind|wordpress)\b/i;
|
|
37
36
|
|
|
37
|
+
/* ── INSPIRAÇÃO, NÃO MOLDE ────────────────────────────────────────────────
|
|
38
|
+
* "Eu quero que tenha modelo pra IA só se INSPIRAR, não pra ela mudar só
|
|
39
|
+
* texto." (24/09/2026)
|
|
40
|
+
* Os kits eram copiados para a pasta e o agente só trocava texto e cor — e
|
|
41
|
+
* todo site saía com o mesmo esqueleto. Agora nada é copiado: o agente recebe
|
|
42
|
+
* uma FICHA da direção que combina com o negócio (paleta, fontes, ideia de
|
|
43
|
+
* layout, o elemento marcante, o movimento, o que evitar) e escreve a página
|
|
44
|
+
* DELE. A ficha é curta (~1,5 mil caracteres) para caber na cota grátis. As
|
|
45
|
+
* páginas completas continuam em kits/<nome>/ como a prova de que cada
|
|
46
|
+
* direção funciona — é delas que as fichas foram tiradas. */
|
|
47
|
+
function instrucaoDoKit(kit) {
|
|
48
|
+
return `INSPIRAÇÃO VISUAL (ponto de partida, NÃO é molde): direção "${kit.titulo}".\n${kit.ficha}\n\n`
|
|
49
|
+
+ 'Crie a página DESTE negócio do zero (index.html, style.css e, se precisar, script.js), com o conteúdo '
|
|
50
|
+
+ 'real do pedido. Use a direção acima como inspiração: pode e deve mudar a estrutura, as seções, as cores '
|
|
51
|
+
+ 'e as fontes para caber no negócio — duas páginas feitas com esta ficha não devem sair iguais. Não copie '
|
|
52
|
+
+ 'textos de exemplo e não invente fato que você não sabe. Antes de escrever, decida em 3 linhas o que '
|
|
53
|
+
+ 'será o elemento marcante desta página. Arquivo grande: escreva o CSS em partes (a base primeiro, os '
|
|
54
|
+
+ 'detalhes depois com edit_file), para cada resposta caber.';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Uma família de direções, e não uma só: "mas os sites vão ficar todos
|
|
58
|
+
iguais?" (24/09/2026). Cada uma pede uma ESTRUTURA diferente. */
|
|
38
59
|
const KITS = {
|
|
39
60
|
landing: {
|
|
40
61
|
nome: 'landing',
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
+ '
|
|
44
|
-
+ '
|
|
45
|
-
+ '
|
|
46
|
-
+ '
|
|
47
|
-
+ '
|
|
48
|
-
+ '
|
|
49
|
-
+ '
|
|
50
|
-
+ '
|
|
51
|
-
|
|
62
|
+
titulo: 'escura e tecnológica',
|
|
63
|
+
ficha: '- para: tecnologia, app, startup, barbearia, tatuagem, academia — negócio de noite ou de futuro.\n'
|
|
64
|
+
+ '- paleta: fundo quase preto azulado (#0b0b12), um azul profundo (#1e3fae), um laranja de destaque '
|
|
65
|
+
+ '(#f97316), um ciano de apoio (#1fb4cf).\n'
|
|
66
|
+
+ '- fontes: um sans geométrico e firme para tudo (ex.: Instrument Sans, Space Grotesk, Sora) e, se '
|
|
67
|
+
+ 'quiser, uma serifa só para um detalhe.\n'
|
|
68
|
+
+ '- layout: abertura de tela cheia centrada, com fundo vivo (rede de pontos ou brilho que segue o '
|
|
69
|
+
+ 'mouse, em canvas ou CSS); depois seções escuras largas.\n'
|
|
70
|
+
+ '- marcante: o fundo animado da abertura. O resto fica sóbrio.\n'
|
|
71
|
+
+ '- evite: fade em toda seção, números 01/02/03, rótulo em caixa alta sobre todo título.',
|
|
72
|
+
},
|
|
73
|
+
editorial: {
|
|
74
|
+
nome: 'editorial',
|
|
75
|
+
titulo: 'editorial clara',
|
|
76
|
+
ficha: '- para: advocacia, consultoria, contabilidade, fotografia, curso, mentoria, agência.\n'
|
|
77
|
+
+ '- paleta: papel quase branco (#fcfcfb), tinta quase preta (#141414), UM azul forte (#1432c8) usado '
|
|
78
|
+
+ 'pouco, e um azul bem claro (#e7ebfb) para uma faixa.\n'
|
|
79
|
+
+ '- fontes: um título condensado e pesado (ex.: Archivo condensada, Oswald, Anton) + uma serifa de '
|
|
80
|
+
+ 'leitura para o texto (ex.: Newsreader, Source Serif, Literata).\n'
|
|
81
|
+
+ '- layout: tudo alinhado à ESQUERDA numa grade assimétrica (coluna estreita de rótulo + coluna larga '
|
|
82
|
+
+ 'de conteúdo); serviços em LINHAS separadas por filete, não em cards; um depoimento grande em itálico.\n'
|
|
83
|
+
+ '- marcante: o título da abertura, enorme e apertado, ocupando metade da tela.\n'
|
|
84
|
+
+ '- movimento: quase nenhum. Formulário só com a linha de baixo nos campos.',
|
|
85
|
+
},
|
|
86
|
+
acolhedor: {
|
|
87
|
+
nome: 'acolhedor',
|
|
88
|
+
titulo: 'acolhedora de bairro',
|
|
89
|
+
ficha: '- para: padaria, confeitaria, salão, clínica, dentista, pet, escola, creche — o negócio da esquina.\n'
|
|
90
|
+
+ '- paleta: fundo creme quente (#fff9f2), um verde acolhedor (#2f7a5f), amarelo sol (#ffc247) e um '
|
|
91
|
+
+ 'coral (#f28b74). Troque pelo que combinar com o ramo.\n'
|
|
92
|
+
+ '- fontes: um título redondo e simpático (ex.: Fredoka, Baloo 2, Nunito ExtraBold) + um texto '
|
|
93
|
+
+ 'arredondado e legível (ex.: Nunito, Quicksand).\n'
|
|
94
|
+
+ '- layout: texto à esquerda e, à direita, uma ILUSTRAÇÃO feita só com CSS que lembra o negócio '
|
|
95
|
+
+ '(lojinha com toldo listrado, bolo, patinha, dente) — nada de imagem de fora; quadro de preços com '
|
|
96
|
+
+ 'pontilhado ligando nome e preço; falas de cliente em balões; botão de WhatsApp em destaque.\n'
|
|
97
|
+
+ '- marcante: um selo "Aberto agora, até as 18h" calculado pelo horário real (script.js).\n'
|
|
98
|
+
+ '- tudo arredondado (raios grandes), nada de fundo escuro.',
|
|
99
|
+
},
|
|
100
|
+
vitrine: {
|
|
101
|
+
nome: 'vitrine',
|
|
102
|
+
titulo: 'vitrine colorida',
|
|
103
|
+
ficha: '- para: comida, pizzaria, hamburgueria, açaí, evento, festa, loja, lançamento de produto.\n'
|
|
104
|
+
+ '- paleta: cores chapadas e vivas em BLOCOS: amarelo (#ffd23f), rosa (#ff4f8b), azul elétrico '
|
|
105
|
+
+ '(#2d5bff), preto (#141414). Contorno grosso preto entre os blocos.\n'
|
|
106
|
+
+ '- fontes: um grotesco bem pesado e expressivo para títulos GIGANTES (ex.: Bricolage Grotesque, '
|
|
107
|
+
+ 'Archivo Black, Rubik) — pode ser a mesma família no texto.\n'
|
|
108
|
+
+ '- layout: cartaz de rua. Abertura em um bloco de cor com título que quase estoura a tela; faixa '
|
|
109
|
+
+ 'com quando/onde/preço; MOSAICO de produtos com tamanhos diferentes (um grande, vários pequenos), '
|
|
110
|
+
+ 'cada um com etiqueta de preço inclinada; chamada final enorme ("Bora?").\n'
|
|
111
|
+
+ '- marcante: um selo serrilhado girado ("Só nesta semana") e, se houver data, a contagem em '
|
|
112
|
+
+ 'palavras ("Faltam 12 dias").\n'
|
|
113
|
+
+ '- evite: cards iguais com sombra suave e degradê.',
|
|
114
|
+
},
|
|
115
|
+
premium: {
|
|
116
|
+
nome: 'premium',
|
|
117
|
+
titulo: 'minimalista premium',
|
|
118
|
+
ficha: '- para: arquitetura, joalheria, imobiliária, hotel, spa, estética, vinho — serviço de alto padrão.\n'
|
|
119
|
+
+ '- paleta: fundo pedra clara (#f4f4f1), um verde muito escuro (#1f3a34), um dourado envelhecido '
|
|
120
|
+
+ '(#b8955a) só em fios e detalhes, e um cinza quente (#dcd9d2).\n'
|
|
121
|
+
+ '- fontes: uma serifa de alto contraste, fina e grande, para títulos (ex.: Bodoni Moda, Playfair '
|
|
122
|
+
+ 'Display, Cormorant) + um sans discreto e pequeno para o texto (ex.: Manrope, Inter).\n'
|
|
123
|
+
+ '- layout: tudo CENTRADO, com MUITO espaço vazio; poucas seções e curtas; três frentes de trabalho '
|
|
124
|
+
+ 'lado a lado só com um fio fino em cima; depoimento curto; contato com hora marcada.\n'
|
|
125
|
+
+ '- marcante: UM elemento só na abertura (ex.: um arco de cor escura com um fio dourado e uma luz), '
|
|
126
|
+
+ 'que se mexe uma vez ao abrir. Nada mais se mexe.\n'
|
|
127
|
+
+ '- botões finos, só contorno. Antes de terminar, tire um enfeite.',
|
|
52
128
|
},
|
|
53
129
|
};
|
|
130
|
+
for (const kit of Object.values(KITS)) kit.instrucao = instrucaoDoKit(kit);
|
|
131
|
+
|
|
132
|
+
/* Tira acento e caixa: "Clínica", "clinica" e "CLÍNICA" viram a mesma
|
|
133
|
+
coisa, e o \b do JavaScript volta a funcionar (ele não entende "í"). */
|
|
134
|
+
function normalizar(texto) {
|
|
135
|
+
return String(texto || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Pedido explícito de estilo vence o ramo do negócio: quem diz "estilo
|
|
139
|
+
editorial" para a padaria quer a editorial. */
|
|
140
|
+
const ESTILO_PEDIDO = [
|
|
141
|
+
['editorial', /\b(editorial|estilo (de )?revista|cara de revista|jornal[ií]stic[oa])\b/],
|
|
142
|
+
['acolhedor', /\b(acolhedor[a]?|aconchegante|fofo|fofa|amigavel|caseir[oa])\b/],
|
|
143
|
+
['vitrine', /\b(vitrine|colorid[oa]|vibrante|chamativ[oa]|divertid[oa]|bold|cartaz)\b/],
|
|
144
|
+
['premium', /\b(premium|minimalista|minimal|sofisticad[oa]|elegante|luxuos[oa]|de luxo|alto padrao)\b/],
|
|
145
|
+
['landing', /\b(escur[oa]|dark|futurista|tecnologic[oa]|neon)\b/],
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/* O ramo do negócio → a direção que combina. Ganha a palavra que aparece
|
|
149
|
+
PRIMEIRO no pedido ("app da padaria" é sobre o app). */
|
|
150
|
+
const RAMO = [
|
|
151
|
+
['acolhedor', /\b(padaria|confeitaria|doceria|salao|cabeleireir[oa]|manicure|clinica|dentista|odonto\w*|consultorio|pet ?shop|pet|veterinari[oa]|escola|creche|bercario|psicolog[oa]|fisioterap\w*|nutricionista|lavanderia|floricultura|mercadinho|costureira|artesanato)\b/],
|
|
152
|
+
['vitrine', /\b(evento|festa|show|festival|comida|lanchonete|lanche|hamburgueria|hamburguer|pizzaria|pizza|sorveteria|acai|food ?truck|delivery|loja|produto|lancamento|promocao|brecho|bar|cervejaria|churrasco|doces?)\b/],
|
|
153
|
+
['editorial', /\b(advocacia|advogad[oa]|consultoria|contabilidade|contador[a]?|jornal|revista|blog|podcast|fotograf[oa]|fotografia|escritor[a]?|livro|palestrante|mentoria|curso|agencia)\b/],
|
|
154
|
+
['premium', /\b(arquitetura|arquitet[oa]|joalheria|joias?|imobiliaria|imoveis|corretor[a]?|interiores|hotel|pousada|spa|vinho|vinicola|estetica|relojoaria|alfaiataria|perfumaria)\b/],
|
|
155
|
+
['landing', /\b(tech|startup|app|aplicativo|software|saas|ia|inteligencia artificial|plataforma|games?|barbearia|tattoo|tatuagem|estudio|academia|crossfit)\b/],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/* Sem pista nenhuma, a escolha RODA pelo texto do pedido: o mesmo pedido cai
|
|
159
|
+
sempre no mesmo kit (dá para reproduzir), mas dois pedidos diferentes não
|
|
160
|
+
caem sempre no mesmo — que era exatamente a reclamação. */
|
|
161
|
+
const ORDEM_RODIZIO = ['editorial', 'acolhedor', 'vitrine', 'premium', 'landing'];
|
|
162
|
+
function hashDoPedido(texto) {
|
|
163
|
+
let h = 5381;
|
|
164
|
+
for (const ch of normalizar(texto).trim()) h = ((h * 33) ^ ch.codePointAt(0)) >>> 0;
|
|
165
|
+
return h;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function primeiraPista(t, tabela) {
|
|
169
|
+
let melhor = null;
|
|
170
|
+
for (const [nome, re] of tabela) {
|
|
171
|
+
const m = re.exec(t);
|
|
172
|
+
if (m && (!melhor || m.index < melhor.pos)) melhor = { nome, pos: m.index };
|
|
173
|
+
}
|
|
174
|
+
return melhor && melhor.nome;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Qual direção visual combina com o pedido (sem as regras de quando usar kit). */
|
|
178
|
+
function direcao(pedido) {
|
|
179
|
+
const t = normalizar(pedido);
|
|
180
|
+
return primeiraPista(t, ESTILO_PEDIDO)
|
|
181
|
+
|| primeiraPista(t, RAMO)
|
|
182
|
+
|| ORDEM_RODIZIO[hashDoPedido(pedido) % ORDEM_RODIZIO.length];
|
|
183
|
+
}
|
|
54
184
|
|
|
55
185
|
/* O kit é para quem NÃO consegue desenhar bem sozinho no tempo da cota. O
|
|
56
186
|
motor forte (Claude Code, Codex, a chave do Claude ou do ChatGPT) desenha do
|
|
@@ -62,34 +192,15 @@ function motorForte(provedorAtual) {
|
|
|
62
192
|
return Boolean(provedorAtual && (provedorAtual.cli || FORTES.has(provedorAtual.id)));
|
|
63
193
|
}
|
|
64
194
|
|
|
65
|
-
/** Qual kit este pedido quer, ou null. */
|
|
195
|
+
/** Qual kit este pedido quer, ou null. A direção sai do negócio (ou do estilo pedido). */
|
|
66
196
|
function escolher(pedido, { provedor = null } = {}) {
|
|
67
|
-
|
|
197
|
+
// A ficha é inspiração, não molde: vale para qualquer motor. O forte usa
|
|
198
|
+
// de partida junto da skill design-de-verdade; o grátis ganha uma direção
|
|
199
|
+
// em vez de inventar do zero. (`provedor` fica para quem quiser distinguir.)
|
|
200
|
+
void provedor;
|
|
68
201
|
const t = String(pedido || '');
|
|
69
202
|
if (!RE_LANDING.test(t) || RE_SEM_KIT.test(t)) return null;
|
|
70
|
-
return KITS
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** A pasta está livre para receber o kit? (não existe, ou só tem memory.md) */
|
|
74
|
-
function pastaLivre(dir) {
|
|
75
|
-
try {
|
|
76
|
-
const tem = fs.readdirSync(dir).filter((n) => !n.startsWith('.') && n !== 'memory.md');
|
|
77
|
-
return tem.length === 0;
|
|
78
|
-
} catch { return true; }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Copia o kit para `dir`. Devolve os arquivos copiados, ou [] se não copiou. */
|
|
82
|
-
function aplicar(kit, dir) {
|
|
83
|
-
if (!kit || !pastaLivre(dir)) return [];
|
|
84
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
-
const feitos = [];
|
|
86
|
-
for (const nome of kit.arquivos) {
|
|
87
|
-
const destino = path.join(dir, nome);
|
|
88
|
-
if (fs.existsSync(destino)) continue;
|
|
89
|
-
fs.copyFileSync(path.join(PASTA_KITS, kit.nome, nome), destino);
|
|
90
|
-
feitos.push(nome);
|
|
91
|
-
}
|
|
92
|
-
return feitos;
|
|
203
|
+
return KITS[direcao(t)];
|
|
93
204
|
}
|
|
94
205
|
|
|
95
|
-
module.exports = { KITS, PASTA_KITS, escolher,
|
|
206
|
+
module.exports = { KITS, PASTA_KITS, escolher, direcao, normalizar, motorForte, instrucaoDoKit };
|
package/lib/skills.js
CHANGED
|
@@ -345,9 +345,14 @@ const semAcento = (v) => String(v || '').toLowerCase()
|
|
|
345
345
|
function pontuar(skill, texto) {
|
|
346
346
|
const t = semAcento(texto);
|
|
347
347
|
let pontos = 0;
|
|
348
|
+
// "página, pagina" no gatilho é UMA palavra escrita de dois jeitos: sem
|
|
349
|
+
// acento as duas são iguais e contavam dois pontos, e a skill que listava
|
|
350
|
+
// as duas grafias ganhava das outras só por isso.
|
|
351
|
+
const vistas = new Set();
|
|
348
352
|
for (const palavra of skill.quando) {
|
|
349
353
|
const p = semAcento(palavra).trim();
|
|
350
|
-
if (!p) continue;
|
|
354
|
+
if (!p || vistas.has(p)) continue;
|
|
355
|
+
vistas.add(p);
|
|
351
356
|
const escapado = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
352
357
|
// A borda e escrita a mao em vez de `\b`: o gatilho pode comecar ou
|
|
353
358
|
// terminar em simbolo ("e-mail"), e ali o `\b` nao vale.
|