@aksp/opencrew 1.7.0 → 1.8.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 (29) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +52 -2
  3. package/package.json +1 -1
  4. package/templates/AGENTS.md +3 -0
  5. package/templates/_opencrew/.opencrew-version +1 -1
  6. package/templates/_opencrew/core/architect.agent.yaml +1 -1
  7. package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
  8. package/templates/_opencrew/core/prompts/entrega.prompt.md +131 -0
  9. package/templates/_opencrew/core/runner.pipeline.md +91 -98
  10. package/templates/_opencrew/core/scripts/caminho/argumentos.mjs +49 -0
  11. package/templates/_opencrew/core/scripts/caminho/disco.mjs +36 -0
  12. package/templates/_opencrew/core/scripts/caminho/nucleo.mjs +61 -0
  13. package/templates/_opencrew/core/scripts/caminho.mjs +124 -0
  14. package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +46 -0
  15. package/templates/_opencrew/core/scripts/entrega/canais.mjs +51 -0
  16. package/templates/_opencrew/core/scripts/entrega/fora.mjs +49 -0
  17. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +83 -0
  18. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +89 -0
  19. package/templates/_opencrew/core/scripts/entrega/leitor.mjs +120 -0
  20. package/templates/_opencrew/core/scripts/entrega/longas.mjs +109 -0
  21. package/templates/_opencrew/core/scripts/entrega/nomes.mjs +78 -0
  22. package/templates/_opencrew/core/scripts/entrega/passos.mjs +99 -0
  23. package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +87 -0
  24. package/templates/_opencrew/core/scripts/entrega/separar.mjs +103 -0
  25. package/templates/_opencrew/core/scripts/entrega/texto.mjs +95 -0
  26. package/templates/_opencrew/core/scripts/entregar.mjs +141 -0
  27. package/templates/_opencrew/core/scripts/verificar/pecas.mjs +4 -4
  28. package/templates/_opencrew/core/scripts/verificar/secoes.mjs +22 -2
  29. package/templates/_opencrew/core/scripts/verificar.mjs +4 -2
@@ -0,0 +1,46 @@
1
+ // Linha de comando do `entregar.mjs`: as opções, a lista `caminho=formato` e a linha de uso.
2
+ // Spec: fase-u3a1-pasta-de-entrega.md, §3 e §6 (repositório do OpenCrew).
3
+ import { lerItemDaLista } from '../verificar/argumentos.mjs';
4
+
5
+ export const USO = 'Uso: node _opencrew/core/scripts/entregar.mjs --crew "crews/<crew>" --run "<id>" --arquivo "<caminho=formato>[,<caminho=formato>…]" [--vai-publicar <canal>] [--ajuda]';
6
+
7
+ const OPCAO = /^--(crew|run|arquivo|vai-publicar)(?:=(.*))?$/s;
8
+
9
+ /** Texto que veio da linha de comando e volta numa mensagem: uma linha só, até 200 caracteres. */
10
+ export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice(0, 200);
11
+
12
+ function guardar(args, nome, valor) {
13
+ if (nome === 'arquivo') args.arquivos.push(valor);
14
+ else if (nome === 'vai-publicar') args.vaiPublicar.push(valor.trim().toLowerCase());
15
+ else args[nome] = valor;
16
+ }
17
+
18
+ /**
19
+ * `argv` → `{ crew, run, arquivos, vaiPublicar, ajuda, desconhecida }`. Opção vale como
20
+ * `--nome valor` e `--nome=valor`. `--arquivo` e `--vai-publicar` repetidos somam, e o que vem
21
+ * solto logo depois da lista é mais um item dela. `desconhecida`: a primeira opção que o script
22
+ * não conhece, ou null.
23
+ */
24
+ export function lerArgs(argv) {
25
+ const args = { arquivos: [], vaiPublicar: [], ajuda: false, desconhecida: null };
26
+ let naLista = false;
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const [, nome, colado] = argv[i].match(OPCAO) ?? [];
29
+ const solto = !nome && !argv[i].startsWith('--');
30
+ if (nome) guardar(args, nome, colado ?? (i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : ''));
31
+ else if (argv[i] === '--ajuda') args.ajuda = true;
32
+ else if (solto && naLista) args.arquivos.push(argv[i]);
33
+ else args.desconhecida ??= argv[i];
34
+ naLista = nome ? nome === 'arquivo' : solto && naLista;
35
+ }
36
+ return args;
37
+ }
38
+
39
+ /** A lista de `--arquivo` → `[{ arquivo, formato }]` (`formato` null quando não veio `=formato`). */
40
+ export function lerLista(arquivos) {
41
+ const textos = arquivos.join(',').split(',').map((s) => s.trim()).filter(Boolean);
42
+ return textos.map(lerItemDaLista).map((i) => (typeof i === 'string' ? { arquivo: i, formato: null } : i));
43
+ }
44
+
45
+ /** O `--run` é um segmento só: sem `/`, `\` nem `..`. */
46
+ export const runValido = (run) => !/[\\/]/.test(run) && run !== '..' && run !== '.';
@@ -0,0 +1,51 @@
1
+ // Canais da entrega: a pasta de cada formato (o `platform:` do best-practice), os nomes que o
2
+ // LEIA-ME mostra e o que nunca entra numa entrega.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, regras 1 e 3 (repositório do OpenCrew).
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { lerFrontmatter, lerTexto } from '../verificar/leitura.mjs';
7
+
8
+ /** Pasta do canal → nome no LEIA-ME, na ordem em que as seções aparecem. */
9
+ export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube' };
10
+ export const OUTROS = 'outros';
11
+ export const EDITAVEIS = 'editaveis';
12
+ export const ehCanal = (pasta) => Object.hasOwn(CANAIS, pasta);
13
+ /** Como a pasta é chamada numa mensagem: o nome do canal, ou `outros/`. */
14
+ export const nomeDaPasta = (pasta) => CANAIS[pasta] ?? `${pasta}/`;
15
+
16
+ async function plataformaEm(raiz, pasta, formato) {
17
+ const arquivo = path.join(raiz, '_opencrew', ...pasta, `${formato}.md`);
18
+ if (!existsSync(arquivo)) return null;
19
+ const valor = lerFrontmatter(await lerTexto(arquivo))?.platform;
20
+ return typeof valor === 'string' && valor.trim() ? valor.trim().toLowerCase() : null;
21
+ }
22
+
23
+ /**
24
+ * Canal de um formato: o `platform:` de `_opencrew/best-practices.local/<formato>.md`, quando o
25
+ * arquivo o declara; senão, o do core. Sem formato, sem best-practice, sem `platform:` ou com
26
+ * plataforma que não é uma das sete pastas: null (o arquivo vai para `outros/`).
27
+ */
28
+ export async function canalDoFormato(raiz, formato) {
29
+ if (!formato || !/^[a-z0-9-]+$/.test(formato)) return null;
30
+ const local = await plataformaEm(raiz, ['best-practices.local'], formato);
31
+ const plataforma = local ?? (await plataformaEm(raiz, ['core', 'best-practices'], formato));
32
+ return plataforma && ehCanal(plataforma) ? plataforma : null;
33
+ }
34
+
35
+ const PASTAS_DE_SERVICO = new Set(['entrega', 'entrega.tmp', 'export']);
36
+
37
+ /**
38
+ * Arquivo de serviço nunca entra, mesmo listado: `verificacao-*.md`, `publicado.json`, o que está
39
+ * em `entrega/`, `entrega.tmp/` ou `export/` de uma execução e o `caption.txt` direto na pasta
40
+ * da execução (é ali que o publicador o grava; dentro de uma pasta `vN` ele é saída de passo).
41
+ * @param {string} rel caminho relativo ao projeto, com `/`
42
+ */
43
+ export function ehDeServico(rel) {
44
+ const partes = rel.toLowerCase().split('/');
45
+ const nome = partes.at(-1);
46
+ if (/^verificacao-.*\.md$/.test(nome) || nome === 'publicado.json') return true;
47
+ const naSaida = partes[0] === 'crews' && partes[2] === 'output';
48
+ if (!naSaida) return false;
49
+ if (partes.slice(4, -1).some((p) => PASTAS_DE_SERVICO.has(p))) return true;
50
+ return nome === 'caption.txt' && partes.length === 5;
51
+ }
@@ -0,0 +1,49 @@
1
+ // Blocos de rótulo do arquivo de origem que não chegam à entrega: os de serviço (notas, checklist,
2
+ // FORMAT) e, nos formatos lidos pelo leitor de peças, os que não são a peça do formato (os SLIDES
3
+ // de um carrossel, por exemplo). A entrega não muda: o LEIA-ME só passa a dizer o que ficou fora.
4
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 e §6, ajuste da execução real (repositório do OpenCrew).
5
+ import { semFrontmatter } from '../verificar/leitura.mjs';
6
+ import { ROTULOS } from '../verificar/pecas.mjs';
7
+ import { lerTrechos, marcar } from '../verificar/secoes.mjs';
8
+ import { PRINCIPAL } from './leitor.mjs';
9
+ import { ehServico, secoesDeRotulo, semComentarios } from './texto.mjs';
10
+
11
+ export const MSG = {
12
+ fora: (blocos) => `Ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem.`,
13
+ foraNaTela: (arquivo, blocos) => `${arquivo}: ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem.`,
14
+ };
15
+
16
+ const tabelaDe = (formato) => (formato === 'twitter-thread' ? 'twitter-post' : formato);
17
+
18
+ /** O bloco deste rótulo chega à entrega? `formato`: o do leitor de peças, ou null (só o de serviço sai). */
19
+ function entra(rotulo, formato) {
20
+ if (ehServico(rotulo)) return false;
21
+ if (!formato) return true;
22
+ const papel = ROTULOS[tabelaDe(formato)]?.[rotulo];
23
+ return papel === PRINCIPAL[formato] || papel === 'parte' || rotulo === 'HASHTAGS';
24
+ }
25
+
26
+ /** As seções de rótulo como o leitor de peças as vê: cada uma vai até o próximo rótulo ou cabeçalho de peça. */
27
+ function secoesDoLeitor(corpo, formato) {
28
+ const trechos = lerTrechos(marcar(corpo), tabelaDe(formato)).filter((t) => t.rotulo);
29
+ return trechos.map((t) => ({ rotulo: t.rotulo, texto: t.linhas.join('\n').trim() }));
30
+ }
31
+
32
+ /**
33
+ * Rótulos dos blocos com texto que ficam fora da entrega, cada um uma vez, na ordem do arquivo.
34
+ * @param {string} texto o arquivo de origem, sem BOM e com LF
35
+ * @param {string|null} [formato] formato lido pelo leitor de peças (`instagram-feed`,
36
+ * `linkedin-post`, `twitter-post`, `twitter-thread`); null nos outros, em que só o bloco de
37
+ * serviço fica fora
38
+ * @returns {string[]}
39
+ */
40
+ export function blocosFora(texto, formato = null) {
41
+ const corpo = semComentarios(semFrontmatter(texto));
42
+ const secoes = formato ? secoesDoLeitor(corpo, formato) : secoesDeRotulo(corpo);
43
+ return [...new Set(secoes.filter((s) => s.rotulo && s.texto && !entra(s.rotulo, formato)).map((s) => s.rotulo))];
44
+ }
45
+
46
+ /** O aviso de um arquivo: `{ pasta, texto, tela }`, ou nenhum quando nada ficou fora. */
47
+ export function avisoDeFora(item, blocos) {
48
+ return blocos.length ? [{ pasta: item.canal, texto: MSG.fora(blocos), tela: MSG.foraNaTela(item.rel, blocos) }] : [];
49
+ }
@@ -0,0 +1,83 @@
1
+ // Gravação da entrega: montada em `entrega.tmp/`, ao lado, e trocada no fim. Nada pela metade:
2
+ // em qualquer falha a entrega anterior fica como estava e não sobra pasta temporária. O script só
3
+ // apaga a própria `entrega/` e os temporários que ele mesmo criou.
4
+ // Spec: fase-u3a1-pasta-de-entrega.md, regras 12 e 15 (repositório do OpenCrew).
5
+ import { promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const apagar = (pasta) => fs.rm(pasta, { recursive: true, force: true, maxRetries: 3 });
9
+
10
+ /** 'pasta', 'arquivo' ou null (não existe). */
11
+ async function tipoDe(caminho) {
12
+ try {
13
+ return (await fs.stat(caminho)).isDirectory() ? 'pasta' : 'arquivo';
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /** Temporário que sobrou de uma chamada interrompida: a pasta é do script; arquivo com o mesmo nome não é. */
20
+ async function limparSobras({ entrega, tmp, antiga }) {
21
+ if ((await tipoDe(antiga)) === 'pasta') {
22
+ if (await tipoDe(entrega)) await apagar(antiga);
23
+ else await fs.rename(antiga, entrega); // a troca anterior parou no meio: a entrega volta
24
+ }
25
+ if ((await tipoDe(tmp)) === 'pasta') await apagar(tmp);
26
+ }
27
+
28
+ /** Grava um arquivo da entrega na pasta temporária: o texto gerado, ou os mesmos bytes da origem. */
29
+ async function gravarArquivo(destino, a) {
30
+ await fs.mkdir(path.dirname(destino), { recursive: true });
31
+ if (a.texto != null) await fs.writeFile(destino, a.texto, 'utf8');
32
+ else await fs.copyFile(a.de, destino);
33
+ }
34
+
35
+ /** Põe a pasta nova no lugar da anterior; se a nova não entra, a anterior volta. */
36
+ async function trocar({ entrega, tmp, antiga }, passo) {
37
+ const anterior = await tipoDe(entrega);
38
+ passo(entrega);
39
+ if (anterior === 'arquivo') throw new Error('entrega não é uma pasta');
40
+ if (anterior) await fs.rename(entrega, antiga);
41
+ try {
42
+ await fs.rename(tmp, entrega);
43
+ } catch (erro) {
44
+ if (anterior) await fs.rename(antiga, entrega);
45
+ throw erro;
46
+ }
47
+ passo(antiga);
48
+ if (anterior) await apagar(antiga);
49
+ }
50
+
51
+ /**
52
+ * Refaz `entrega/` do zero e grava, ao lado, o relatório da verificação.
53
+ * @param {string} execucao pasta da execução (`crews/<crew>/output/<run>/`), absoluta
54
+ * @param {object[]} arquivos `{ pasta, nome, texto | de }`
55
+ * @param {{ leiame: string, relatorio: string }} textos
56
+ * @returns {Promise<string|null>} null quando gravou tudo; senão, o caminho que não foi gravado
57
+ */
58
+ export async function gravarEntrega(execucao, arquivos, { leiame, relatorio }) {
59
+ const pastas = { entrega: path.join(execucao, 'entrega'), tmp: path.join(execucao, 'entrega.tmp'), antiga: path.join(execucao, 'entrega.antiga.tmp') };
60
+ let alvo = pastas.tmp;
61
+ let criei = false;
62
+ const passo = (caminho) => { alvo = caminho; };
63
+ try {
64
+ await limparSobras(pastas);
65
+ await fs.mkdir(pastas.tmp);
66
+ criei = true;
67
+ for (const a of arquivos) {
68
+ passo(path.join(pastas.tmp, a.pasta, a.nome));
69
+ await gravarArquivo(alvo, a);
70
+ }
71
+ passo(path.join(pastas.tmp, 'LEIA-ME.md'));
72
+ await fs.writeFile(alvo, leiame, 'utf8');
73
+ await trocar(pastas, passo);
74
+ criei = false;
75
+ passo(path.join(execucao, 'verificacao-entrega.md'));
76
+ await fs.writeFile(alvo, relatorio, 'utf8');
77
+ return null;
78
+ } catch {
79
+ if (criei) await apagar(pastas.tmp).catch(() => {});
80
+ // O arquivo que falhou dentro da pasta temporária é citado pelo lugar em que ficaria.
81
+ return alvo.startsWith(pastas.tmp + path.sep) ? path.join(pastas.entrega, alvo.slice(pastas.tmp.length + 1)) : alvo;
82
+ }
83
+ }
@@ -0,0 +1,89 @@
1
+ // O LEIA-ME.md da entrega: títulos fixos, na ordem da spec; seção sem conteúdo não aparece.
2
+ // Arquivo da entrega é citado com caminho relativo à pasta do LEIA-ME; o de origem, relativo ao
3
+ // projeto. Sem hora: as mesmas entradas geram os mesmos bytes. PT-BR fixo.
4
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 e §6 (repositório do OpenCrew).
5
+ import { CANAIS, EDITAVEIS, OUTROS } from './canais.mjs';
6
+ import { frasesDePendencia } from './pendencias.mjs';
7
+ import { passosDe } from './passos.mjs';
8
+
9
+ export const TEXTO = {
10
+ vaiPublicar: 'Esta crew publica este canal sozinha. Antes de postar à mão, confira se já saiu.',
11
+ pdf: 'Abra o arquivo que você quer (por exemplo, o artigo do blog) no navegador ou no editor de texto e use Imprimir → Salvar como PDF.',
12
+ semCanal: 'sem canal de publicação; está aqui para você usar como quiser.',
13
+ sobre: 'Esta pasta é refeita a cada entrega e fica fora do git: o que você editar aqui se perde. Para guardar, copie a pasta para outro lugar do projeto.',
14
+ pronto: 'Pronto',
15
+ naoPronto: 'Não está pronto',
16
+ };
17
+
18
+ const lista = (linhas) => linhas.map((l) => `- ${l}`).join('\n');
19
+ const bloco = (titulo, linhas) => (linhas.length ? [`${titulo}\n${lista(linhas)}`] : []);
20
+ const secao = (titulo, partes) => (partes.length ? [`## ${titulo}\n\n${partes.join('\n\n')}`] : []);
21
+
22
+ /** Uma linha por arquivo entregue: o caminho na entrega, o título do bloco (se houver) e a origem. */
23
+ function linhaDoArquivo(a) {
24
+ const titulo = a.titulo ? ` — ${a.titulo}` : '';
25
+ return `\`${a.pasta}/${a.nome}\`${titulo} — origem: \`${a.origem}\``;
26
+ }
27
+
28
+ /** Os avisos de uma pasta que o LEIA-ME mostra (o que só vai para a tela não tem `texto`). */
29
+ const avisosDe = (d, pasta) => d.avisos.filter((a) => a.pasta === pasta && a.texto).map((a) => a.texto);
30
+
31
+ /** `## Antes de usar`: os alertas de tamanho e, por canal que não está pronto, o que falta. */
32
+ function antesDeUsar(d) {
33
+ const pastas = [...d.pastas, OUTROS].filter((p) => d.pendencias.has(p));
34
+ const frases = frasesDePendencia(pastas, d.pendencias);
35
+ const faltas = pastas.map((p, i) => `- ${frases[i]}\n${d.pendencias.get(p).map((l) => ` - ${l}`).join('\n')}`);
36
+ return secao('Antes de usar', [[...d.alertas.map((a) => `- ${a}`), ...faltas].join('\n')].filter(Boolean));
37
+ }
38
+
39
+ /** `## {Canal}`: o aviso de publicação, a situação, os arquivos, as pendências, os avisos e os passos. */
40
+ function secaoDoCanal(d, pasta) {
41
+ const arquivos = d.arquivos.filter((a) => a.pasta === pasta);
42
+ const pendencias = d.pendencias.get(pasta) ?? [];
43
+ const passos = passosDe(pasta, arquivos, pendencias.length > 0).map((p, i) => `${i + 1}. ${p}`);
44
+ return secao(CANAIS[pasta], [
45
+ ...(d.vaiPublicar.includes(pasta) ? [TEXTO.vaiPublicar] : []),
46
+ `Situação: ${pendencias.length ? TEXTO.naoPronto : TEXTO.pronto}`,
47
+ ...bloco('Arquivos:', arquivos.map(linhaDoArquivo)),
48
+ ...bloco('Pendências:', pendencias),
49
+ ...bloco('Atenção:', avisosDe(d, pasta)),
50
+ ...(passos.length ? [`Passos:\n${passos.join('\n')}`] : []),
51
+ ]);
52
+ }
53
+
54
+ /** `## Outros arquivos` e `## Editáveis`: cada arquivo com a origem; em `outros`, a linha diz por que está ali. */
55
+ function secaoDaPasta(d, pasta, titulo) {
56
+ const fim = pasta === OUTROS ? ` — ${TEXTO.semCanal}` : '';
57
+ const arquivos = d.arquivos.filter((a) => a.pasta === pasta).map((a) => `${linhaDoArquivo(a)}${fim}`);
58
+ const pendencias = d.pendencias.get(pasta) ?? [];
59
+ return secao(titulo, [
60
+ ...(arquivos.length ? [lista(arquivos)] : []),
61
+ ...bloco('Pendências:', pendencias),
62
+ ...bloco('Atenção:', avisosDe(d, pasta)),
63
+ ]);
64
+ }
65
+
66
+ /**
67
+ * @param {object} d
68
+ * @param {string} d.crew nome da crew · @param {string} d.run id da execução
69
+ * @param {string[]} d.pastas os canais presentes, na ordem das seções
70
+ * @param {object[]} d.arquivos `{ pasta, nome, tipo, origem, titulo, numerado }`
71
+ * @param {object[]} d.avisos `{ pasta, texto, tela }` (`tela`: como o aviso sai no resumo da tela)
72
+ * @param {Map<string, string[]>} d.pendencias por pasta · @param {string[]} d.alertas regra 34
73
+ * @param {string[]} d.naoConferido · @param {string[]} d.vaiPublicar canais que a crew publica
74
+ * @returns {string} o LEIA-ME, em UTF-8 sem BOM e com LF
75
+ */
76
+ export function montarLeiame(d) {
77
+ const temMd = d.arquivos.some((a) => a.nome.toLowerCase().endsWith('.md'));
78
+ const partes = [
79
+ `# Entrega — ${d.crew} — ${d.run}`,
80
+ ...antesDeUsar(d),
81
+ ...d.pastas.flatMap((pasta) => secaoDoCanal(d, pasta)),
82
+ ...secaoDaPasta(d, OUTROS, 'Outros arquivos'),
83
+ ...secaoDaPasta(d, EDITAVEIS, 'Editáveis'),
84
+ ...secao('O que não foi conferido', [lista(d.naoConferido)]),
85
+ ...(temMd ? secao('Para ter um PDF', [TEXTO.pdf]) : []),
86
+ ...secao('Sobre esta pasta', [TEXTO.sobre]),
87
+ ];
88
+ return `${partes.join('\n\n')}\n`;
89
+ }
@@ -0,0 +1,120 @@
1
+ // Peças que vêm do leitor do verificador (`lerPecas`): legenda, post e tweet, cada um com as
2
+ // suas hashtags no fim — e o primeiro comentário do LinkedIn, que sai do texto do post.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 e regra 4 (repositório do OpenCrew).
4
+ import { semAcento } from '../verificar/leitura.mjs';
5
+ import { lerPecas, listarHashtags } from '../verificar/pecas.mjs';
6
+ import { lerTrechos, marcar, rotuloDe } from '../verificar/secoes.mjs';
7
+ import { paraColar } from './texto.mjs';
8
+
9
+ /** Peça principal de cada formato: sem ela, o arquivo vai inteiro. */
10
+ export const PRINCIPAL = { 'instagram-feed': 'legenda', 'linkedin-post': 'post', 'twitter-post': 'tweet', 'twitter-thread': 'tweet' };
11
+ const TITULO = /^(#{1,6})\s+(.*)$/s;
12
+ const COMENTARIO = /(?<![\p{L}\p{N}])(?:comentarios?|comments?)(?![\p{L}\p{N}])/u;
13
+ const marca = (n) => `⟦comentario:${n}⟧`;
14
+ const MARCA = /^⟦comentario:(\d+)⟧$/;
15
+
16
+ /**
17
+ * Tira do corpo as seções de comentário (cabeçalho com "comentário" ou "comment", até o próximo
18
+ * cabeçalho do mesmo nível ou acima, ou até um rótulo). No lugar de cada uma fica uma linha de
19
+ * marca: ela cai dentro do texto do post que vem antes.
20
+ * @returns {{ corpo: string, comentarios: string[] }}
21
+ */
22
+ function tirarComentarios(corpo) {
23
+ const linhas = [];
24
+ const comentarios = [];
25
+ let nivel = 0; // nível do cabeçalho de comentário aberto; 0 = nenhum
26
+ for (const linha of corpo.split('\n')) {
27
+ const h = linha.match(TITULO);
28
+ if (nivel && (rotuloDe(linha) || (h && h[1].length <= nivel))) nivel = 0;
29
+ if (!nivel && h && COMENTARIO.test(semAcento(h[2]))) {
30
+ nivel = h[1].length;
31
+ linhas.push(marca(comentarios.push([]) - 1));
32
+ } else if (nivel) comentarios.at(-1).push(linha);
33
+ else linhas.push(linha);
34
+ }
35
+ return { corpo: linhas.join('\n'), comentarios: comentarios.map((c) => c.join('\n')) };
36
+ }
37
+
38
+ /** Texto da peça sem as linhas de marca, e os números dos comentários que estavam nele. */
39
+ function separarMarcas(texto) {
40
+ const numeros = [];
41
+ const linhas = texto.split('\n').filter((linha) => {
42
+ const m = linha.trim().match(MARCA);
43
+ if (m) numeros.push(Number(m[1]));
44
+ return !m;
45
+ });
46
+ return { texto: linhas.join('\n'), numeros };
47
+ }
48
+
49
+ /** Cabeçalhos do corpo, na ordem: `[{ titulo, pai }]` (`pai`: o cabeçalho logo acima, ou null). */
50
+ function cabecalhos(corpo) {
51
+ const lista = [];
52
+ let pilha = [];
53
+ for (const linha of corpo.split('\n')) {
54
+ const h = linha.match(TITULO);
55
+ if (!h) continue;
56
+ pilha = pilha.filter((c) => c.nivel < h[1].length);
57
+ lista.push({ titulo: h[2].trim(), pai: pilha.at(-1)?.titulo ?? null });
58
+ pilha.push({ nivel: h[1].length, titulo: h[2].trim() });
59
+ }
60
+ return lista;
61
+ }
62
+
63
+ /** Título do bloco de cada peça de cabeçalho: o cabeçalho logo acima do dela, quando houver. */
64
+ function titulosDeBloco(corpo, pecas) {
65
+ const lista = cabecalhos(corpo);
66
+ let cursor = 0;
67
+ return pecas.map((p) => {
68
+ const i = p.origem === 'cabecalho' ? lista.findIndex((c, n) => n >= cursor && c.titulo === p.cabecalho) : -1;
69
+ if (i < 0) return null;
70
+ cursor = p.porParagrafo ? i : i + 1;
71
+ return lista[i].pai;
72
+ });
73
+ }
74
+
75
+ /** Hashtags da seção própria (rótulo ou cabeçalho) da legenda ou do post, sem as que já estão no texto. */
76
+ function hashtagsDaPeca(pecas, p) {
77
+ const h = pecas.find((x) => x.tipo === 'hashtags' && x.de === p.tipo && x.ordem === p.ordem && x.formato === p.formato);
78
+ return h ? listarHashtags(h.texto).slice(listarHashtags(p.texto).length) : [];
79
+ }
80
+
81
+ /** Hashtags das seções `=== HASHTAGS ===` e de cabeçalho "Hashtags" de um arquivo de tweets. */
82
+ export function hashtagsSoltas(corpo, tabela = 'twitter-post') {
83
+ const trechos = lerTrechos(marcar(corpo), tabela).filter((t) => t.rotulo === 'HASHTAGS' || t.tipos?.[0] === 'hashtags');
84
+ return trechos.flatMap((t) => listarHashtags(t.linhas.join('\n')));
85
+ }
86
+
87
+ const comHashtags = (texto, hashtags) => paraColar(hashtags.length ? `${texto}\n\n${hashtags.join(' ')}` : texto);
88
+
89
+ /** Comentário de cada post: o que caiu no texto dele; os que sobraram vão, na ordem, para os posts sem comentário. */
90
+ function distribuir(marcas, comentarios) {
91
+ const usados = new Set(marcas.flat());
92
+ const sobras = comentarios.map((_, n) => n).filter((n) => !usados.has(n));
93
+ return marcas.map((numeros) => (numeros.length ? numeros : sobras.splice(0, 1)).map((n) => comentarios[n]).join('\n\n'));
94
+ }
95
+
96
+ /**
97
+ * Lê as peças do formato num corpo já sem frontmatter e sem blocos de serviço.
98
+ * @returns {{ unidades: object[], temSlides: boolean, outros: string[] }} `unidades`: uma por
99
+ * legenda, post ou tweet, `{ tipo, titulo, ordem, total, partes: [{ nome, sufixo, ext, tipo, texto }] }`
100
+ * · `outros`: cabeçalhos das seções de outro canal, que não são separadas
101
+ */
102
+ export function pecasDoLeitor(corpoOriginal, formato) {
103
+ const tabela = formato === 'twitter-thread' ? 'twitter-post' : formato;
104
+ const tipo = PRINCIPAL[formato];
105
+ const { corpo, comentarios } = tipo === 'post' ? tirarComentarios(corpoOriginal) : { corpo: corpoOriginal, comentarios: [] };
106
+ const pecas = lerPecas(corpo, tabela);
107
+ const principais = pecas.filter((p) => p.formato === tabela && p.tipo === tipo);
108
+ const limpas = principais.map((p) => separarMarcas(p.texto));
109
+ const doPost = distribuir(limpas.map((l) => l.numeros), comentarios);
110
+ const titulos = titulosDeBloco(corpo, principais);
111
+ const soltas = tipo === 'tweet' ? hashtagsSoltas(corpo) : [];
112
+ const unidades = principais.map((p, i) => {
113
+ const hashtags = tipo === 'tweet' ? (i === 0 ? soltas : []) : hashtagsDaPeca(pecas, p);
114
+ const partes = [{ nome: tipo, sufixo: '', ext: 'txt', tipo, texto: comHashtags(limpas[i].texto, hashtags) }];
115
+ if (doPost[i]?.trim()) partes.push({ nome: tipo, sufixo: '-comentario', ext: 'txt', tipo: 'comentario', texto: paraColar(doPost[i]) });
116
+ return { tipo, titulo: titulos[i], ordem: i + 1, total: principais.length, partes };
117
+ });
118
+ const outros = pecas.filter((p) => p.formato !== tabela && p.tipo !== 'hashtags' && p.cabecalho).map((p) => p.cabecalho);
119
+ return { unidades, temSlides: pecas.some((p) => p.formato === tabela && p.tipo === 'slides'), outros: [...new Set(outros)] };
120
+ }
@@ -0,0 +1,109 @@
1
+ // Peças que o leitor do verificador não lê: blog (`seo.txt` e `artigo.md`), e-mail (assunto,
2
+ // prévia e corpo), mensagem de WhatsApp e os tweets de uma thread (`TWEET n/N`).
3
+ // Recebem o texto do arquivo, sem BOM e com LF. Spec: fase-u3a1-pasta-de-entrega.md, §4.
4
+ import { lerFrontmatter, semAcento, semFrontmatter } from '../verificar/leitura.mjs';
5
+ import { lerPecas } from '../verificar/pecas.mjs';
6
+ import { rotuloDe } from '../verificar/secoes.mjs';
7
+ import { fecharMd, paraColar, secoesDeRotulo, semServico } from './texto.mjs';
8
+
9
+ const parte = (nome, ext, texto) => ({ nome, sufixo: '', ext, tipo: nome, texto });
10
+ const TITULO = /^#{1,6}\s+(.*)$/s;
11
+ const palavra = (formas) => new RegExp(`(?<![\\p{L}\\p{N}])(?:${formas})(?![\\p{L}\\p{N}])`, 'u');
12
+
13
+ // ── Blog ─────────────────────────────────────────────────────────────────────────────────────
14
+ const FORA_DO_ARTIGO = new Set(['TITLE', 'TITLE TAG', 'META DESCRIPTION', 'TARGET KEYWORD']);
15
+ const emUmaLinha = (valor) => String(valor).replace(/\s+/g, ' ').trim();
16
+
17
+ /** Palavra-chave: do frontmatter; senão, o que vem depois de `Primary:` em `=== TARGET KEYWORD ===`. */
18
+ function palavraChave(fm, secoes) {
19
+ const doRotulo = secoes.find((s) => s.rotulo === 'TARGET KEYWORD')?.texto.match(/Primary:[ \t]*([^|\n]+)/i)?.[1];
20
+ return [fm.palavra_chave, fm.keyword, doRotulo].find((v) => v != null && typeof v !== 'object' && String(v).trim());
21
+ }
22
+
23
+ /** @returns {object[]} uma unidade com `seo.txt` (só as linhas que existem) e `artigo.md` */
24
+ export function pecasDoBlog(texto, formato) {
25
+ const fm = lerFrontmatter(texto) ?? {};
26
+ const secoes = secoesDeRotulo(semServico(semFrontmatter(texto)));
27
+ const lida = (tipo) => lerPecas(texto, formato).find((p) => p.tipo === tipo)?.texto;
28
+ const campos = [['Título', lida('titulo')], ['Meta description', lida('meta')], ['Palavra-chave', palavraChave(fm, secoes)], ['Slug', typeof fm.slug === 'object' ? null : fm.slug]];
29
+ const seo = campos.filter(([, v]) => v != null && emUmaLinha(v)).map(([campo, v]) => `${campo}: ${emUmaLinha(v)}\n`).join('');
30
+ const artigo = secoes.filter((s) => !FORA_DO_ARTIGO.has(s.rotulo) && s.texto).map((s) => s.texto).join('\n\n');
31
+ return [{ tipo: 'blog', partes: [parte('seo', 'txt', seo), parte('artigo', 'md', fecharMd(artigo))] }];
32
+ }
33
+
34
+ // ── E-mail ───────────────────────────────────────────────────────────────────────────────────
35
+ const DO_ROTULO = { 'SUBJECT LINE': 'assunto', 'PREVIEW TEXT': 'previa' };
36
+ const DO_CABECALHO = [['assunto', palavra('assuntos?|subject')], ['previa', palavra('previas?|preview')], ['corpo', /^(?:corpo|body)(?![\p{L}\p{N}])/u]];
37
+
38
+ /** O que a linha abre: assunto, prévia ou corpo (linha de rótulo ou cabeçalho que nomeia); senão null. */
39
+ function papelDaLinha(linha) {
40
+ const rotulo = rotuloDe(linha);
41
+ if (rotulo) return DO_ROTULO[rotulo] ?? 'corpo';
42
+ const h = linha.match(TITULO);
43
+ return h ? DO_CABECALHO.find(([, rx]) => rx.test(semAcento(h[1]).trim()))?.[0] ?? null : null;
44
+ }
45
+
46
+ /** Tira do fim das linhas o cabeçalho que vem logo antes de um assunto: é o título do bloco. */
47
+ function tituloPendente(linhas) {
48
+ let fim = linhas.length;
49
+ while (fim && !linhas[fim - 1].trim()) fim--;
50
+ const h = fim ? linhas[fim - 1].match(TITULO) : null;
51
+ if (!h) return null;
52
+ linhas.length = fim - 1;
53
+ return h[1].trim();
54
+ }
55
+
56
+ /** Os e-mails do corpo, na ordem: cada assunto abre um. `[{ titulo, assunto, previa, corpo }]` (linhas). */
57
+ function lerEmails(corpo) {
58
+ const novo = () => ({ titulo: null, assunto: [], previa: [], corpo: [] });
59
+ const emails = [novo()];
60
+ let alvo = 'corpo';
61
+ for (const linha of corpo.split('\n')) {
62
+ const papel = papelDaLinha(linha);
63
+ if (papel === 'assunto') {
64
+ const titulo = tituloPendente(emails.at(-1).corpo);
65
+ if (emails.at(-1).assunto.length) emails.push(novo());
66
+ emails.at(-1).titulo = titulo;
67
+ }
68
+ if (papel) alvo = papel;
69
+ else if (TITULO.test(linha) && alvo !== 'corpo') alvo = 'corpo'; // outro cabeçalho: é conteúdo
70
+ if (!papel) emails.at(-1)[alvo].push(linha);
71
+ }
72
+ return emails;
73
+ }
74
+
75
+ /** @returns {object[]} uma unidade por e-mail, com `assunto.txt`, `previa.txt` e `corpo.md` */
76
+ export function pecasDoEmail(texto) {
77
+ const emails = lerEmails(semServico(semFrontmatter(texto)));
78
+ return emails.map((e) => ({
79
+ tipo: 'email',
80
+ titulo: e.titulo,
81
+ partes: [
82
+ parte('assunto', 'txt', paraColar(e.assunto.join('\n'))),
83
+ parte('previa', 'txt', paraColar(e.previa.join('\n'))),
84
+ parte('corpo', 'md', fecharMd(e.corpo.join('\n'))),
85
+ ],
86
+ }));
87
+ }
88
+
89
+ // ── WhatsApp ─────────────────────────────────────────────────────────────────────────────────
90
+ /** @returns {object[]} uma unidade: os textos das seções, na ordem, separados por uma linha em branco */
91
+ export function pecasDoWhatsapp(texto) {
92
+ const textos = secoesDeRotulo(semServico(semFrontmatter(texto))).map((s) => s.texto).filter(Boolean);
93
+ return [{ tipo: 'mensagem', partes: [parte('mensagem', 'txt', paraColar(textos.join('\n\n'), { whatsapp: true }))] }];
94
+ }
95
+
96
+ // ── Thread ───────────────────────────────────────────────────────────────────────────────────
97
+ const TWEET_N = /^[\s*_#>-]{0,8}TWEET\s{1,3}\d{1,3}\s{0,3}\/\s{0,3}(?:\d{1,3}|N)(?![\p{L}\p{N}])/iu;
98
+
99
+ /** Texto de cada bloco iniciado por `TWEET n/N` (sem essa linha), até o próximo bloco, rótulo ou cabeçalho. */
100
+ export function tweetsDaThread(corpo) {
101
+ const blocos = [];
102
+ let atual = null;
103
+ for (const linha of corpo.split('\n')) {
104
+ if (TWEET_N.test(linha)) blocos.push((atual = []));
105
+ else if (rotuloDe(linha) || TITULO.test(linha)) atual = null;
106
+ else atual?.push(linha);
107
+ }
108
+ return blocos.map((b) => b.join('\n').trim()).filter(Boolean);
109
+ }
@@ -0,0 +1,78 @@
1
+ // Nome de cada arquivo da entrega: peças do mesmo tipo no mesmo canal são numeradas; cópia cujo
2
+ // nome já existe na pasta ganha o prefixo `2-`, `3-`. Nenhum arquivo é gravado por cima de outro.
3
+ // Spec: fase-u3a-entrega-por-canal.md, regras 7 e 8, citadas pela fatia 1 (repositório do OpenCrew).
4
+
5
+ export const MSG = {
6
+ mesmoNome: (arquivo, novo) => `${arquivo} tem o mesmo nome de outro e foi guardado como ${novo}.`,
7
+ };
8
+
9
+ const chave = (pasta, nome) => `${pasta}/${nome.toLowerCase()}`;
10
+
11
+ /** Arquivos das unidades: `post.txt` quando há uma só no canal; `post-1.txt`, `post-1-comentario.txt`… com várias. */
12
+ function nomearUnidades(unidades) {
13
+ const total = {};
14
+ for (const u of unidades) total[`${u.pasta}|${u.tipo}`] = (total[`${u.pasta}|${u.tipo}`] ?? 0) + 1;
15
+ const vez = {};
16
+ return unidades.flatMap((u) => {
17
+ const grupo = `${u.pasta}|${u.tipo}`;
18
+ vez[grupo] = (vez[grupo] ?? 0) + 1;
19
+ const numero = total[grupo] > 1 ? `-${vez[grupo]}` : '';
20
+ return u.partes.map((parte) => ({
21
+ pasta: u.pasta, nome: `${parte.nome}${numero}${parte.sufixo}.${parte.ext}`, tipo: parte.tipo, texto: parte.texto,
22
+ origem: u.origem, formato: u.formato, titulo: numero ? u.titulo ?? null : null, numerado: Boolean(numero), ordem: u.ordem, total: u.total,
23
+ }));
24
+ });
25
+ }
26
+
27
+ /** Cópias agrupadas por pasta da entrega e, dentro dela, por pasta de origem, na ordem da lista. */
28
+ function grupos(copias) {
29
+ const porChave = new Map();
30
+ for (const c of copias) {
31
+ const id = `${c.pasta}|${c.pastaDeOrigem}`;
32
+ if (!porChave.has(id)) porChave.set(id, { pasta: c.pasta, copias: [] });
33
+ // O mesmo arquivo listado duas vezes para a mesma pasta é uma cópia só.
34
+ if (!porChave.get(id).copias.some((outra) => outra.nome === c.nome)) porChave.get(id).copias.push(c);
35
+ }
36
+ return [...porChave.values()];
37
+ }
38
+
39
+ /** O menor prefixo, a partir de `n-`, com que nenhum nome do grupo bate com um nome já usado. */
40
+ function prefixoLivre(grupo, usados, n) {
41
+ while (grupo.copias.some((c) => usados.has(chave(grupo.pasta, `${n}-${c.nome}`)))) n += 1;
42
+ return n;
43
+ }
44
+
45
+ /**
46
+ * Cópias com o nome original. Quando um arquivo chegaria a uma pasta onde já há outro com esse
47
+ * nome, todos os que vêm da mesma pasta de origem ganham o prefixo (`2-`; os da pasta seguinte,
48
+ * `3-`). O aviso sai para cada arquivo cujo nome batia.
49
+ */
50
+ function nomearCopias(copias, usados) {
51
+ const arquivos = [];
52
+ const avisos = [];
53
+ const proximo = {};
54
+ for (const grupo of grupos(copias)) {
55
+ const bate = (c) => usados.has(chave(grupo.pasta, c.nome));
56
+ const n = grupo.copias.some(bate) ? prefixoLivre(grupo, usados, proximo[grupo.pasta] ?? 2) : null;
57
+ if (n) proximo[grupo.pasta] = n + 1;
58
+ for (const c of grupo.copias) {
59
+ const nome = n ? `${n}-${c.nome}` : c.nome;
60
+ if (n && bate(c)) avisos.push({ pasta: c.pasta, texto: MSG.mesmoNome(c.origem, nome) });
61
+ arquivos.push({ ...c, nome });
62
+ }
63
+ for (const a of arquivos.slice(-grupo.copias.length)) usados.add(chave(a.pasta, a.nome));
64
+ }
65
+ return { arquivos, avisos };
66
+ }
67
+
68
+ /**
69
+ * @param {{ unidades: object[], copias: object[], avisos: object[] }} produtos o que `separar` devolve
70
+ * @returns {{ arquivos: object[], avisos: object[] }} `arquivos`: `{ pasta, nome, tipo, origem,
71
+ * texto | de, … }`, na ordem da lista (as peças antes das cópias), sem dois com o mesmo caminho
72
+ */
73
+ export function nomear({ unidades, copias, avisos }) {
74
+ const gerados = nomearUnidades(unidades);
75
+ const usados = new Set(gerados.map((a) => chave(a.pasta, a.nome)));
76
+ const copiados = nomearCopias(copias, usados);
77
+ return { arquivos: [...gerados, ...copiados.arquivos], avisos: [...avisos, ...copiados.avisos] };
78
+ }