@aksp/opencrew 1.7.1 → 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.
- package/CHANGELOG.md +60 -0
- package/README.md +52 -2
- package/package.json +1 -1
- package/templates/AGENTS.md +3 -0
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/prompts/entrega.prompt.md +131 -0
- package/templates/_opencrew/core/runner.pipeline.md +13 -4
- package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +46 -0
- package/templates/_opencrew/core/scripts/entrega/canais.mjs +51 -0
- package/templates/_opencrew/core/scripts/entrega/fora.mjs +49 -0
- package/templates/_opencrew/core/scripts/entrega/gravar.mjs +83 -0
- package/templates/_opencrew/core/scripts/entrega/leiame.mjs +89 -0
- package/templates/_opencrew/core/scripts/entrega/leitor.mjs +120 -0
- package/templates/_opencrew/core/scripts/entrega/longas.mjs +109 -0
- package/templates/_opencrew/core/scripts/entrega/nomes.mjs +78 -0
- package/templates/_opencrew/core/scripts/entrega/passos.mjs +99 -0
- package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +87 -0
- package/templates/_opencrew/core/scripts/entrega/separar.mjs +103 -0
- package/templates/_opencrew/core/scripts/entrega/texto.mjs +95 -0
- package/templates/_opencrew/core/scripts/entregar.mjs +141 -0
- package/templates/_opencrew/core/scripts/verificar/pecas.mjs +4 -4
- package/templates/_opencrew/core/scripts/verificar/secoes.mjs +22 -2
- package/templates/_opencrew/core/scripts/verificar.mjs +4 -2
|
@@ -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
|
|
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
|
|
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) {
|