@aksp/opencrew 1.5.0 → 1.6.1

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 (32) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +35 -14
  3. package/package.json +1 -1
  4. package/src/cli.js +2 -1
  5. package/src/commands/init.js +42 -30
  6. package/src/commands/update.js +58 -42
  7. package/src/lib/ides.js +15 -12
  8. package/src/lib/manifest.js +89 -0
  9. package/src/lib/migrations.js +147 -0
  10. package/templates/.mcp.json +1 -1
  11. package/templates/AGENTS.md +2 -1
  12. package/templates/_opencrew/.opencrew-version +1 -1
  13. package/templates/_opencrew/core/prompts/build.prompt.md +14 -0
  14. package/templates/_opencrew/core/prompts/discovery.prompt.md +15 -2
  15. package/templates/_opencrew/core/runner.pipeline.md +86 -20
  16. package/templates/_opencrew/core/scripts/comum.mjs +48 -0
  17. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +82 -0
  18. package/templates/_opencrew/core/scripts/conferir-fontes/coleta.mjs +104 -0
  19. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +52 -0
  20. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +135 -0
  21. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +33 -0
  22. package/templates/_opencrew/core/scripts/verificar/arquivos.mjs +50 -0
  23. package/templates/_opencrew/core/scripts/verificar/html.mjs +52 -0
  24. package/templates/_opencrew/core/scripts/verificar/leitura.mjs +138 -66
  25. package/templates/_opencrew/core/scripts/verificar/medicao.mjs +142 -0
  26. package/templates/_opencrew/core/scripts/verificar/pecas.mjs +190 -0
  27. package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +100 -0
  28. package/templates/_opencrew/core/scripts/verificar/regras.mjs +110 -91
  29. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +43 -0
  30. package/templates/_opencrew/core/scripts/verificar/secoes.mjs +145 -0
  31. package/templates/_opencrew/core/scripts/verificar.mjs +162 -88
  32. package/templates/skills/opencrew-best-practice-creator/SKILL.md +4 -4
@@ -0,0 +1,104 @@
1
+ // Coleta da conferência de fontes: os caminhos que a crew cita. Lê o crew.yaml, os passos
2
+ // (pipeline/steps/*.md) e todos os .md de agents/, em qualquer nível (agentes e tasks).
3
+ // Spec: specs/fase-r1-reparos-1-6-1.md, regras 15 e 16 (repositório do OpenCrew).
4
+ import { readFile, readdir } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ // Marcador de modelo: nome a preencher, não arquivo (R1-06j). Pela ordem: ano (`AAAA-MM-DD`,
9
+ // `YYYY`); par de data ou hora sem letra nem dígito em volta (`DD-MM`, `MM-AA`, `HHMM`); número de
10
+ // ordem (`slide-NN.png`, só em maiúsculas); reticências no lugar de uma pasta. Ficam de fora, para
11
+ // não esconder fonte de verdade: `MM`, `DD` e `AA` sozinhos, `XX` e `[…]`.
12
+ const MARCADORES = [
13
+ /AAAA|YYYY/i,
14
+ /(?<![A-Za-z0-9])(?:DD[-_.]?MM|MM[-_.]?DD|MM[-_.]?(?:AA|YY)|(?:AA|YY)[-_.]?MM|HH[-_.h]?MM)(?![A-Za-z0-9])/i,
15
+ /(?<![A-Za-z0-9])NN(?![A-Za-z0-9])/,
16
+ /(^|[\\/])(\.\.\.|…)[\\/]/,
17
+ ];
18
+ // Comando que termina em nome de arquivo (`node scripts/render.js`) não é caminho.
19
+ const COMANDO = /^(?:node|npx|npm|pnpm|yarn|python3?|pip|git|bash|sh|ls|cd|cat|cp|mv|mkdir|curl)\s/;
20
+
21
+ function pareceCaminho(t) {
22
+ if (/[{}<>*$|]/.test(t) || /^https?:/i.test(t) || !/[\\/]/.test(t)) return false;
23
+ if (COMANDO.test(t) || MARCADORES.some((m) => m.test(t))) return false;
24
+ return /\.[A-Za-z0-9]{1,5}$/.test(t) || /[\\/]$/.test(t);
25
+ }
26
+ const saidaDeRun = (t) => /(^|[\\/])(output|_build)[\\/]/.test(t);
27
+
28
+ const ENTRE_CRASES = /`([^`\n]+)`/g;
29
+ // Só espaço e tab antes de `caminho:`: `\s` atravessa linhas e fica lento com muitas em branco.
30
+ const LINHA_DE_CAMINHO = /^([ \t]*(?:-[ \t]*)?caminho:[ \t]*)(.*)$/gm;
31
+ // Linha `- **Writes to**: …` do formato do agente: o que a crew grava (destino), não o que lê.
32
+ const LINHA_DE_DESTINO = /^[ \t]*[-*][ \t]*\*\*Writes to:?\*\*.*$/gim;
33
+
34
+ /** Caminhos de arquivo citados entre crases (saída de run não é fonte). */
35
+ function entreCrases(texto) {
36
+ const citados = [...texto.matchAll(ENTRE_CRASES)].map((m) => m[1].trim());
37
+ return citados.filter((t) => pareceCaminho(t) && !saidaDeRun(t));
38
+ }
39
+
40
+ /** Valor de `caminho:` — aceita aspas simples ou duplas e comentário `# …` no fim da linha. */
41
+ function valorDeCaminho(resto) {
42
+ const entreAspas = resto.match(/^(["'])(.*?)\1\s*(?:#.*)?$/);
43
+ if (entreAspas) return entreAspas[2].trim();
44
+ return resto.replace(/(^|\s)#.*$/, '').trim();
45
+ }
46
+
47
+ /** Os `caminho:` declarados em `fontes:` no crew.yaml. */
48
+ function fontesDeclaradas(texto) {
49
+ const linhas = [...texto.matchAll(LINHA_DE_CAMINHO)];
50
+ return linhas.map((m) => valorDeCaminho(m[2].trim())).filter(Boolean);
51
+ }
52
+
53
+ /**
54
+ * Troca `ref` por `sugestao` só onde a coleta o leu: o texto inteiro de um par de crases e, no
55
+ * crew.yaml, o valor de uma linha `caminho:` (aspas e comentário ficam). O resto do arquivo —
56
+ * outro caminho que contém o citado, frontmatter, texto fora de crases — não muda.
57
+ */
58
+ export function trocarCitacao(texto, { ref, sugestao }, noCrewYaml) {
59
+ const trocar = (trecho) => trecho.replace(ref, () => sugestao);
60
+ const novo = texto.replace(ENTRE_CRASES, (tudo, dentro) => (dentro.trim() === ref ? trocar(tudo) : tudo));
61
+ if (!noCrewYaml) return novo;
62
+ const naLinha = (tudo, chave, resto) => (valorDeCaminho(resto.trim()) === ref ? chave + trocar(resto) : tudo);
63
+ return novo.replace(LINHA_DE_CAMINHO, naLinha);
64
+ }
65
+
66
+ /** Os .md de uma pasta; com `fundo`, também os das subpastas (caminhada própria, Node 20.0). */
67
+ async function arquivosMd(dir, fundo) {
68
+ let entradas;
69
+ try { entradas = await readdir(dir, { withFileTypes: true }); } catch { return []; }
70
+ const achados = [];
71
+ for (const e of entradas) {
72
+ const abs = path.join(dir, e.name);
73
+ if (!e.isDirectory() && e.name.endsWith('.md')) achados.push(abs);
74
+ if (e.isDirectory() && fundo) achados.push(...await arquivosMd(abs, true));
75
+ }
76
+ return achados;
77
+ }
78
+
79
+ /**
80
+ * Caminho citado → { arquivos: os arquivos da crew em que ele aparece, destino: true quando só é
81
+ * citado em linha `Writes to` }.
82
+ */
83
+ export async function coletar(raiz, crew) {
84
+ const base = path.resolve(raiz, crew);
85
+ const crewYaml = path.join(base, 'crew.yaml');
86
+ const arquivos = [
87
+ ...(existsSync(crewYaml) ? [crewYaml] : []),
88
+ ...await arquivosMd(path.join(base, 'pipeline', 'steps'), false),
89
+ ...await arquivosMd(path.join(base, 'agents'), true),
90
+ ];
91
+ const refs = new Map();
92
+ const anotar = (ref, arquivo, destino) => {
93
+ if (!refs.has(ref)) refs.set(ref, { arquivos: new Set(), destino: true });
94
+ refs.get(ref).arquivos.add(arquivo);
95
+ refs.get(ref).destino &&= destino;
96
+ };
97
+ for (const arquivo of arquivos) {
98
+ const texto = await readFile(arquivo, 'utf8');
99
+ const lidos = new Set(entreCrases(texto.replace(LINHA_DE_DESTINO, '')));
100
+ for (const ref of entreCrases(texto)) anotar(ref, arquivo, !lidos.has(ref));
101
+ if (arquivo === crewYaml) for (const ref of fontesDeclaradas(texto)) anotar(ref, arquivo, false);
102
+ }
103
+ return refs;
104
+ }
@@ -0,0 +1,52 @@
1
+ // Relatório da conferência de fontes e as mensagens ao usuário (PT-BR).
2
+ // Spec: specs/fase-r1-reparos-1-6-1.md, regra 17 e seção 6 (repositório do OpenCrew).
3
+ import path from 'node:path';
4
+ import { barra } from './busca.mjs';
5
+
6
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
7
+
8
+ export const MSG = {
9
+ nadaACorrigir: 'Nada a corrigir.',
10
+ corrigidos: (n) => `${plural(n, 'caminho corrigido', 'caminhos corrigidos')} (cópia .bak ao lado de cada arquivo alterado).\n`,
11
+ semCorrecaoAutomatica: (n) => `Não há correção automática para ${n} pendência(s): escolha um candidato ou corrija o caminho na crew.`,
12
+ buscaParcial: (limite) => `Procurei só nos primeiros ${limite} itens do projeto; pode existir um arquivo com esse nome que eu não vi.`,
13
+ };
14
+
15
+ const MAX_NOMES = 20;
16
+ const comCrases = (lista) => lista.map((c) => `\`${c}\``);
17
+
18
+ /** Os primeiros nomes e, se sobram, quantos: uma pasta grande não pode encher a conversa. */
19
+ function resumida(nomes) {
20
+ if (nomes.length <= MAX_NOMES) return nomes.join(', ');
21
+ return [...nomes.slice(0, MAX_NOMES), `… e mais ${nomes.length - MAX_NOMES}`].join(', ');
22
+ }
23
+
24
+ /** Linha de um caminho que não existe. `aviso` é a frase de busca parcial, ou ''. */
25
+ function linhaDaPendencia(i, onde, aviso) {
26
+ const inicio = `- ❌ Não encontrei \`${i.ref}\` (${onde})`;
27
+ const fim = aviso ? `. ${aviso}` : '';
28
+ const achados = plural(i.candidatos.length, 'candidato', 'candidatos');
29
+ if (i.sugestao) return `${inicio}. Novo caminho sugerido: \`${i.sugestao}\`${fim}`;
30
+ if (i.candidatos.length) return `${inicio}. Encontrei ${achados}: ${resumida(comCrases(i.candidatos))}${fim}`;
31
+ if (i.pasta.length) return `${inicio}. Na pasta esperada existem: ${resumida(i.pasta)}${fim}`;
32
+ return aviso ? `${inicio}${fim}` : `${inicio} nem nada com esse nome no projeto.`;
33
+ }
34
+
35
+ function linhaDoAlerta(i, onde) {
36
+ const sugestao = i.sugestao ? ` Sugestão: \`${i.sugestao}\`` : '';
37
+ return `- ⚠️ \`${i.ref}\` é um caminho absoluto (não é portátil — quebra em outro computador).${sugestao} (${onde})`;
38
+ }
39
+
40
+ export function formatar(r) {
41
+ const aviso = r.buscaParcial ? MSG.buscaParcial(r.limite) : '';
42
+ const contar = (estado) => r.refs.filter((i) => i.estado === estado).length;
43
+ const apontados = r.refs.filter((x) => x.estado !== 'ok');
44
+ const linhas = [`## Conferência de fontes — ${r.crew}`, ''];
45
+ for (const i of apontados) {
46
+ const onde = `citado em ${i.citadoEm.map((a) => barra(path.relative(r.raiz, a))).join(', ')}`;
47
+ linhas.push(i.estado === 'nao-portatil' ? linhaDoAlerta(i, onde) : linhaDaPendencia(i, onde, aviso));
48
+ }
49
+ if (apontados.length) linhas.push(''); // sem pendência nem alerta, uma linha em branco só
50
+ linhas.push(`**Resumo: ${r.refs.length} fontes — ${contar('ok')} ok, ${contar('faltando')} pendentes, ${contar('nao-portatil')} alertas**`, '');
51
+ return linhas.join('\n');
52
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // Conferência de fontes do OpenCrew — no início do run, confere se os arquivos que a crew cita
3
+ // existem. Se foram movidos, sugere o novo caminho (relativo à raiz do projeto); se o nome mudou,
4
+ // lista o que existe na pasta esperada. Nunca apaga nada; só corrige com --corrigir (e .bak).
5
+ // Uso: node _opencrew/core/scripts/conferir-fontes.mjs --crew crews/<nome> [--corrigir]
6
+ // Rode a partir da pasta do projeto (a que tem `_opencrew/`); a crew fica dentro dela.
7
+ // Última linha da saída: FONTES:OK ou FONTES:PENDENTE (o runner lê esta linha).
8
+ // Código de saída: 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso (opção faltando, pasta sem
9
+ // `_opencrew/`, crew fora do projeto ou inexistente), sem linha FONTES: e sem escrever nada.
10
+ // Specs: specs/fase-u2-crew-que-conhece-o-projeto.md e specs/fase-r1-reparos-1-6-1.md
11
+ // (repositório do OpenCrew). Módulos em conferir-fontes/: coleta, busca e relatório.
12
+ import { readFile, writeFile, copyFile } from 'node:fs/promises';
13
+ import { existsSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { erroDeUso, dentroDoProjeto, ehPrincipal } from './comum.mjs';
16
+ import { coletar, trocarCitacao } from './conferir-fontes/coleta.mjs';
17
+ import {
18
+ LIMITE_DA_BUSCA, barra, ehAbsoluto, temBarraFinal, resolver, indexar, candidatosPorNome, nomesDaPastaEsperada,
19
+ } from './conferir-fontes/busca.mjs';
20
+ import { formatar, MSG } from './conferir-fontes/relatorio.mjs';
21
+
22
+ export { formatar };
23
+
24
+ /**
25
+ * Caminho absoluto que existe dentro do projeto: o mesmo caminho, relativo à raiz. A própria raiz
26
+ * não tem caminho relativo a sugerir.
27
+ */
28
+ function sugestaoRelativa(raiz, ref, achado) {
29
+ const relativo = dentroDoProjeto(raiz, achado) ? barra(path.relative(raiz, achado)) : '';
30
+ return relativo ? relativo + (temBarraFinal(ref) ? '/' : '') : null;
31
+ }
32
+
33
+ /**
34
+ * Caminho que não existe: candidatos com o mesmo nome ou, sem nenhum, o que há na pasta esperada.
35
+ * Destino (só citado em linha `Writes to`) não ganha sugestão: com a troca, a crew gravaria por
36
+ * cima do arquivo achado. O candidato é listado e a escolha fica com o usuário. Busca que parou
37
+ * no limite também não sugere: o único candidato visto pode não ser o único que existe.
38
+ */
39
+ async function procurar(item, { raiz, crew, indice, destino }) {
40
+ item.estado = 'faltando';
41
+ item.candidatos = candidatosPorNome(indice, item.ref);
42
+ if (item.candidatos.length === 1 && !destino && !indice.parcial) item.sugestao = item.candidatos[0];
43
+ if (!item.candidatos.length) item.pasta = await nomesDaPastaEsperada(raiz, crew, item.ref);
44
+ }
45
+
46
+ /**
47
+ * Confere os caminhos que a crew cita. `limite` é o máximo de itens do projeto vistos na busca
48
+ * por nome; quando a busca para nele, `buscaParcial` é true.
49
+ */
50
+ export async function conferir({ raiz, crew, limite = LIMITE_DA_BUSCA }) {
51
+ let indice = null;
52
+ const refs = [];
53
+ for (const [ref, { arquivos, destino }] of await coletar(raiz, crew)) {
54
+ const item = { ref, citadoEm: [...arquivos], estado: 'ok', sugestao: null, candidatos: [], pasta: [] };
55
+ const achado = resolver(raiz, crew, ref, item.citadoEm);
56
+ if (!achado) {
57
+ indice ??= await indexar(raiz, limite);
58
+ await procurar(item, { raiz, crew, indice, destino });
59
+ } else if (ehAbsoluto(ref)) {
60
+ item.estado = 'nao-portatil';
61
+ item.sugestao = sugestaoRelativa(raiz, ref, achado);
62
+ }
63
+ refs.push(item);
64
+ }
65
+ const status = refs.some((i) => i.estado === 'faltando') ? 'PENDENTE' : 'OK';
66
+ return { crew, raiz, refs, status, buscaParcial: Boolean(indice?.parcial), limite };
67
+ }
68
+
69
+ async function copiaDeSeguranca(arquivo) {
70
+ const bak = existsSync(`${arquivo}.bak`) ? `${arquivo}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}` : `${arquivo}.bak`;
71
+ await copyFile(arquivo, bak);
72
+ }
73
+
74
+ /**
75
+ * Troca, nos arquivos da crew, cada caminho com sugestão única — só a citação que a coleta leu,
76
+ * nunca um pedaço de outro texto. @returns quantos caminhos
77
+ */
78
+ export async function corrigir({ resultado }) {
79
+ const comSugestao = resultado.refs.filter((i) => i.sugestao && i.estado !== 'ok');
80
+ const tocados = new Set();
81
+ for (const item of comSugestao) {
82
+ for (const arquivo of item.citadoEm) {
83
+ const texto = await readFile(arquivo, 'utf8');
84
+ if (!tocados.has(arquivo)) {
85
+ await copiaDeSeguranca(arquivo);
86
+ tocados.add(arquivo);
87
+ }
88
+ await writeFile(arquivo, trocarCitacao(texto, item, path.basename(arquivo) === 'crew.yaml'));
89
+ }
90
+ }
91
+ return comSugestao.length;
92
+ }
93
+
94
+ /** --corrigir: troca o que tem sugestão única e diz quantas pendências ficam sem correção. */
95
+ async function corrigirEAvisar(r, escrever) {
96
+ const n = await corrigir({ resultado: r });
97
+ let atual = r;
98
+ if (n) {
99
+ escrever(MSG.corrigidos(n));
100
+ atual = await conferir({ raiz: r.raiz, crew: r.crew });
101
+ escrever(formatar(atual));
102
+ }
103
+ const semSugestao = atual.refs.filter((i) => i.estado === 'faltando' && !i.sugestao).length;
104
+ if (semSugestao) escrever(MSG.semCorrecaoAutomatica(semSugestao));
105
+ else if (!n) escrever(MSG.nadaACorrigir);
106
+ return atual;
107
+ }
108
+
109
+ const USO = 'Uso: node _opencrew/core/scripts/conferir-fontes.mjs --crew crews/<nome> [--corrigir]';
110
+
111
+ function lerCrew(argv) {
112
+ const i = argv.indexOf('--crew');
113
+ const valor = i > -1 ? argv[i + 1] : null;
114
+ return valor && !valor.startsWith('--') ? valor : null;
115
+ }
116
+
117
+ /** @returns {Promise<number>} 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso */
118
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
119
+ const crew = lerCrew(argv);
120
+ const erro = erroDeUso({ raiz: cwd, faltando: crew ? [] : ['--crew'], crew });
121
+ if (erro) {
122
+ escrever(erro);
123
+ if (!crew) escrever(USO);
124
+ return 1;
125
+ }
126
+ let r = await conferir({ raiz: cwd, crew });
127
+ escrever(formatar(r));
128
+ if (argv.includes('--corrigir')) r = await corrigirEAvisar(r, escrever);
129
+ escrever(`FONTES:${r.status}`);
130
+ return 0;
131
+ }
132
+
133
+ if (ehPrincipal(import.meta.url)) {
134
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
135
+ }
@@ -0,0 +1,33 @@
1
+ // Linha de comando do verificador: as opções, os itens `caminho=formato` e a linha de uso.
2
+ // Spec: fase-r1-reparos-1-6-1.md, §3.
3
+
4
+ // Uma linha só, e sem o texto do status: em erro de uso a saída não tem linha de status.
5
+ export const USO = 'Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo "<caminho=formato>[,<caminho=formato>…]" [--formato blog-post|blog-seo]'
6
+ + ' — o "=formato" é opcional; o relatório termina em OK, BLOQUEADA ou AGUARDANDO_USUARIO; código de saída: 0 = verificou, 1 = erro de uso';
7
+
8
+ /**
9
+ * Opções da linha de comando: `{ crew, formato, arquivos }`. `--arquivo` repetido soma à lista, e
10
+ * o que vem solto logo depois da lista (sem `--`) é mais um item dela: nenhum arquivo citado fica
11
+ * sem verificação.
12
+ */
13
+ export function lerArgs(argv) {
14
+ const args = { arquivos: [] };
15
+ let naLista = false;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const opcao = argv[i].match(/^--(crew|arquivo|formato)$/)?.[1];
18
+ if (opcao && i + 1 < argv.length) {
19
+ if (opcao === 'arquivo') args.arquivos.push(argv[++i]);
20
+ else args[opcao] = argv[++i];
21
+ naLista = opcao === 'arquivo';
22
+ } else if (naLista && !argv[i].startsWith('--')) args.arquivos.push(argv[i]);
23
+ else naLista = false;
24
+ }
25
+ return args;
26
+ }
27
+
28
+ /** `caminho=formato` → `{ arquivo, formato }`; "=formato" só vale com minúsculas, dígitos e hífen. */
29
+ export function lerItemDaLista(texto) {
30
+ const igual = texto.lastIndexOf('=');
31
+ const formato = igual > 0 ? texto.slice(igual + 1) : '';
32
+ return /^[a-z0-9-]+$/.test(formato) ? { arquivo: texto.slice(0, igual), formato } : texto;
33
+ }
@@ -0,0 +1,50 @@
1
+ // O que é cada item da lista do verificador: texto, não-texto, pasta, ausente ou texto que não
2
+ // está em UTF-8 (o UTF-16 com a marca de início é lido).
3
+ // Só texto é verificado; em HTML, o texto visível e os links (fase-r1-reparos-1-6-1.md, regra 9).
4
+ import { readFile, stat } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { semBom } from './leitura.mjs';
7
+ import { textoDeHtml } from './html.mjs';
8
+
9
+ const NAO_TEXTO = new Set(
10
+ '.png .jpg .jpeg .gif .webp .bmp .ico .svg .pdf .doc .docx .xls .xlsx .ods .pptx .mp3 .wav .ogg .mp4 .mov .webm .zip .css .js .json'.split(' '),
11
+ );
12
+ const HTML = new Set(['.html', '.htm']);
13
+ const COM_PECAS = new Set(['.md', '.txt']);
14
+
15
+ async function tipoNoDisco(caminho) {
16
+ try {
17
+ return (await stat(caminho)).isDirectory() ? 'pasta' : 'arquivo';
18
+ } catch (erro) {
19
+ if (erro.code === 'ENOENT' || erro.code === 'ENOTDIR') return 'ausente';
20
+ throw erro;
21
+ }
22
+ }
23
+
24
+ /** Texto de um arquivo que começa pela marca de UTF-16 (FF FE ou FE FF); sem a marca, null. */
25
+ function deUtf16(bytes) {
26
+ const [a, b] = bytes;
27
+ if (!(a === 0xff && b === 0xfe) && !(a === 0xfe && b === 0xff)) return null;
28
+ const pares = bytes.subarray(0, bytes.length - (bytes.length % 2));
29
+ return (a === 0xfe ? pares.swap16() : pares).toString('utf16le');
30
+ }
31
+
32
+ /**
33
+ * Lê um item da lista.
34
+ * @returns {Promise<{ tipo: 'ausente'|'pasta'|'nao-texto'|'nao-utf8'|'texto', texto?: string, comPecas?: boolean }>}
35
+ * `comPecas`: a procura de peças só vale para `.md` e `.txt` · `nao-utf8`: `.md`, `.txt`,
36
+ * `.html` ou `.htm` com byte nulo e sem a marca de UTF-16
37
+ */
38
+ export async function lerItem(caminho) {
39
+ const tipo = await tipoNoDisco(caminho);
40
+ if (tipo !== 'arquivo') return { tipo };
41
+ const ext = path.extname(caminho).toLowerCase();
42
+ if (NAO_TEXTO.has(ext)) return { tipo: 'nao-texto' };
43
+ const bytes = await readFile(caminho);
44
+ const deTexto = COM_PECAS.has(ext) || HTML.has(ext);
45
+ const utf16 = deTexto ? deUtf16(bytes) : null;
46
+ // Byte nulo é conteúdo binário; em arquivo de texto, é outra codificação.
47
+ if (utf16 == null && bytes.includes(0)) return { tipo: deTexto ? 'nao-utf8' : 'nao-texto' };
48
+ const texto = semBom(utf16 ?? bytes.toString('utf8'));
49
+ return { tipo: 'texto', texto: HTML.has(ext) ? textoDeHtml(texto) : texto, comPecas: COM_PECAS.has(ext) };
50
+ }
@@ -0,0 +1,52 @@
1
+ // Texto de um HTML para as checagens gerais: o texto visível e os valores de `href`, `src` e
2
+ // `alt`. Ficam de fora `<style>`, `<script>`, os comentários e o resto das tags.
3
+ // Uma passada só pelo arquivo, de `<` em `<`: nada aqui recomeça a busca do início.
4
+
5
+ const ATRIBUTO = /(?:^|\s)(href|src|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
6
+ const DE_BLOCO = /^\/?(?:p|div|br|hr|li|ul|ol|h[1-6]|tr|td|th|table|section|article|header|footer|main|nav|aside|title|blockquote|figure|figcaption|body|head|html)\b/i;
7
+ const ENTIDADES = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
8
+
9
+ /** Posição logo depois de `marca`, procurada a partir de `de`; o fim do texto se não houver. */
10
+ function depoisDe(html, marca, de) {
11
+ marca.lastIndex = de;
12
+ const m = marca.exec(html);
13
+ return m ? m.index + m[0].length : html.length;
14
+ }
15
+
16
+ /** Consome o que começa no `<` da posição `abre` e devolve a posição seguinte. */
17
+ function consumir(html, abre, saida) {
18
+ if (html.startsWith('<!--', abre)) return depoisDe(html, /-->/g, abre + 4);
19
+ const ehTag = /[a-z/!?]/i.test(html[abre + 1] ?? '');
20
+ const fecha = ehTag ? html.indexOf('>', abre) : -1;
21
+ if (fecha < 0) {
22
+ // "<" solto é texto; tag sem nenhum ">" adiante: o resto todo é texto.
23
+ const ate = ehTag ? html.length : abre + 1;
24
+ saida.visivel.push(html.slice(abre, ate));
25
+ return ate;
26
+ }
27
+ const tag = html.slice(abre + 1, fecha);
28
+ for (const m of tag.matchAll(ATRIBUTO)) {
29
+ const valor = m[2] ?? m[3] ?? m[4];
30
+ // `src="data:…"` é o conteúdo de um arquivo embutido, não um link: fica de fora.
31
+ if (m[1].toLowerCase() === 'alt' || !/^\s*data:/i.test(valor)) saida.valores.push(valor);
32
+ }
33
+ if (DE_BLOCO.test(tag)) saida.visivel.push('\n');
34
+ // Só `<script>` e `<style>`: `<script-x>` e `<style-guia>` são tags comuns.
35
+ const invisivel = tag.match(/^(script|style)(?=[\s/]|$)/i);
36
+ return invisivel ? depoisDe(html, new RegExp(`</${invisivel[1]}\\s*>`, 'gi'), fecha + 1) : fecha + 1;
37
+ }
38
+
39
+ /** Texto visível do HTML, seguido dos valores de `href`, `src` e `alt` (um por linha). */
40
+ export function textoDeHtml(html) {
41
+ const saida = { visivel: [], valores: [] };
42
+ let pos = 0;
43
+ while (pos < html.length) {
44
+ const abre = html.indexOf('<', pos);
45
+ if (abre < 0) break;
46
+ saida.visivel.push(html.slice(pos, abre));
47
+ pos = consumir(html, abre, saida);
48
+ }
49
+ saida.visivel.push(html.slice(pos));
50
+ const texto = [saida.visivel.join(''), ...saida.valores].join('\n');
51
+ return texto.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (entidade) => ENTIDADES[entidade]);
52
+ }