@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,99 @@
1
+ // Os passos de cada canal no LEIA-ME: o quadro "Passos por canal" da spec, guardado como dado.
2
+ // O texto é literal (aprovado com a spec): não reescreva aqui sem mudar a spec.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 (repositório do OpenCrew).
4
+
5
+ /** O primeiro passo do canal que não está pronto (ajuste da execução real). */
6
+ export const ANTES_DE_POSTAR = 'Antes de postar, resolva o que está em Pendências. Corrija no arquivo de origem e peça para montar a entrega de novo: o que você mudar nesta pasta se perde.';
7
+ const MARKDOWN = 'Este texto está em markdown: se o seu editor não aceitar, ajuste títulos, negrito e links depois de colar.';
8
+
9
+ // Cada passo: [o que o faz aparecer, o texto]. null = sempre que o canal tem arquivo; um tipo de
10
+ // arquivo = só quando a entrega tem arquivo desse tipo no canal; `tipo:um` e `tipo:varios` = só
11
+ // quando a peça é uma só, ou quando é numerada. `{arquivo}`: uma linha por arquivo. `{campos}`:
12
+ // os campos que o `seo.txt` daquela entrega tem.
13
+ const PASSOS = {
14
+ instagram: [
15
+ [null, 'No computador, abra instagram.com e comece uma publicação nova. Pelo celular, mande os arquivos para ele antes.'],
16
+ ['imagem', 'Escolha as imagens na ordem dos nomes dos arquivos.'],
17
+ ['legenda', 'Abra `instagram/legenda.txt`, copie tudo e cole no campo da legenda. As hashtags já estão no fim.'],
18
+ [null, 'Confira a prévia e publique.'],
19
+ ['texto', '`instagram/{arquivo}` é para ler e produzir (gravar ou montar os slides): não é texto para colar.'],
20
+ ],
21
+ linkedin: [
22
+ [null, 'No computador, abra linkedin.com e comece uma publicação.'],
23
+ ['post', 'Abra `linkedin/post.txt`, copie tudo e cole.'],
24
+ ['imagem', 'Anexe as imagens na ordem dos nomes dos arquivos.'],
25
+ [null, 'Confira e publique.'],
26
+ ['comentario', 'Depois de publicar, abra `linkedin/post-comentario.txt`, copie e cole como primeiro comentário.'],
27
+ ['roteiro', 'Para o artigo, escolha escrever um artigo no LinkedIn e cole o texto de `linkedin/{arquivo}`, seção por seção.'],
28
+ ],
29
+ blog: [
30
+ [null, 'Abra o editor do seu blog e crie um post novo.'],
31
+ ['seo', 'Abra `blog/seo.txt` e copie cada linha para o campo de mesmo nome ({campos}).'],
32
+ ['artigo', 'Abra `blog/artigo.md`, copie tudo e cole no corpo do post.'],
33
+ ['artigo', MARKDOWN],
34
+ [null, 'Confira a prévia e publique.'],
35
+ ],
36
+ email: [
37
+ [null, 'Abra a sua ferramenta de e-mail e crie uma mensagem nova.'],
38
+ ['assunto', 'Copie o texto de `email/assunto.txt` para o campo do assunto.'],
39
+ ['previa', 'Copie o texto de `email/previa.txt` para o campo de prévia (a linha que aparece ao lado do assunto).'],
40
+ ['corpo', 'Abra `email/corpo.md`, copie tudo e cole no corpo.'],
41
+ ['corpo', MARKDOWN],
42
+ [null, 'Envie um teste para você antes de enviar para a lista.'],
43
+ ],
44
+ whatsapp: [
45
+ [null, 'No computador, abra o WhatsApp Web ou o aplicativo. Pelo celular, mande o arquivo para ele antes.'],
46
+ ['mensagem', 'Abra `whatsapp/mensagem.txt`, copie tudo e cole na conversa ou na lista de transmissão.'],
47
+ [null, 'Se o texto tiver `{{…}}`, troque pelo dado real, ou confira se a sua ferramenta de envio faz a troca.'],
48
+ [null, 'Envie primeiro para você, para ver como ficou.'],
49
+ ],
50
+ twitter: [
51
+ [null, 'No computador, abra x.com e comece uma publicação.'],
52
+ ['tweet:um', 'Abra `twitter/tweet.txt`, copie tudo e cole.'],
53
+ ['tweet:varios', 'Para a sequência, cole `twitter/tweet-1.txt`, acrescente outra publicação e cole o arquivo seguinte, na ordem dos números.'],
54
+ ['imagem', 'Anexe as imagens.'],
55
+ [null, 'Confira e publique.'],
56
+ ],
57
+ youtube: [['roteiro', '`youtube/{arquivo}` é o roteiro para gravar: não é texto para colar.']],
58
+ };
59
+ // "texto": o roteiro e o arquivo que foi inteiro (carrossel em texto, peça não encontrada).
60
+ const TIPOS = { texto: ['roteiro', 'inteiro'] };
61
+
62
+ /** Com várias peças, o passo cita os arquivos numerados no lugar do nome sem número. */
63
+ function citar(texto, pasta, achados) {
64
+ if (!achados[0].numerado) return texto;
65
+ const lista = achados.map((a) => `\`${pasta}/${a.nome}\``).join(', ');
66
+ return texto.replace(new RegExp(`\`${pasta}/[^\`]+\``), lista);
67
+ }
68
+
69
+ /** Os campos que os arquivos têm (`Título: …` → "título"), cada um uma vez, na ordem das linhas. */
70
+ function camposDe(achados) {
71
+ const linhas = achados.flatMap((a) => (a.texto ?? '').split('\n')).filter((l) => l.includes(':'));
72
+ return [...new Set(linhas.map((l) => l.slice(0, l.indexOf(':')).toLowerCase()))].join(', ');
73
+ }
74
+
75
+ /** Os passos do quadro, só os que citam arquivo que existe nesse canal, na ordem do quadro. */
76
+ function doQuadro(pasta, arquivos) {
77
+ if (!arquivos.length) return [];
78
+ return (PASSOS[pasta] ?? []).flatMap(([condicao, texto]) => {
79
+ if (!condicao) return [texto];
80
+ const [tipo, quantos] = condicao.split(':');
81
+ const achados = arquivos.filter((a) => (TIPOS[tipo] ?? [tipo]).includes(a.tipo));
82
+ if (!achados.length) return [];
83
+ if (quantos) return Boolean(achados[0].numerado) === (quantos === 'varios') ? [texto] : [];
84
+ if (texto.includes('{arquivo}')) return achados.map((a) => texto.replace('{arquivo}', a.nome));
85
+ return [citar(texto, pasta, achados).replace('{campos}', camposDe(achados))];
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Os passos de um canal. No canal que não está pronto, o primeiro manda resolver as pendências.
91
+ * @param {string} pasta a pasta do canal
92
+ * @param {object[]} arquivos os arquivos da entrega nessa pasta: `{ nome, tipo, numerado, texto }`
93
+ * @param {boolean} [comPendencia] o canal tem pendência
94
+ * @returns {string[]}
95
+ */
96
+ export function passosDe(pasta, arquivos, comPendencia = false) {
97
+ const passos = doQuadro(pasta, arquivos);
98
+ return comPendencia && passos.length ? [ANTES_DE_POSTAR, ...passos] : passos;
99
+ }
@@ -0,0 +1,87 @@
1
+ // O que a verificação diz da entrega: as pendências de cada canal (bloqueio, [PREENCHER], item
2
+ // que não existe), o que não foi conferido e o alerta de tamanho do texto entregue.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, regras 17, 19, 20 e 34 (repositório do OpenCrew).
4
+ import path from 'node:path';
5
+ import { lerLimites } from '../verificar/leitura.mjs';
6
+ import { contar, FALTA_INFO, NAO_MEDIDO, NAO_VERIFICADO } from '../verificar/regras.mjs';
7
+ import { OUTROS, nomeDaPasta } from './canais.mjs';
8
+
9
+ export const MSG = {
10
+ naoEncontrei: (arquivo) => `Não encontrei ${arquivo}.`,
11
+ naoPronto: (canal, n) => `${canal} não está pronto: ${n} ${n === 1 ? 'pendência' : 'pendências'}.`,
12
+ alerta: (arquivo, n, limite) => `ALERTA: \`${arquivo}\` tem ${n} caracteres; o limite é ${limite}. Encurte antes de publicar.`,
13
+ };
14
+ /** A lista fixa do que o script nunca confere. */
15
+ export const NUNCA_CONFERIDO = ['Links e fatos citados no texto.', 'Texto dentro das imagens.', 'Aparência final em cada rede.'];
16
+
17
+ const ehNaoMedido = (i) => i.item === NAO_MEDIDO || i.item === NAO_VERIFICADO;
18
+ const origem = (arquivo) => `origem: \`${arquivo}\``;
19
+
20
+ /** Uma linha de pendência ou de alerta, com o trecho (ou a medida) e o arquivo de origem. */
21
+ function linhaDoItem(i, arquivo) {
22
+ if (i.item === FALTA_INFO) return `Falta preencher "${i.detalhe}" — ${origem(arquivo)}`;
23
+ if (ehNaoMedido(i)) return `${i.item} — ${i.detalhe} — ${origem(arquivo)}`;
24
+ if (i.medido != null) return `${i.item}: ${i.medido} (${i.minimo ? 'mínimo' : 'limite'} ${i.limite}) — ${origem(arquivo)}`;
25
+ return `${i.item}: "${i.detalhe}" — ${origem(arquivo)}`;
26
+ }
27
+
28
+ /** O resultado do verificador para um item da lista (só os arquivos de texto têm). */
29
+ const resultadoDe = (raiz, r, item) => r.arquivos.find((a) => path.resolve(raiz, a.arquivo) === item.abs && (a.formato ?? null) === (item.formato ?? null));
30
+
31
+ /**
32
+ * Pendências por pasta da entrega (canal ou `outros`), na ordem da lista.
33
+ * @returns {Map<string, string[]>} só as pastas que têm pendência
34
+ */
35
+ export function pendenciasPorPasta(raiz, itens, r) {
36
+ const mapa = new Map();
37
+ const somar = (item, linha) => mapa.set(item.canal ?? OUTROS, [...(mapa.get(item.canal ?? OUTROS) ?? []), linha]);
38
+ for (const item of itens) {
39
+ if (item.tipo !== 'arquivo') somar(item, MSG.naoEncontrei(item.rel));
40
+ for (const i of resultadoDe(raiz, r, item)?.itens ?? []) if (i.nivel === 'bloqueio') somar(item, linhaDoItem(i, item.rel));
41
+ }
42
+ return mapa;
43
+ }
44
+
45
+ /** As frases "{canal} não está pronto: {n} pendências.", na ordem das pastas dadas. */
46
+ export const frasesDePendencia = (pastas, pendencias) => pastas.filter((p) => pendencias.has(p)).map((p) => MSG.naoPronto(nomeDaPasta(p), pendencias.get(p).length));
47
+
48
+ /** O que não foi conferido: alertas, "não medido", "não verificado" e a lista fixa. Não muda o final. */
49
+ export function naoConferido(raiz, itens, r) {
50
+ const linhas = [];
51
+ for (const item of itens.filter((i) => i.tipo === 'arquivo')) {
52
+ for (const i of resultadoDe(raiz, r, item)?.itens ?? []) {
53
+ if (i.nivel === 'alerta' || ehNaoMedido(i)) linhas.push(linhaDoItem(i, item.rel));
54
+ }
55
+ }
56
+ if (r.naoTexto.length) linhas.push(`${NAO_VERIFICADO} — não é texto: ${r.naoTexto.join(', ')}`);
57
+ return [...linhas, ...NUNCA_CONFERIDO];
58
+ }
59
+
60
+ const LIMITE = { legenda: 'caption_max_chars', post: 'post_max_chars', tweet: 'tweet_max_chars' };
61
+ const NOME_NO_RELATORIO = { legenda: 'Legenda Instagram', post: 'Post LinkedIn', tweet: 'Tweet' };
62
+ const NUMERADA = { legenda: 'legenda', post: 'post', tweet: 'Tweet' };
63
+
64
+ /** O verificador já bloqueou, no arquivo de origem, o tamanho desta mesma peça? */
65
+ function jaApontada(itensDaOrigem, a) {
66
+ const doTipo = itensDaOrigem.filter((i) => i.nivel === 'bloqueio' && i.item.startsWith(NOME_NO_RELATORIO[a.tipo]) && i.item.endsWith('caracteres'));
67
+ if (a.total < 2) return doTipo.length > 0;
68
+ const numero = new RegExp(`${NUMERADA[a.tipo]} ${a.ordem}(?!\\d)`);
69
+ return doTipo.some((i) => numero.test(i.item));
70
+ }
71
+
72
+ /**
73
+ * Regra 34: cada `legenda*.txt`, `post*.txt` (não o comentário) e `tweet*.txt` gerado é contado
74
+ * como o verificador conta e comparado com o limite do formato do item. Passou: um ALERTA — mas
75
+ * só quando o verificador não apontou a mesma peça. Não é pendência.
76
+ */
77
+ export async function alertasDeTamanho(raiz, itens, arquivos, r) {
78
+ const alertas = [];
79
+ for (const a of arquivos.filter((x) => LIMITE[x.tipo] && x.texto)) {
80
+ const limite = (await lerLimites(raiz, a.formato)).limites?.[LIMITE[a.tipo]];
81
+ const medido = contar(a.texto);
82
+ const item = itens.find((i) => i.rel === a.origem && i.formato === a.formato);
83
+ const daOrigem = (item && resultadoDe(raiz, r, item)?.itens) ?? [];
84
+ if (typeof limite === 'number' && medido > limite && !jaApontada(daOrigem, a)) alertas.push(MSG.alerta(`${a.pasta}/${a.nome}`, medido, limite));
85
+ }
86
+ return alertas;
87
+ }
@@ -0,0 +1,103 @@
1
+ // Separa os itens da lista em produtos da entrega: peças que viram arquivo próprio (unidades),
2
+ // cópias com o nome original e avisos. Não escreve nada: quem grava é `gravar.mjs`.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, §4 e regras 4, 8, 9 e 11 (repositório do OpenCrew).
4
+ import path from 'node:path';
5
+ import { lerItem } from '../verificar/arquivos.mjs';
6
+ import { semFrontmatter } from '../verificar/leitura.mjs';
7
+ import { EDITAVEIS, OUTROS } from './canais.mjs';
8
+ import { avisoDeFora, blocosFora } from './fora.mjs';
9
+ import { PRINCIPAL, hashtagsSoltas, pecasDoLeitor } from './leitor.mjs';
10
+ import { pecasDoBlog, pecasDoEmail, pecasDoWhatsapp, tweetsDaThread } from './longas.mjs';
11
+ import { emLf, fecharMd, paraColar, semServico } from './texto.mjs';
12
+
13
+ export const MSG = {
14
+ // Só no resumo da tela: no LEIA-ME, a linha do arquivo em "Outros arquivos" já diz.
15
+ semCanal: (arquivo) => `${arquivo} não tem canal conhecido. Está em \`outros/\`.`,
16
+ semPeca: (peca, arquivo) => `Não encontrei ${peca} em ${arquivo}. Confira antes de colar.`,
17
+ outroCanal: (arquivo, cabecalho) => `${arquivo} tem uma seção de outro canal (${cabecalho}) que não foi separada. Ela continua no arquivo de origem.`,
18
+ };
19
+
20
+ const IMAGEM = new Set(['.png', '.jpg', '.jpeg']);
21
+ const HTML = new Set(['.html', '.htm']);
22
+ const DITA = { legenda: 'a legenda', post: 'o post', tweet: 'o tweet', blog: 'o artigo', email: 'o e-mail', mensagem: 'a mensagem' };
23
+ const LONGAS = {
24
+ 'blog-post': ['blog', pecasDoBlog], 'blog-seo': ['blog', pecasDoBlog],
25
+ 'email-newsletter': ['email', pecasDoEmail], 'email-sales': ['email', pecasDoEmail],
26
+ 'whatsapp-broadcast': ['mensagem', pecasDoWhatsapp],
27
+ };
28
+
29
+ const extensao = (item) => path.extname(item.abs).toLowerCase();
30
+ const semExtensao = (item) => item.abs.slice(0, item.abs.length - path.extname(item.abs).length).toLowerCase();
31
+ const ehImagem = (item) => IMAGEM.has(extensao(item));
32
+
33
+ /** HTML editável: tem na lista uma imagem de mesmo nome, na mesma pasta. */
34
+ const ehEditavel = (item, itens) => HTML.has(extensao(item)) && itens.some((i) => i.tipo === 'arquivo' && ehImagem(i) && semExtensao(i) === semExtensao(item));
35
+
36
+ /** Cópia com o nome original: `de` (os mesmos bytes do arquivo) ou `texto` (o corpo tratado). */
37
+ function copia(item, pasta, tipo, texto = null) {
38
+ const base = { pasta, nome: path.basename(item.abs), tipo, origem: item.rel, pastaDeOrigem: path.dirname(item.abs) };
39
+ return texto ? { ...base, texto } : { ...base, de: item.abs };
40
+ }
41
+
42
+ /** Tweets de uma thread escrita em blocos `TWEET n/N`; as hashtags soltas vão no fim do primeiro. */
43
+ function daThread(corpo) {
44
+ const tweets = tweetsDaThread(corpo);
45
+ const soltas = tweets.length ? hashtagsSoltas(corpo) : [];
46
+ const unidades = tweets.map((t, i) => {
47
+ const texto = paraColar(i === 0 && soltas.length ? `${t}\n\n${soltas.join(' ')}` : t);
48
+ return { tipo: 'tweet', ordem: i + 1, total: tweets.length, partes: [{ nome: 'tweet', sufixo: '', ext: 'txt', tipo: 'tweet', texto }] };
49
+ });
50
+ return { unidades, temSlides: false, outros: [] };
51
+ }
52
+
53
+ /** As unidades de um arquivo de texto, pelo formato dele; `tipo`: a peça que não pode faltar; `fora`: os blocos que não entram. */
54
+ function lerUnidades(texto, formato) {
55
+ if (LONGAS[formato]) return { tipo: LONGAS[formato][0], unidades: LONGAS[formato][1](texto, formato), temSlides: false, outros: [], fora: blocosFora(texto) };
56
+ const corpo = semServico(semFrontmatter(texto));
57
+ const thread = formato === 'twitter-thread' ? daThread(corpo) : null;
58
+ if (thread?.unidades.length) return { tipo: PRINCIPAL[formato], ...thread, fora: blocosFora(texto) };
59
+ return { tipo: PRINCIPAL[formato], ...pecasDoLeitor(corpo, formato), fora: blocosFora(texto, formato) };
60
+ }
61
+
62
+ /** Arquivo `.md` ou `.txt` de um formato com peças: as unidades, ou o arquivo inteiro, com aviso. */
63
+ function separarPecas(item, texto, itens, p) {
64
+ const { tipo, unidades, temSlides, outros, fora } = lerUnidades(texto, item.formato);
65
+ const comArquivo = unidades.map((u) => ({ ...u, partes: u.partes.filter((parte) => parte.texto) })).filter((u) => u.partes.length);
66
+ for (const u of comArquivo) p.unidades.push({ ...u, pasta: item.canal, origem: item.rel, formato: item.formato });
67
+ for (const cabecalho of outros) p.avisos.push({ pasta: item.canal, texto: MSG.outroCanal(item.rel, cabecalho) });
68
+ const semImagem = !itens.some((i) => i.tipo === 'arquivo' && ehImagem(i) && i.formato === item.formato);
69
+ if (!comArquivo.length) p.avisos.push({ pasta: item.canal, texto: MSG.semPeca(DITA[tipo], item.rel) });
70
+ // O arquivo que vai inteiro leva todos os blocos: só há o que avisar quando ele não vai.
71
+ if (!comArquivo.length || (temSlides && semImagem)) p.copias.push(copia(item, item.canal, 'inteiro'));
72
+ else p.avisos.push(...avisoDeFora(item, fora));
73
+ }
74
+
75
+ /** Um item que existe → os produtos dele. */
76
+ async function separarItem(item, itens, p) {
77
+ if (ehEditavel(item, itens)) return p.copias.push(copia(item, EDITAVEIS, 'editavel'));
78
+ if (!item.canal) {
79
+ p.avisos.push({ pasta: OUTROS, tela: MSG.semCanal(item.rel) });
80
+ return p.copias.push(copia(item, OUTROS, 'copia'));
81
+ }
82
+ const lido = ['.md', '.txt'].includes(extensao(item)) ? await lerItem(item.abs) : null;
83
+ if (lido?.tipo !== 'texto') return p.copias.push(copia(item, item.canal, ehImagem(item) ? 'imagem' : 'copia'));
84
+ const texto = emLf(lido.texto);
85
+ if (LONGAS[item.formato] || PRINCIPAL[item.formato]) return separarPecas(item, texto, itens, p);
86
+ // Roteiro ou artigo: o corpo, sem frontmatter e sem blocos de serviço; as linhas de rótulo ficam.
87
+ p.avisos.push(...avisoDeFora(item, blocosFora(texto)));
88
+ return p.copias.push(copia(item, item.canal, 'roteiro', fecharMd(semServico(semFrontmatter(texto)))));
89
+ }
90
+
91
+ /**
92
+ * @param {object[]} itens os itens da lista, já sem os arquivos de serviço:
93
+ * `{ abs, rel, formato, canal, tipo }` (`tipo`: arquivo, pasta ou ausente)
94
+ * @returns {Promise<{ unidades: object[], copias: object[], avisos: object[] }>}
95
+ * · `unidades`: `{ pasta, tipo, titulo, origem, formato, ordem, total, partes }`
96
+ * · `copias`: `{ pasta, nome, tipo, origem, pastaDeOrigem, de | texto }`
97
+ * · `avisos`: `{ pasta, texto, tela }` (`tela`: o texto do resumo da tela; sem `texto`, só sai lá)
98
+ */
99
+ export async function separar(itens) {
100
+ const produtos = { unidades: [], copias: [], avisos: [] };
101
+ for (const item of itens.filter((i) => i.tipo === 'arquivo')) await separarItem(item, itens, produtos);
102
+ return produtos;
103
+ }
@@ -0,0 +1,95 @@
1
+ // Texto da entrega: o que sai do markdown num arquivo para colar, o que é bloco de serviço e as
2
+ // seções de rótulo de um corpo. As expressões rodam linha a linha, sobre texto do usuário.
3
+ // Spec: fase-u3a-entrega-por-canal.md, regra 6, citada pela fatia 1 (repositório do OpenCrew).
4
+ import { rotuloDe } from '../verificar/secoes.mjs';
5
+ import { semBom } from '../verificar/leitura.mjs';
6
+
7
+ /** Sem o BOM e com LF. */
8
+ export const emLf = (texto) => semBom(texto).replace(/\r\n?/g, '\n');
9
+
10
+ /** Texto em markdown que vai para um arquivo: aparado, com uma quebra de linha no fim. */
11
+ export function fecharMd(texto) {
12
+ const aparado = texto.trim();
13
+ return aparado ? `${aparado}\n` : '';
14
+ }
15
+
16
+ /** Texto para colar: sem espaço no fim das linhas, sem linhas em branco seguidas, aparado. */
17
+ export const fechar = (texto) => fecharMd(texto.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n'));
18
+
19
+ /** Sem os comentários HTML (`<!-- … -->`); o que não fecha fica como está. */
20
+ export function semComentarios(texto) {
21
+ let saida = '';
22
+ let i = 0;
23
+ for (;;) {
24
+ const abre = texto.indexOf('<!--', i);
25
+ const fecha = abre < 0 ? -1 : texto.indexOf('-->', abre + 4);
26
+ if (fecha < 0) return saida + texto.slice(i);
27
+ saida += texto.slice(i, abre);
28
+ i = fecha + 3;
29
+ }
30
+ }
31
+
32
+ /** Rótulo de bloco de serviço: termina em `NOTES`, `NOTE` ou `CHECKLIST`, ou é `FORMAT`. */
33
+ export const ehServico = (rotulo) => rotulo === 'FORMAT' || /(?:^|\s)(?:NOTES?|CHECKLIST)$/.test(rotulo);
34
+
35
+ /**
36
+ * Corpo sem os blocos de serviço: os comentários HTML e as seções de rótulo de serviço, cada uma
37
+ * até o próximo rótulo. As outras linhas de rótulo ficam.
38
+ */
39
+ export function semServico(corpo) {
40
+ const linhas = [];
41
+ let fora = false;
42
+ for (const linha of semComentarios(corpo).split('\n')) {
43
+ const rotulo = rotuloDe(linha);
44
+ if (rotulo) fora = ehServico(rotulo);
45
+ if (!fora) linhas.push(linha);
46
+ }
47
+ return linhas.join('\n');
48
+ }
49
+
50
+ /** As seções de rótulo de um corpo, na ordem: `[{ rotulo, texto }]`; antes do primeiro, `rotulo` null. */
51
+ export function secoesDeRotulo(corpo) {
52
+ const secoes = [{ rotulo: null, linhas: [] }];
53
+ for (const linha of corpo.split('\n')) {
54
+ const rotulo = rotuloDe(linha);
55
+ if (rotulo) secoes.push({ rotulo, linhas: [] });
56
+ else secoes.at(-1).linhas.push(linha);
57
+ }
58
+ return secoes.map((s) => ({ rotulo: s.rotulo, texto: s.linhas.join('\n').trim() }));
59
+ }
60
+
61
+ // O que nunca perde marcador: URL, e-mail, @usuário e #hashtag. Fica guardado enquanto a linha é
62
+ // limpa e volta depois, igual.
63
+ const PROTEGIDO = /(?:https?:\/\/|www\.|wa\.me\/)[^\s*]+|[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+|(?<![\p{L}\p{N}_])[@#][\p{L}\p{N}_]+/gu;
64
+ const [ABRE, FECHA] = [String.fromCharCode(0xe000), String.fromCharCode(0xe001)];
65
+ const GUARDADO = new RegExp(`${ABRE}(\\d+)${FECHA}`, 'g');
66
+ const LINK = /\[([^\]\n]+)\]\(\s*([^)\s]+)(?:\s+"[^"\n]*")?\s*\)/g;
67
+ // O marcador só sai em par e colado ao texto que marca; nunca no meio de palavra.
68
+ const NEGRITO = /\*\*(?=[^\s*])([^\n]+?)(?<=[^\s*])\*\*/g;
69
+ const MARCADORES = [
70
+ NEGRITO,
71
+ /(?<![\p{L}\p{N}_])__(?=[^\s_])([^\n]+?)(?<=[^\s_])__(?![\p{L}\p{N}_])/gu,
72
+ /(?<![\p{L}\p{N}*])\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?![\p{L}\p{N}*])/gu,
73
+ /(?<![\p{L}\p{N}_])_(?=[^\s_])([^_\n]+?)(?<=[^\s_])_(?![\p{L}\p{N}_])/gu,
74
+ ];
75
+
76
+ /** `[texto](url)` vira `texto: url` (ou só a URL); negrito e itálico saem. No WhatsApp, `**x**` vira `*x*`. */
77
+ function limparLinha(linha, whatsapp) {
78
+ const guardados = [];
79
+ const comLinks = linha.replace(LINK, (_, texto, url) => (texto.trim() === url ? url : `${texto}: ${url}`));
80
+ const mascarada = comLinks.replace(PROTEGIDO, (achado) => `${ABRE}${guardados.push(achado) - 1}${FECHA}`);
81
+ const limpa = whatsapp ? mascarada.replace(NEGRITO, '*$1*') : MARCADORES.reduce((t, rx) => t.replace(rx, '$1'), mascarada);
82
+ return limpa.replace(GUARDADO, (_, n) => guardados[Number(n)]);
83
+ }
84
+
85
+ /**
86
+ * Texto pronto para colar: as mesmas palavras, na mesma ordem, sem as linhas de rótulo, sem a
87
+ * linha `---`, sem os `#` de título e sem a sintaxe de negrito, itálico e link. Marcador de lista
88
+ * e citação ficam.
89
+ * @param {string} texto
90
+ * @param {{ whatsapp?: boolean }} [opcoes] no WhatsApp, `*x*` e `_x_` ficam e `**x**` vira `*x*`
91
+ */
92
+ export function paraColar(texto, { whatsapp = false } = {}) {
93
+ const linhas = emLf(texto).split('\n').filter((linha) => linha.trim() !== '---' && !rotuloDe(linha));
94
+ return fechar(linhas.map((linha) => limparLinha(linha.replace(/^#{1,6}\s+/, ''), whatsapp)).join('\n'));
95
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // Entrega de uma execução: separa os arquivos aprovados por canal, com texto pronto para colar e
3
+ // um LEIA-ME que diz o que fazer com cada arquivo.
4
+ // Uso (na pasta do projeto): node _opencrew/core/scripts/entregar.mjs --crew "crews/<crew>"
5
+ // --run "<id>" --arquivo "<caminho=formato>[,<caminho=formato>…]" [--vai-publicar <canal>] [--ajuda]
6
+ // --arquivo: a lista do verificador (o "=formato" é opcional); entra o que estiver nela e existir.
7
+ // --vai-publicar: canal que a crew publica sozinha (pode repetir); o LEIA-ME avisa.
8
+ // Grava só em crews/<crew>/output/<run>/: a pasta `entrega/` (refeita do zero a cada chamada,
9
+ // montada em `entrega.tmp/`) e, ao lado, `verificacao-entrega.md`.
10
+ // Última linha da saída (o runner lê esta linha): ENTREGA:OK (nenhuma pendência) ou
11
+ // ENTREGA:INCOMPLETA (algum canal não está pronto, ou a gravação falhou).
12
+ // Código de saída: 0 sempre que a linha ENTREGA: sai, e em --ajuda · 1 = erro de uso ou erro que
13
+ // impediu a entrega inteira; com código 1 não há linha ENTREGA: e nada é escrito.
14
+ // Spec: fase-u3a1-pasta-de-entrega.md (repositório do OpenCrew).
15
+ import { existsSync, readdirSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { MSG as COMUM, erroDeUso, ehPrincipal, relativoAoProjeto } from './comum.mjs';
18
+ import { formatarRelatorio, verificar } from './verificar.mjs';
19
+ import { USO, lerArgs, lerLista, limpar, runValido } from './entrega/argumentos.mjs';
20
+ import { CANAIS, OUTROS, canalDoFormato, ehCanal, ehDeServico } from './entrega/canais.mjs';
21
+ import { separar } from './entrega/separar.mjs';
22
+ import { nomear } from './entrega/nomes.mjs';
23
+ import { alertasDeTamanho, frasesDePendencia, naoConferido, pendenciasPorPasta } from './entrega/pendencias.mjs';
24
+ import { TEXTO, montarLeiame } from './entrega/leiame.mjs';
25
+ import { gravarEntrega } from './entrega/gravar.mjs';
26
+
27
+ const MSG = {
28
+ desconhecida: (opcao) => `Opção desconhecida: ${limpar(opcao)}.`,
29
+ semExecucao: (run, lista) => `Execução não encontrada: ${limpar(run)}. Execuções desta crew: ${lista.join(', ') || 'nenhuma'}.`,
30
+ nenhumArquivo: 'Nenhum arquivo da lista foi encontrado.',
31
+ deServico: (arquivo) => `${arquivo} é arquivo de serviço e não entra na entrega.`,
32
+ semCanal: (canal) => `Canal não encontrado nesta entrega: ${limpar(canal)}.`,
33
+ falhaDeEscrita: (arquivo) => `Não consegui gravar ${arquivo}. Feche o arquivo, ou espere a sincronização da pasta, e rode de novo.`,
34
+ };
35
+
36
+ const ehPasta = (p) => existsSync(p) && statSync(p).isDirectory();
37
+ const tipoNoDisco = (p) => (!existsSync(p) ? 'ausente' : statSync(p).isDirectory() ? 'pasta' : 'arquivo');
38
+ const execucoes = (saida) => (ehPasta(saida) ? readdirSync(saida, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() : []);
39
+
40
+ /** Erro de uso, na ordem da §6: `{ mensagem, comUso }`, ou null quando a chamada está certa. */
41
+ function erroDeChamada(raiz, args, pedidos) {
42
+ if (args.desconhecida) return { mensagem: MSG.desconhecida(args.desconhecida), comUso: true };
43
+ const faltando = [['--crew', args.crew], ['--run', args.run], ['--arquivo', pedidos.length]].filter(([, v]) => !v).map(([opcao]) => opcao);
44
+ const comum = erroDeUso({ raiz, faltando, crew: args.crew, caminhos: pedidos.map((p) => p.arquivo) });
45
+ if (comum) return { mensagem: comum, comUso: faltando.length > 0 };
46
+ const crew = path.resolve(raiz, args.crew);
47
+ if (!existsSync(path.join(crew, 'crew.yaml'))) return { mensagem: COMUM.crewNaoEncontrada(args.crew) };
48
+ const saida = path.join(crew, 'output');
49
+ if (!runValido(args.run) || !ehPasta(path.join(saida, args.run))) return { mensagem: MSG.semExecucao(args.run, execucoes(saida)) };
50
+ return null;
51
+ }
52
+
53
+ /** Cada item da lista, uma vez: onde fica, se existe, se é de serviço e qual é o canal do formato. */
54
+ async function classificar(raiz, pedidos) {
55
+ const itens = [];
56
+ const vistos = new Set();
57
+ for (const { arquivo, formato } of pedidos) {
58
+ const abs = path.resolve(raiz, arquivo);
59
+ if (vistos.has(`${abs}|${formato ?? ''}`)) continue;
60
+ vistos.add(`${abs}|${formato ?? ''}`);
61
+ const rel = relativoAoProjeto(raiz, arquivo);
62
+ itens.push({ arquivo, formato, abs, rel, tipo: tipoNoDisco(abs), servico: ehDeServico(rel), canal: await canalDoFormato(raiz, formato) });
63
+ }
64
+ return itens;
65
+ }
66
+
67
+ /** Verifica na origem, separa por canal e monta tudo o que a entrega mostra; não escreve nada. */
68
+ async function montar(raiz, args, itens) {
69
+ const arquivosDaLista = itens.map((i) => (i.formato ? { arquivo: i.arquivo, formato: i.formato } : i.arquivo));
70
+ const verificacao = await verificar({ raiz, crew: args.crew, arquivos: arquivosDaLista, semPadraoDeBlog: true });
71
+ const { arquivos, avisos } = nomear(await separar(itens));
72
+ const pendencias = pendenciasPorPasta(raiz, itens, verificacao);
73
+ const presentes = new Set([...arquivos.map((a) => a.pasta), ...pendencias.keys()]);
74
+ const dados = {
75
+ crew: path.basename(path.resolve(raiz, args.crew)), run: args.run, arquivos, avisos, pendencias,
76
+ pastas: Object.keys(CANAIS).filter((c) => presentes.has(c)),
77
+ alertas: await alertasDeTamanho(raiz, itens, arquivos, verificacao),
78
+ naoConferido: naoConferido(raiz, itens, verificacao),
79
+ vaiPublicar: args.vaiPublicar,
80
+ };
81
+ return { dados, leiame: montarLeiame(dados), relatorio: `${formatarRelatorio(verificacao)}\n` };
82
+ }
83
+
84
+ /** O resumo da tela: a pasta, a situação de cada canal, o que falta, os avisos e o LEIA-ME. */
85
+ function resumo(execucao, { crew, run, pastas, pendencias, avisos, alertas }, deServico) {
86
+ const comPendencia = [...pastas, OUTROS].filter((p) => pendencias.has(p));
87
+ const notas = [...alertas, ...deServico.map((i) => MSG.deServico(i.rel)), ...avisos.map((a) => a.tela ?? a.texto)];
88
+ return [
89
+ `Entrega da execução ${run} da crew ${crew}`,
90
+ `Pasta: ${execucao}/entrega`,
91
+ ...pastas.map((p) => `- ${CANAIS[p]}: ${pendencias.has(p) ? TEXTO.naoPronto : TEXTO.pronto}`),
92
+ ...frasesDePendencia(comPendencia, pendencias).flatMap((frase, i) => [frase, ...pendencias.get(comPendencia[i]).map((l) => `- ${l}`)]),
93
+ ...(notas.length ? ['Avisos:', ...notas.map((l) => `- ${l}`)] : []),
94
+ `LEIA-ME: ${execucao}/entrega/LEIA-ME.md`,
95
+ ];
96
+ }
97
+
98
+ async function entregar(raiz, args, itens, escrever) {
99
+ const validos = itens.filter((i) => !i.servico);
100
+ const execucao = relativoAoProjeto(raiz, path.join(args.crew, 'output', args.run));
101
+ const { dados, leiame, relatorio } = await montar(raiz, args, validos);
102
+ const falha = await gravarEntrega(path.resolve(raiz, execucao), dados.arquivos, { leiame, relatorio });
103
+ if (falha) {
104
+ escrever(MSG.falhaDeEscrita(relativoAoProjeto(raiz, falha)));
105
+ escrever('ENTREGA:INCOMPLETA');
106
+ return 0;
107
+ }
108
+ for (const linha of resumo(execucao, dados, itens.filter((i) => i.servico))) escrever(linha);
109
+ escrever(dados.pendencias.size ? 'ENTREGA:INCOMPLETA' : 'ENTREGA:OK');
110
+ return 0;
111
+ }
112
+
113
+ /**
114
+ * @param {string[]} argv
115
+ * @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`
116
+ * @returns {Promise<number>} 0 = a linha `ENTREGA:` saiu (ou `--ajuda`) · 1 = erro de uso, ou erro
117
+ * que impediu a entrega inteira (uma linha em PT-BR, sem linha `ENTREGA:`, nada escrito)
118
+ */
119
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
120
+ const args = lerArgs(argv);
121
+ const sair = (...linhas) => linhas.forEach((l) => escrever(l)) ?? 1;
122
+ if (args.ajuda) return sair(USO) && 0;
123
+ const pedidos = lerLista(args.arquivos);
124
+ const erro = erroDeChamada(cwd, args, pedidos);
125
+ if (erro) return sair(erro.mensagem, ...(erro.comUso ? [USO] : []));
126
+ try {
127
+ const itens = await classificar(cwd, pedidos);
128
+ const validos = itens.filter((i) => !i.servico);
129
+ if (!validos.some((i) => i.tipo === 'arquivo')) return sair(MSG.nenhumArquivo);
130
+ const canais = new Set(validos.map((i) => i.canal));
131
+ const fora = args.vaiPublicar.find((canal) => !ehCanal(canal) || !canais.has(canal));
132
+ if (fora !== undefined) return sair(MSG.semCanal(fora));
133
+ return await entregar(cwd, args, itens, escrever);
134
+ } catch (erroGeral) {
135
+ return sair(`Não consegui montar a entrega: ${limpar(erroGeral?.message ?? erroGeral)}`);
136
+ }
137
+ }
138
+
139
+ if (ehPrincipal(import.meta.url)) {
140
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
141
+ }
@@ -12,7 +12,7 @@ export const FORMATOS_DA_TABELA = [...BLOG, 'instagram-feed', 'linkedin-post', '
12
12
  const FORMATO_DA_PECA = { legenda: 'instagram-feed', slides: 'instagram-feed', post: 'linkedin-post', tweet: 'twitter-post' };
13
13
  // O que cada rótulo faz, por formato declarado. Sem formato declarado valem todos: os
14
14
  // inconfundíveis abrem peça e os outros só se juntam a uma peça já aberta.
15
- const ROTULOS = {
15
+ export const ROTULOS = {
16
16
  'instagram-feed': { CAPTION: 'legenda', SLIDES: 'slides', HASHTAGS: 'hashtags' },
17
17
  'linkedin-post': { HOOK: 'post', BODY: 'parte', INSIGHTS: 'parte', CTA: 'parte', HASHTAGS: 'hashtags' },
18
18
  'twitter-post': { TWEET: 'tweet' },
@@ -159,8 +159,8 @@ function fechar(entradas) {
159
159
  * Lê as peças de um texto, na ordem em que aparecem.
160
160
  * @param {string} texto conteúdo de um arquivo `.md` ou `.txt`, já sem o BOM
161
161
  * @param {string|null} [formato] formato declarado (`caminho=formato`); null = sem formato declarado
162
- * @param {{ formatoDeBlog?: string }} [opcoes] sem formato declarado, o formato em que o título do
163
- * frontmatter é medido (`--formato`)
162
+ * @param {{ formatoDeBlog?: string|null }} [opcoes] sem formato declarado, o formato em que o
163
+ * título do frontmatter é medido (`--formato`); null = não é peça (a entrega chama assim)
164
164
  * @returns {object[]} peças `{ tipo, formato, texto, origem, cabecalho, ordem }`
165
165
  * · `tipo`: titulo, meta, legenda, hashtags, slides, post ou tweet
166
166
  * · `formato`: o formato cujo limite vale para a peça
@@ -180,7 +180,7 @@ export function lerPecas(texto, formato = null, { formatoDeBlog = 'blog-post' }
180
180
  if (BLOG.includes(tabela)) pecasDoBlog(e, texto, trechos, tabela, true);
181
181
  else {
182
182
  // Fora da tabela os rótulos não são lidos; sem formato declarado, título no frontmatter é blog.
183
- if (formato == null) pecasDoBlog(e, texto, trechos, formatoDeBlog, false);
183
+ if (formato == null && formatoDeBlog) pecasDoBlog(e, texto, trechos, formatoDeBlog, false);
184
184
  const rotulos = tabela ? ROTULOS[tabela] : formato == null ? TODOS_OS_ROTULOS : {};
185
185
  for (const trecho of trechos) pecasDoTrecho(e, trecho, rotulos);
186
186
  pecaDoArquivoInteiro(e, marcas);
@@ -110,18 +110,36 @@ function abrir(trechos, trecho) {
110
110
  return trechos.at(-1);
111
111
  }
112
112
 
113
+ /**
114
+ * Peças a que algum cabeçalho de baixo do cabeçalho de nível 1 da posição `i` é candidato; a
115
+ * seção dele vai até o próximo de nível 1 ou até um rótulo.
116
+ */
117
+ function tiposSobOTitulo(marcas, i, formato) {
118
+ const tipos = new Set();
119
+ let pilha = [marcas[i]];
120
+ for (let j = i + 1; j < marcas.length && !marcas[j].rotulo && marcas[j].nivel !== 1; j++) {
121
+ if (!marcas[j].nivel) continue;
122
+ pilha = pilha.filter((c) => c.nivel < marcas[j].nivel);
123
+ for (const tipo of candidaturas(marcas[j].norm, pilha.map((c) => c.norm), formato)) tipos.add(tipo);
124
+ pilha.push(marcas[j]);
125
+ }
126
+ return tipos;
127
+ }
128
+
113
129
  /**
114
130
  * Percorre as marcas e devolve os trechos, na ordem do texto: `{ rotulo, linhas }` para cada
115
131
  * linha de rótulo e `{ titulo, tipos, canal, linhas }` para cada cabeçalho candidato a peça
116
132
  * (`canal`: na seção de hashtags, o formato do canal que o cabeçalho cita; senão null).
117
133
  * A seção de cabeçalho vai até o próximo cabeçalho do mesmo nível ou acima, ou até um rótulo; a
118
134
  * de rótulo, até o próximo rótulo ou até um cabeçalho candidato (regra 2).
135
+ * O cabeçalho de nível 1 que tem, abaixo dele, outro candidato à mesma peça é o título do
136
+ * arquivo: não abre essa peça (fase-u3a1-pasta-de-entrega.md, regra 4).
119
137
  */
120
138
  export function lerTrechos(marcas, formato) {
121
139
  const trechos = [];
122
140
  let pilha = []; // cabeçalhos abertos, do mais externo ao mais interno
123
141
  let rotulo = null; // seção de rótulo aberta
124
- for (const m of marcas) {
142
+ for (const [i, m] of marcas.entries()) {
125
143
  if (m.rotulo) {
126
144
  pilha = [];
127
145
  rotulo = abrir(trechos, { rotulo: m.rotulo });
@@ -129,7 +147,9 @@ export function lerTrechos(marcas, formato) {
129
147
  }
130
148
  if (m.nivel) {
131
149
  pilha = pilha.filter((c) => c.nivel < m.nivel);
132
- const tipos = candidaturas(m.norm, pilha.map((c) => c.norm), formato);
150
+ const todos = candidaturas(m.norm, pilha.map((c) => c.norm), formato);
151
+ const abaixo = m.nivel === 1 && todos.length ? tiposSobOTitulo(marcas, i, formato) : null;
152
+ const tipos = abaixo ? todos.filter((tipo) => !abaixo.has(tipo)) : todos;
133
153
  const canal = tipos[0] === 'hashtags' ? canalDe(m.norm) : null;
134
154
  const trecho = tipos.length ? abrir(trechos, { titulo: m.titulo, tipos, canal }) : null;
135
155
  pilha.push({ nivel: m.nivel, norm: m.norm, trecho });
@@ -135,6 +135,8 @@ function resumir(arquivos, naoTexto, notas) {
135
135
  * @param {Array<string|{ arquivo: string, formato?: string }>} o.arquivos caminho (sem formato
136
136
  * declarado) ou `{ arquivo, formato }` (formato declarado)
137
137
  * @param {string} [o.formato] limites de blog do item sem formato declarado que tem título no frontmatter
138
+ * @param {boolean} [o.semPadraoDeBlog] só a entrega passa true: no item sem formato declarado, o
139
+ * título do frontmatter não é medido como blog (fase-u3a1-pasta-de-entrega.md, regra 17)
138
140
  * @param {Function} [o.regraDeTeste] SÓ PARA TESTE: regra extra, chamada com `{ arquivo, formato,
139
141
  * texto }` em cada arquivo de texto; serve para simular uma regra que lança erro
140
142
  * @returns {Promise<object>} `{ arquivos, naoTexto, notas, bloqueios, alertas, naoMedidos, status }`
@@ -144,11 +146,11 @@ function resumir(arquivos, naoTexto, notas) {
144
146
  * · `naoTexto`: caminhos que não são texto · `naoMedidos`: linhas "Não medido"/"Não verificado"
145
147
  * · `status`: OK, BLOQUEADA ou AGUARDANDO_USUARIO
146
148
  */
147
- export async function verificar({ raiz, crew, arquivos, formato = 'blog-post', regraDeTeste }) {
149
+ export async function verificar({ raiz, crew, arquivos, formato = 'blog-post', semPadraoDeBlog = false, regraDeTeste }) {
148
150
  const entradas = semRepetidas(raiz, arquivos.map(normalizar));
149
151
  const erro = erroDeUso({ raiz, crew, caminhos: entradas.map((e) => e.arquivo) });
150
152
  if (erro) throw new Error(erro);
151
- const ctx = await prepararContexto(raiz, crew, formato);
153
+ const ctx = await prepararContexto(raiz, crew, semPadraoDeBlog ? null : formato);
152
154
  const resultado = [];
153
155
  const naoTexto = [];
154
156
  for (const entrada of entradas) {