@aksp/opencrew 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +19 -5
- package/package.json +1 -1
- package/src/cli.js +2 -1
- package/src/commands/init.js +26 -18
- package/src/lib/migrations.js +40 -3
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/prompts/build.prompt.md +2 -0
- package/templates/_opencrew/core/runner.pipeline.md +54 -26
- package/templates/_opencrew/core/scripts/comum.mjs +48 -0
- package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +82 -0
- package/templates/_opencrew/core/scripts/conferir-fontes/coleta.mjs +104 -0
- package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +52 -0
- package/templates/_opencrew/core/scripts/conferir-fontes.mjs +82 -136
- package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +33 -0
- package/templates/_opencrew/core/scripts/verificar/arquivos.mjs +50 -0
- package/templates/_opencrew/core/scripts/verificar/html.mjs +52 -0
- package/templates/_opencrew/core/scripts/verificar/leitura.mjs +138 -71
- package/templates/_opencrew/core/scripts/verificar/medicao.mjs +142 -0
- package/templates/_opencrew/core/scripts/verificar/pecas.mjs +190 -0
- package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +100 -0
- package/templates/_opencrew/core/scripts/verificar/regras.mjs +110 -91
- package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +43 -0
- package/templates/_opencrew/core/scripts/verificar/secoes.mjs +145 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +162 -88
|
@@ -1,104 +1,171 @@
|
|
|
1
|
-
// Leitura para o verificador automático:
|
|
2
|
-
//
|
|
1
|
+
// Leitura para o verificador automático: texto sem a marca de início de arquivo (BOM),
|
|
2
|
+
// frontmatter, limites dos formatos (constraints:) e domínio do site. Node puro, sem dependências.
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
/** Tira a marca invisível de início de arquivo (BOM), se houver. */
|
|
8
|
+
export const semBom = (texto) => (texto.charCodeAt(0) === 0xfeff ? texto.slice(1) : texto);
|
|
9
|
+
|
|
10
|
+
/** Lê um arquivo de texto em UTF-8, sem o BOM. */
|
|
11
|
+
export async function lerTexto(arquivo) {
|
|
12
|
+
return semBom(await readFile(arquivo, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Sem acentos, mantendo maiúsculas e minúsculas. */
|
|
16
|
+
export const semDiacritico = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
|
17
|
+
|
|
18
|
+
/** Sem acentos e em minúsculas: a forma em que cabeçalhos e termos são comparados. */
|
|
19
|
+
export const semAcento = (s) => semDiacritico(s).toLowerCase();
|
|
20
|
+
|
|
21
|
+
// As expressões que leem o resto de uma linha levam a flag `s`: com ela o `(.*)$` nunca falha e
|
|
22
|
+
// não há retrocesso, mesmo com um `\r` solto no meio de milhares de espaços.
|
|
23
|
+
const ENTRE_ASPAS = [/^"((?:[^"\\]|\\.)*)"(?:\s+#.*)?$/s, /^'((?:[^']|'')*)'(?:\s+#.*)?$/s, /^"(.*)"$/s, /^'(.*)'$/s];
|
|
24
|
+
|
|
25
|
+
/** Valor de uma linha: sem as aspas; fora delas, sem o comentário do fim (` # …`) e número vira número. */
|
|
7
26
|
function valorYaml(bruto) {
|
|
8
27
|
const v = bruto.trim();
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
for (const rx of ENTRE_ASPAS) {
|
|
29
|
+
const aspas = v.match(rx);
|
|
30
|
+
if (aspas) return aspas[1].replace(/\\"/g, '"');
|
|
31
|
+
}
|
|
32
|
+
const semComentario = v.replace(/\s#.*$/s, '').trimEnd();
|
|
33
|
+
return /^-?\d+(\.\d+)?$/.test(semComentario) ? Number(semComentario) : semComentario;
|
|
13
34
|
}
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
const quantas = (texto, aspa) => texto.replace(/\\./g, '').split(aspa).length - 1;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Valor que continua nas linhas de baixo: bloco `>` ou `|` (as linhas recuadas) ou aspas ainda
|
|
40
|
+
* abertas (até a linha que as fecha). Devolve o valor e a última linha usada, ou null.
|
|
41
|
+
*/
|
|
42
|
+
function valorLongo(v, linhas, i) {
|
|
43
|
+
if (/^[>|][+-]?$/.test(v)) {
|
|
44
|
+
let fim = i + 1;
|
|
45
|
+
while (fim < linhas.length && (linhas[fim].trim() === '' || /^\s/.test(linhas[fim]))) fim++;
|
|
46
|
+
const partes = linhas.slice(i + 1, fim).map((l) => l.trim());
|
|
47
|
+
return { valor: partes.join(v[0] === '>' ? ' ' : '\n').trim(), ultima: fim - 1 };
|
|
48
|
+
}
|
|
49
|
+
const aspa = v[0];
|
|
50
|
+
const aberta = (aspa === '"' || aspa === "'") && !(v.length > 1 && v.endsWith(aspa)) && quantas(v, aspa) % 2 === 1;
|
|
51
|
+
const fecha = aberta ? linhas.findIndex((l, n) => n > i && quantas(l, aspa) % 2 === 1) : -1;
|
|
52
|
+
if (fecha < 0) return null;
|
|
53
|
+
return { valor: valorYaml([v, ...linhas.slice(i + 1, fecha + 1).map((l) => l.trim())].join(' ')), ultima: fecha };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const CHAVE = /^([\p{L}\p{N}_-]+)\s*:\s*(.*)$/su;
|
|
57
|
+
const FILHO = /^\s+([\w-]+)\s*:\s*(.*)$/s;
|
|
58
|
+
// Linha que cabe num frontmatter: recuada, comentário, item de lista ou `chave: valor`.
|
|
59
|
+
const DE_YAML = /^(?:\s|#|-(?:\s|$)|[\p{L}\p{N}_-]+\s*:|[^\s:#-][^:]{0,60}:(?:\s|$))/u;
|
|
60
|
+
|
|
61
|
+
/** `chave: valor` e um nível de bloco (ex.: `constraints:`); null quando alguma linha não é YAML. */
|
|
62
|
+
function lerYaml(linhas) {
|
|
19
63
|
const dados = {};
|
|
20
64
|
let bloco = null;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
dados[chave] = bloco ? {} : valorYaml(valor);
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
const filho = linha.match(/^\s+([\w-]+):\s*(.*)$/);
|
|
65
|
+
let chaves = 0;
|
|
66
|
+
for (let i = 0; i < linhas.length; i++) {
|
|
67
|
+
if (linhas[i] !== '' && !DE_YAML.test(linhas[i])) return null;
|
|
68
|
+
const topo = linhas[i].match(CHAVE);
|
|
69
|
+
const filho = topo ? null : linhas[i].match(FILHO);
|
|
30
70
|
if (filho && bloco) dados[bloco][filho[1]] = valorYaml(filho[2]);
|
|
71
|
+
if (!topo) continue;
|
|
72
|
+
chaves += 1;
|
|
73
|
+
const longo = valorLongo(topo[2].trim(), linhas, i);
|
|
74
|
+
bloco = !longo && topo[2].trim() === '' ? topo[1] : null;
|
|
75
|
+
dados[topo[1]] = bloco ? {} : longo ? longo.valor : valorYaml(topo[2]);
|
|
76
|
+
if (longo) i = longo.ultima;
|
|
31
77
|
}
|
|
32
|
-
return dados;
|
|
78
|
+
return chaves ? dados : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* O frontmatter do começo do arquivo: `{ dados, fim }`, ou null. Um bloco entre duas linhas `---`
|
|
83
|
+
* só é frontmatter se parece YAML (ao menos uma chave e nenhuma linha de prosa); senão o `---` é
|
|
84
|
+
* um separador e o texto fica inteiro.
|
|
85
|
+
*/
|
|
86
|
+
function acharFrontmatter(texto) {
|
|
87
|
+
const m = texto.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
88
|
+
const dados = m && lerYaml(m[1].split(/\r?\n/));
|
|
89
|
+
return dados ? { dados, fim: m[0].length } : null;
|
|
33
90
|
}
|
|
34
91
|
|
|
92
|
+
/** Frontmatter YAML simples: `chave: valor` e um nível de bloco (ex.: `constraints:`). */
|
|
93
|
+
export const lerFrontmatter = (texto) => acharFrontmatter(texto)?.dados ?? null;
|
|
94
|
+
|
|
35
95
|
/** Corpo do texto sem o frontmatter. */
|
|
36
96
|
export function semFrontmatter(texto) {
|
|
37
|
-
|
|
97
|
+
const fm = acharFrontmatter(texto);
|
|
98
|
+
return fm ? texto.slice(fm.fim) : texto;
|
|
38
99
|
}
|
|
39
100
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
101
|
+
const PASTA_LOCAL = '`_opencrew/best-practices.local/`';
|
|
102
|
+
const PASTA_CORE = '`_opencrew/core/best-practices/`';
|
|
103
|
+
const NOTA = {
|
|
104
|
+
semFormato: (id) => `Formato "${id}" não encontrado em ${PASTA_LOCAL} nem em ${PASTA_CORE}.`,
|
|
105
|
+
overlaySemLimites: (id) => `O arquivo \`_opencrew/best-practices.local/${id}.md\` não declara limites (\`constraints:\`); usei os do core.`,
|
|
106
|
+
limiteInvalido: (id, escritos) => `O arquivo \`_opencrew/best-practices.local/${id}.md\` tem um limite que não é número inteiro (${escritos}); usei o do core.`,
|
|
107
|
+
};
|
|
108
|
+
// Limites que o verificador mede: no overlay local só valem com número inteiro a partir de 0.
|
|
109
|
+
const MEDIDOS = new Set('title_max_chars meta_description_chars min_internal_links min_external_links caption_max_chars hashtags_max carousel_max_slides post_max_chars tweet_max_chars'.split(' '));
|
|
110
|
+
|
|
111
|
+
/** Limite escrito entre aspas (`hashtags_max: "5"`) vale como número. */
|
|
112
|
+
const comoNumero = ([chave, v]) => [chave, typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v];
|
|
113
|
+
|
|
114
|
+
/** `constraints:` de um best-practice; `declarados` é null quando o arquivo não os declara. */
|
|
115
|
+
async function constraintsDe(raiz, pasta, formatoId) {
|
|
116
|
+
const arquivo = path.join(raiz, '_opencrew', ...pasta, `${formatoId}.md`);
|
|
117
|
+
if (!existsSync(arquivo)) return { existe: false, declarados: null };
|
|
118
|
+
const texto = await lerTexto(arquivo);
|
|
119
|
+
const c = lerFrontmatter(texto)?.constraints;
|
|
120
|
+
return { existe: true, texto, declarados: c && typeof c === 'object' ? Object.fromEntries(Object.entries(c).map(comoNumero)) : null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** O valor de `chave:` como está escrito no arquivo, sem o comentário do fim da linha. */
|
|
124
|
+
function comoEscrito(texto, chave) {
|
|
125
|
+
const linha = texto.split(/\r?\n/).map((l) => l.match(FILHO)).find((m) => m?.[1] === chave);
|
|
126
|
+
return linha ? linha[2].replace(/\s#.*$/s, '').trim() : '';
|
|
50
127
|
}
|
|
51
128
|
|
|
52
129
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
130
|
+
* Limites do overlay local que cobrem os do core. O limite medido que não é número inteiro a
|
|
131
|
+
* partir de 0 ("2.200", "dois mil") fica de fora e é devolvido em `invalidos`, como foi escrito.
|
|
55
132
|
*/
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const corpo = [];
|
|
64
|
-
for (const seguinte of linhas.slice(i + 1)) {
|
|
65
|
-
const h2 = seguinte.match(/^(#{1,6})\s/);
|
|
66
|
-
if ((h2 && h2[1].length <= nivel) || /^---\s*$/.test(seguinte)) break;
|
|
67
|
-
corpo.push(seguinte);
|
|
68
|
-
}
|
|
69
|
-
secoes.push({ titulo: h[2].trim(), nivel, corpo: corpo.join('\n').trim() });
|
|
70
|
-
});
|
|
71
|
-
return secoes;
|
|
133
|
+
function limitesDoOverlay({ declarados, texto }) {
|
|
134
|
+
const invalido = ([chave, v]) => MEDIDOS.has(chave) && !(Number.isInteger(v) && v >= 0);
|
|
135
|
+
const pares = Object.entries(declarados ?? {});
|
|
136
|
+
return {
|
|
137
|
+
validos: Object.fromEntries(pares.filter((p) => !invalido(p))),
|
|
138
|
+
invalidos: pares.filter(invalido).map(([chave]) => `\`${chave}: ${comoEscrito(texto, chave)}\``),
|
|
139
|
+
};
|
|
72
140
|
}
|
|
73
141
|
|
|
74
|
-
const semAcento = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
|
75
|
-
export { semAcento };
|
|
76
|
-
|
|
77
|
-
const ASPAS = /"([^"]+)"|“([^”]+)”|‘([^’]+)’|`([^`]+)`|(?<![\p{L}])'([^']+)'(?![\p{L}])/gu;
|
|
78
|
-
|
|
79
142
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
143
|
+
* Limites do formato: os `constraints:` do core com as chaves que o arquivo do usuário
|
|
144
|
+
* (`_opencrew/best-practices.local/<id>.md`, nunca tocado pelo update) declarar por cima; o
|
|
145
|
+
* limite medido que lá não é número inteiro não cobre o do core e vira nota.
|
|
146
|
+
* @returns {Promise<{ limites: object|null, nota: string|null }>} `limites` null = formato não
|
|
147
|
+
* encontrado; `nota` = aviso para a seção "Notas" do relatório
|
|
82
148
|
*/
|
|
83
|
-
export async function
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
else semAspas += 1;
|
|
94
|
-
}
|
|
95
|
-
return { existe: true, termos, semAspas };
|
|
149
|
+
export async function lerLimites(raiz, formatoId) {
|
|
150
|
+
const id = String(formatoId);
|
|
151
|
+
if (!/^[a-z0-9-]+$/.test(id)) return { limites: null, nota: NOTA.semFormato(id) };
|
|
152
|
+
const core = await constraintsDe(raiz, ['core', 'best-practices'], id);
|
|
153
|
+
const local = await constraintsDe(raiz, ['best-practices.local'], id);
|
|
154
|
+
if (!core.existe && !local.existe) return { limites: null, nota: NOTA.semFormato(id) };
|
|
155
|
+
const semLimites = local.existe && !local.declarados && core.existe;
|
|
156
|
+
const { validos, invalidos } = limitesDoOverlay(local);
|
|
157
|
+
const nota = semLimites ? NOTA.overlaySemLimites(id) : invalidos.length ? NOTA.limiteInvalido(id, invalidos.join('; ')) : null;
|
|
158
|
+
return { limites: { ...core.declarados, ...validos }, nota };
|
|
96
159
|
}
|
|
97
160
|
|
|
98
161
|
/** Domínio do site da empresa (para separar links internos de externos), se houver. */
|
|
99
162
|
export async function lerDominioDoSite(raiz) {
|
|
100
163
|
const arquivo = path.join(raiz, '_opencrew', '_memory', 'company.md');
|
|
101
164
|
if (!existsSync(arquivo)) return null;
|
|
102
|
-
const
|
|
103
|
-
|
|
165
|
+
for (const linha of (await lerTexto(arquivo)).split('\n')) {
|
|
166
|
+
const palavra = linha.search(/site|website/i);
|
|
167
|
+
const m = palavra < 0 ? null : linha.slice(palavra).match(/https?:\/\/([^\s/)>\]]+)/i);
|
|
168
|
+
if (m) return m[1].replace(/^www\./, '').toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
104
171
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Medição das peças: compara cada peça com o limite do seu formato (`constraints:`) e diz o que
|
|
2
|
+
// não foi medido. Máximos bloqueiam (fácil de corrigir: encurtar); mínimos alertam (podem
|
|
3
|
+
// depender do usuário). Spec: fase-r1-reparos-1-6-1.md, regras 3 e 6.
|
|
4
|
+
import { BLOG, secoesDeOutroCanal } from './secoes.mjs';
|
|
5
|
+
import { contarSlides, listarHashtags } from './pecas.mjs';
|
|
6
|
+
import { contar, item, NAO_MEDIDO } from './regras.mjs';
|
|
7
|
+
|
|
8
|
+
const NOME = { titulo: 'Título (SEO)', meta: 'Meta description', legenda: 'Legenda Instagram', slides: 'Carrossel Instagram', post: 'Post LinkedIn', tweet: 'Tweet' };
|
|
9
|
+
const NUMERADA = { legenda: 'legenda', slides: 'carrossel', post: 'post' };
|
|
10
|
+
const DITA = { titulo: 'título', meta: 'meta description', legenda: 'legenda', hashtags: 'hashtags', slides: 'slides', post: 'post', tweet: 'tweet' };
|
|
11
|
+
const LIMITE = {
|
|
12
|
+
titulo: 'title_max_chars',
|
|
13
|
+
meta: 'meta_description_chars',
|
|
14
|
+
legenda: 'caption_max_chars',
|
|
15
|
+
hashtags: 'hashtags_max',
|
|
16
|
+
slides: 'carousel_max_slides',
|
|
17
|
+
post: 'post_max_chars',
|
|
18
|
+
tweet: 'tweet_max_chars',
|
|
19
|
+
};
|
|
20
|
+
// Peça principal de cada formato da tabela: sem ela, o arquivo sai como "Não medido".
|
|
21
|
+
const PRINCIPAL = {
|
|
22
|
+
'blog-post': [['titulo'], 'o título'],
|
|
23
|
+
'blog-seo': [['titulo'], 'o título'],
|
|
24
|
+
'instagram-feed': [['legenda', 'slides'], 'legenda nem slides'],
|
|
25
|
+
'linkedin-post': [['post'], 'o post'],
|
|
26
|
+
'twitter-post': [['tweet'], 'o tweet'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// O cabeçalho entra no relatório até 120 caracteres: a linha não cresce com ele.
|
|
30
|
+
const curto = (cabecalho) => (cabecalho?.length > 120 ? `${cabecalho.slice(0, 120).trimEnd()}…` : cabecalho);
|
|
31
|
+
|
|
32
|
+
/** Com mais de uma peça do mesmo tipo, o nome traz o número de ordem e o cabeçalho da seção. */
|
|
33
|
+
function nomeDaPeca(p, total) {
|
|
34
|
+
const base = NOME[p.tipo];
|
|
35
|
+
if (p.origem === 'arquivo') return `${base} (arquivo inteiro)`;
|
|
36
|
+
if (p.tipo === 'tweet') return p.porParagrafo || total > 1 ? `Tweet ${p.ordem}` : base;
|
|
37
|
+
if (total < 2) return base;
|
|
38
|
+
const cabecalho = curto(p.cabecalho);
|
|
39
|
+
return `${base} — ${NUMERADA[p.tipo]} ${p.ordem}${cabecalho ? ` (${cabecalho})` : ''}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function nomeDoItem(p, totais) {
|
|
43
|
+
const total = (tipo) => totais[`${tipo}|${p.formato}`] ?? 0;
|
|
44
|
+
if (p.tipo !== 'hashtags') return `${nomeDaPeca(p, total(p.tipo))} — ${p.tipo === 'slides' ? 'slides' : 'caracteres'}`;
|
|
45
|
+
const canal = p.formato === 'instagram-feed' ? 'Instagram' : 'LinkedIn';
|
|
46
|
+
return `${p.de ? nomeDaPeca({ ...p, tipo: p.de }, total(p.de)) : canal} — hashtags`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function medir(p) {
|
|
50
|
+
if (p.tipo === 'slides') return contarSlides(p.texto);
|
|
51
|
+
return p.tipo === 'hashtags' ? listarHashtags(p.texto).length : contar(p.texto);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Âncora da própria página (`#seção`), e-mail, telefone e WhatsApp não são link interno nem externo.
|
|
55
|
+
const NAO_CONTA = /^(#|mailto:|tel:|https?:\/\/(wa\.me|api\.whatsapp\.com)\/)/i;
|
|
56
|
+
|
|
57
|
+
/** O `]` da posição `fecha` fecha uma imagem (``)? Procura o `[` par até 500 caracteres atrás. */
|
|
58
|
+
function ehImagem(corpo, fecha) {
|
|
59
|
+
let nivel = 0;
|
|
60
|
+
for (let i = fecha - 1; i >= 0 && i >= fecha - 500; i--) {
|
|
61
|
+
if (corpo[i] === ']') nivel += 1;
|
|
62
|
+
else if (corpo[i] === '[' && nivel-- === 0) return corpo[i - 1] === '!';
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Alvos dos links markdown `](alvo)` ou `](alvo "título")`. Imagem não é link. */
|
|
68
|
+
function alvosDeLinks(corpo) {
|
|
69
|
+
const fecho = /(?:[ \t][^)\n]{0,300})?\)/y;
|
|
70
|
+
const alvos = [];
|
|
71
|
+
for (const m of corpo.matchAll(/\]\(([^)\s]{1,2000})/g)) {
|
|
72
|
+
fecho.lastIndex = m.index + m[0].length;
|
|
73
|
+
if (fecho.test(corpo) && !ehImagem(corpo, m.index)) alvos.push(m[1]);
|
|
74
|
+
}
|
|
75
|
+
return alvos;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Link relativo é interno; http(s) é interno quando o domínio é o do site. Malformado: fora. */
|
|
79
|
+
function ehInterno(url, dominio) {
|
|
80
|
+
if (!/^https?:\/\//i.test(url)) return true;
|
|
81
|
+
try {
|
|
82
|
+
return new URL(url).hostname.replace(/^www\./, '') === dominio;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function itensDeLinks(corpo, limites, dominio) {
|
|
89
|
+
const tipos = alvosDeLinks(corpo).filter((u) => !NAO_CONTA.test(u)).map((u) => ehInterno(u, dominio));
|
|
90
|
+
const minimo = (nome, medido, limite) =>
|
|
91
|
+
(typeof limite === 'number' ? [{ ...item(nome, medido, limite, medido < limite ? 'alerta' : 'ok'), minimo: true }] : []);
|
|
92
|
+
return [
|
|
93
|
+
...minimo('Links internos', tipos.filter((t) => t === true).length, limites?.min_internal_links),
|
|
94
|
+
...minimo('Links externos', tipos.filter((t) => t === false).length, limites?.min_external_links),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Linhas "Não medido" do arquivo: peça principal ausente, peça sem limite, seção de outro canal
|
|
100
|
+
* em blog declarado (cabeçalho é conteúdo: não é medida), formato fora da tabela.
|
|
101
|
+
*/
|
|
102
|
+
function naoMedidos({ pecas, corpo, formato, limites }, semLimite, medidas) {
|
|
103
|
+
const linhas = [];
|
|
104
|
+
const [tipos, dita] = PRINCIPAL[formato] ?? [];
|
|
105
|
+
const achou = tipos && pecas.some((p) => p.formato === formato && tipos.includes(p.tipo));
|
|
106
|
+
if (tipos && !achou) linhas.push(item(NAO_MEDIDO, null, null, 'alerta', `não encontrei ${dita} neste arquivo (formato ${formato})`));
|
|
107
|
+
for (const detalhe of semLimite) linhas.push(item(NAO_MEDIDO, null, null, null, detalhe));
|
|
108
|
+
for (const cabecalho of BLOG.includes(formato) ? secoesDeOutroCanal(corpo) : []) {
|
|
109
|
+
linhas.push(item(NAO_MEDIDO, null, null, null, `seção de outro canal num arquivo de blog: ${curto(cabecalho)}`));
|
|
110
|
+
}
|
|
111
|
+
if (formato && !tipos && limites[formato] && !medidas) {
|
|
112
|
+
linhas.push(item(NAO_MEDIDO, null, null, null, `o verificador ainda não mede os limites do formato ${formato}`));
|
|
113
|
+
}
|
|
114
|
+
return linhas;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mede as peças de um arquivo `.md` ou `.txt`.
|
|
119
|
+
* @param {{ pecas: object[], corpo: string, formato: string|null, limites: object, dominio: string|null }} o
|
|
120
|
+
* `formato`: o declarado, ou null · `limites`: `{ [formato]: constraints | null }`
|
|
121
|
+
* @returns {{ itens: object[], medidas: number }} `medidas`: quantas peças tinham limite numérico
|
|
122
|
+
*/
|
|
123
|
+
export function medirPecas(o) {
|
|
124
|
+
const totais = {};
|
|
125
|
+
for (const p of o.pecas) totais[`${p.tipo}|${p.formato}`] = (totais[`${p.tipo}|${p.formato}`] ?? 0) + 1;
|
|
126
|
+
const itens = [];
|
|
127
|
+
const semLimite = new Set();
|
|
128
|
+
let medidas = 0;
|
|
129
|
+
for (const p of o.pecas) {
|
|
130
|
+
const limite = o.limites[p.formato]?.[LIMITE[p.tipo]];
|
|
131
|
+
if (typeof limite !== 'number') semLimite.add(`${DITA[p.tipo]}: sem limite definido no formato ${p.formato}`);
|
|
132
|
+
else {
|
|
133
|
+
medidas += 1;
|
|
134
|
+
const medido = medir(p);
|
|
135
|
+
// Nos tweets por parágrafo, só o que passa do limite gera linha.
|
|
136
|
+
if (!p.porParagrafo || medido > limite) itens.push(item(nomeDoItem(p, totais), medido, limite, medido > limite ? 'bloqueio' : 'ok'));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const blog = BLOG.includes(o.formato) ? o.formato : o.pecas.find((p) => p.tipo === 'titulo')?.formato;
|
|
140
|
+
if (blog) itens.push(...itensDeLinks(o.corpo, o.limites[blog], o.dominio));
|
|
141
|
+
return { itens: [...itens, ...naoMedidos(o, semLimite, medidas)], medidas };
|
|
142
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Leitor de peças do verificador: acha, num texto, os trechos que têm limite próprio no formato
|
|
2
|
+
// (título, meta description, legenda, hashtags, slides, post, tweet), nos dois jeitos de
|
|
3
|
+
// escrever — cabeçalho markdown e rótulo `=== RÓTULO ===`. Sem estado global e sem depender do
|
|
4
|
+
// relatório: recebe o texto e o formato declarado e devolve as peças.
|
|
5
|
+
// Spec: specs/fase-r1-reparos-1-6-1.md, regras 1 a 5 (repositório do OpenCrew).
|
|
6
|
+
import { lerFrontmatter, semFrontmatter } from './leitura.mjs';
|
|
7
|
+
import { BLOG, marcar, lerTrechos, numeroDoSlide } from './secoes.mjs';
|
|
8
|
+
|
|
9
|
+
/** Formatos que o verificador mede (a tabela da regra 3). */
|
|
10
|
+
export const FORMATOS_DA_TABELA = [...BLOG, 'instagram-feed', 'linkedin-post', 'twitter-post'];
|
|
11
|
+
|
|
12
|
+
const FORMATO_DA_PECA = { legenda: 'instagram-feed', slides: 'instagram-feed', post: 'linkedin-post', tweet: 'twitter-post' };
|
|
13
|
+
// O que cada rótulo faz, por formato declarado. Sem formato declarado valem todos: os
|
|
14
|
+
// inconfundíveis abrem peça e os outros só se juntam a uma peça já aberta.
|
|
15
|
+
const ROTULOS = {
|
|
16
|
+
'instagram-feed': { CAPTION: 'legenda', SLIDES: 'slides', HASHTAGS: 'hashtags' },
|
|
17
|
+
'linkedin-post': { HOOK: 'post', BODY: 'parte', INSIGHTS: 'parte', CTA: 'parte', HASHTAGS: 'hashtags' },
|
|
18
|
+
'twitter-post': { TWEET: 'tweet' },
|
|
19
|
+
};
|
|
20
|
+
const TODOS_OS_ROTULOS = Object.assign({}, ...Object.values(ROTULOS));
|
|
21
|
+
|
|
22
|
+
/** Hashtags de um texto, na ordem em que aparecem. Âncora de URL e entidade `'` não contam. */
|
|
23
|
+
export const listarHashtags = (texto) => texto.replace(/https?:\/\/\S+/gi, ' ').match(/(?<!&)#[\p{L}\p{N}_]+/gu) ?? [];
|
|
24
|
+
|
|
25
|
+
/** Quantos números N diferentes aparecem em linhas que começam por "Slide N". */
|
|
26
|
+
export function contarSlides(texto) {
|
|
27
|
+
const numeros = texto.split('\n').map(numeroDoSlide).filter((n) => n != null);
|
|
28
|
+
return new Set(numeros).size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parágrafos: blocos de linhas separados por linha em branco. */
|
|
32
|
+
function paragrafos(texto) {
|
|
33
|
+
const blocos = [[]];
|
|
34
|
+
for (const linha of texto.split('\n')) {
|
|
35
|
+
if (linha.trim()) blocos.at(-1).push(linha);
|
|
36
|
+
else if (blocos.at(-1).length) blocos.push([]);
|
|
37
|
+
}
|
|
38
|
+
return blocos.filter((b) => b.length).map((b) => b.join('\n'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Abre uma peça. Legenda e post ganham, logo a seguir, a peça das suas hashtags. */
|
|
42
|
+
function abrir(e, tipo, origem, extra = {}) {
|
|
43
|
+
const peca = { tipo, formato: FORMATO_DA_PECA[tipo], texto: '', origem, cabecalho: null, ordem: 0, ...extra };
|
|
44
|
+
const entrada = { peca, partes: [], dono: null, hashtags: null };
|
|
45
|
+
e.entradas.push(entrada);
|
|
46
|
+
if (tipo === 'legenda' || tipo === 'post') {
|
|
47
|
+
entrada.hashtags = { peca: { ...peca, tipo: 'hashtags', de: tipo }, partes: [], dono: entrada };
|
|
48
|
+
e.entradas.push(entrada.hashtags);
|
|
49
|
+
e.dono = entrada;
|
|
50
|
+
e.donos[peca.formato] = entrada;
|
|
51
|
+
}
|
|
52
|
+
return entrada;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hashtags de rótulo ou de cabeçalho somam à legenda ou ao post aberto por último; quando o
|
|
57
|
+
* cabeçalho cita um canal (`canal`), à legenda ou ao post desse canal aberto por último.
|
|
58
|
+
*/
|
|
59
|
+
function juntarHashtags(e, texto, origem, cabecalho = null, canal = null) {
|
|
60
|
+
const dono = canal ? e.donos[canal] : e.dono;
|
|
61
|
+
const formato = canal ?? e.tabela;
|
|
62
|
+
if (dono) dono.hashtags.partes.push(texto);
|
|
63
|
+
else if (e.tabela && (formato === 'instagram-feed' || formato === 'linkedin-post')) {
|
|
64
|
+
abrir(e, 'hashtags', origem, { formato, cabecalho, de: null }).partes.push(texto);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pecaDoRotulo(e, papel, texto) {
|
|
69
|
+
if (papel === 'hashtags') juntarHashtags(e, texto, 'rotulo');
|
|
70
|
+
else if (papel === 'parte') {
|
|
71
|
+
// BODY, INSIGHTS e CTA pertencem ao post aberto por último; sem HOOK antes, abrem o primeiro.
|
|
72
|
+
if (!e.post && e.tabela === 'linkedin-post') e.post = abrir(e, 'post', 'rotulo');
|
|
73
|
+
e.post?.partes.push(texto);
|
|
74
|
+
} else if (papel) {
|
|
75
|
+
const aberta = abrir(e, papel, 'rotulo');
|
|
76
|
+
aberta.partes.push(texto);
|
|
77
|
+
if (papel === 'post') e.post = aberta;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pecasDoCabecalho(e, tipo, texto, { titulo: cabecalho, canal }) {
|
|
82
|
+
if (tipo === 'hashtags') juntarHashtags(e, texto, 'cabecalho', cabecalho, canal);
|
|
83
|
+
else if (tipo === 'tweet') {
|
|
84
|
+
for (const p of paragrafos(texto)) abrir(e, 'tweet', 'cabecalho', { cabecalho, porParagrafo: true }).partes.push(p);
|
|
85
|
+
} else abrir(e, tipo, 'cabecalho', { cabecalho }).partes.push(texto);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cabeçalho de legenda e de slides ao mesmo tempo abre uma peça só: slides, se o texto tem "Slide N". */
|
|
89
|
+
function semPecaDupla(tipos, texto) {
|
|
90
|
+
if (!tipos.includes('legenda') || !tipos.includes('slides')) return tipos;
|
|
91
|
+
const sobra = contarSlides(texto) ? 'legenda' : 'slides';
|
|
92
|
+
return tipos.filter((tipo) => tipo !== sobra);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pecasDoTrecho(e, trecho, rotulos) {
|
|
96
|
+
const texto = trecho.linhas.join('\n').trim();
|
|
97
|
+
if (trecho.rotulo) pecaDoRotulo(e, rotulos[trecho.rotulo], texto);
|
|
98
|
+
else for (const tipo of semPecaDupla(trecho.tipos, texto)) pecasDoCabecalho(e, tipo, texto, trecho);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Blog: título e meta description do frontmatter; senão, da primeira ocorrência do rótulo. */
|
|
102
|
+
function pecasDoBlog(e, texto, trechos, formato, lerRotulos) {
|
|
103
|
+
const fm = lerFrontmatter(texto) ?? {};
|
|
104
|
+
const achar = (tipo, chaves, rotulos) => {
|
|
105
|
+
const valor = chaves.map((c) => fm[c]).find((v) => v != null && typeof v !== 'object' && String(v).trim());
|
|
106
|
+
const trecho = valor == null && lerRotulos ? trechos.find((t) => rotulos.includes(t.rotulo)) : null;
|
|
107
|
+
if (valor == null && !trecho) return false;
|
|
108
|
+
// Título escrito como cabeçalho logo abaixo do rótulo: o "# " não é texto.
|
|
109
|
+
const escrito = trecho ? trecho.linhas.join('\n').trim().replace(/^#{1,6}\s+/, '') : String(valor);
|
|
110
|
+
abrir(e, tipo, trecho ? 'rotulo' : 'frontmatter', { formato }).partes.push(escrito);
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
const temTitulo = achar('titulo', ['title', 'titulo'], ['TITLE', 'TITLE TAG']);
|
|
114
|
+
if (temTitulo || lerRotulos) achar('meta', ['meta_description', 'meta_descricao'], ['META DESCRIPTION']);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Arquivo que é só o texto (sem cabeçalho nem rótulo): o corpo inteiro é o post ou o tweet. */
|
|
118
|
+
function pecaDoArquivoInteiro(e, marcas) {
|
|
119
|
+
const tipo = { 'linkedin-post': 'post', 'twitter-post': 'tweet' }[e.tabela];
|
|
120
|
+
if (!tipo || marcas.some((m) => m.rotulo || m.nivel)) return;
|
|
121
|
+
abrir(e, tipo, 'arquivo').partes.push(marcas.map((m) => m.linha).join('\n').trim());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `instagram-feed` sem peça de slides: os cabeçalhos "Slide N" do arquivo inteiro são o
|
|
126
|
+
* carrossel, quando trazem 2 ou mais números diferentes.
|
|
127
|
+
*/
|
|
128
|
+
function slidesDoArquivoInteiro(e, marcas) {
|
|
129
|
+
const temSlides = ({ peca, partes }) => peca.tipo === 'slides' && contarSlides(partes.join('\n')) > 0;
|
|
130
|
+
if (e.tabela !== 'instagram-feed' || e.entradas.some(temSlides)) return;
|
|
131
|
+
const titulos = marcas.filter((m) => m.nivel && numeroDoSlide(m.linha) != null).map((m) => m.linha).join('\n');
|
|
132
|
+
if (contarSlides(titulos) >= 2) abrir(e, 'slides', 'arquivo').partes.push(titulos);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Texto final e número de ordem de cada peça. Sem texto próprio, a peça não entra. */
|
|
136
|
+
function fechar(entradas) {
|
|
137
|
+
const contagem = {};
|
|
138
|
+
const proxima = ({ tipo, formato }) => {
|
|
139
|
+
const chave = `${tipo}|${formato}`;
|
|
140
|
+
contagem[chave] = (contagem[chave] ?? 0) + 1;
|
|
141
|
+
return contagem[chave];
|
|
142
|
+
};
|
|
143
|
+
const pecas = [];
|
|
144
|
+
for (const { peca, partes, dono } of entradas) {
|
|
145
|
+
const proprio = partes.filter(Boolean).join('\n\n');
|
|
146
|
+
const comDono = Boolean(dono?.peca.ordem);
|
|
147
|
+
if (peca.tipo === 'hashtags') {
|
|
148
|
+
peca.texto = listarHashtags(`${comDono ? dono.peca.texto : ''}\n${proprio}`).join(' ');
|
|
149
|
+
if (!comDono) peca.de = null;
|
|
150
|
+
} else peca.texto = peca.tipo === 'slides' && !contarSlides(proprio) ? '' : proprio;
|
|
151
|
+
if (!peca.texto && !comDono) continue;
|
|
152
|
+
peca.ordem = comDono ? dono.peca.ordem : proxima(peca);
|
|
153
|
+
pecas.push(peca);
|
|
154
|
+
}
|
|
155
|
+
return pecas;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lê as peças de um texto, na ordem em que aparecem.
|
|
160
|
+
* @param {string} texto conteúdo de um arquivo `.md` ou `.txt`, já sem o BOM
|
|
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`)
|
|
164
|
+
* @returns {object[]} peças `{ tipo, formato, texto, origem, cabecalho, ordem }`
|
|
165
|
+
* · `tipo`: titulo, meta, legenda, hashtags, slides, post ou tweet
|
|
166
|
+
* · `formato`: o formato cujo limite vale para a peça
|
|
167
|
+
* · `texto`: o texto da peça (nas hashtags, as hashtags achadas, separadas por espaço)
|
|
168
|
+
* · `origem`: de onde veio — cabecalho, rotulo, frontmatter ou arquivo (o arquivo inteiro)
|
|
169
|
+
* · `cabecalho`: texto do cabeçalho da seção, ou null
|
|
170
|
+
* · `ordem`: número da peça entre as do mesmo tipo e formato; as hashtags levam o da legenda
|
|
171
|
+
* ou do post a que pertencem (dito em `de`; null quando não pertencem a nenhum)
|
|
172
|
+
* · `porParagrafo`: true no tweet tirado de um parágrafo de uma seção de cabeçalho
|
|
173
|
+
*/
|
|
174
|
+
export function lerPecas(texto, formato = null, { formatoDeBlog = 'blog-post' } = {}) {
|
|
175
|
+
const tabela = FORMATOS_DA_TABELA.includes(formato) ? formato : null;
|
|
176
|
+
// Formato declarado fora da tabela: a linha `=== RÓTULO ===` é texto comum (regra 3 c).
|
|
177
|
+
const marcas = marcar(semFrontmatter(texto), formato == null || tabela != null);
|
|
178
|
+
const trechos = lerTrechos(marcas, tabela);
|
|
179
|
+
const e = { entradas: [], tabela, dono: null, donos: {}, post: null };
|
|
180
|
+
if (BLOG.includes(tabela)) pecasDoBlog(e, texto, trechos, tabela, true);
|
|
181
|
+
else {
|
|
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);
|
|
184
|
+
const rotulos = tabela ? ROTULOS[tabela] : formato == null ? TODOS_OS_ROTULOS : {};
|
|
185
|
+
for (const trecho of trechos) pecasDoTrecho(e, trecho, rotulos);
|
|
186
|
+
pecaDoArquivoInteiro(e, marcas);
|
|
187
|
+
slidesDoArquivoInteiro(e, marcas);
|
|
188
|
+
}
|
|
189
|
+
return fechar(e.entradas);
|
|
190
|
+
}
|