@aksp/opencrew 1.9.0 → 1.11.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 (53) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +98 -6
  3. package/package.json +1 -1
  4. package/src/commands/init.js +4 -5
  5. package/src/commands/update.js +8 -0
  6. package/src/lib/resumo.js +5 -1
  7. package/templates/AGENTS.md +17 -7
  8. package/templates/_opencrew/.opencrew-version +1 -1
  9. package/templates/_opencrew/core/architect.agent.yaml +25 -16
  10. package/templates/_opencrew/core/best-practices/_catalog.yaml +5 -0
  11. package/templates/_opencrew/core/best-practices/documento-oficial.md +144 -0
  12. package/templates/_opencrew/core/formato-da-crew.md +162 -0
  13. package/templates/_opencrew/core/modelos/documento-oficial.md +42 -0
  14. package/templates/_opencrew/core/prompts/build.prompt.md +33 -57
  15. package/templates/_opencrew/core/prompts/design.prompt.md +12 -11
  16. package/templates/_opencrew/core/prompts/discovery.prompt.md +23 -5
  17. package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
  18. package/templates/_opencrew/core/prompts/entrega.prompt.md +5 -4
  19. package/templates/_opencrew/core/prompts/repair.prompt.md +75 -84
  20. package/templates/_opencrew/core/runner.pipeline.md +9 -12
  21. package/templates/_opencrew/core/scripts/conserto/achados.mjs +158 -0
  22. package/templates/_opencrew/core/scripts/conserto/aplicar.mjs +156 -0
  23. package/templates/_opencrew/core/scripts/conserto/argumentos.mjs +51 -0
  24. package/templates/_opencrew/core/scripts/conserto/crew.mjs +126 -0
  25. package/templates/_opencrew/core/scripts/conserto/edicoes.mjs +133 -0
  26. package/templates/_opencrew/core/scripts/conserto/gravar.mjs +55 -0
  27. package/templates/_opencrew/core/scripts/conserto.mjs +82 -0
  28. package/templates/_opencrew/core/scripts/documento/argumentos.mjs +50 -0
  29. package/templates/_opencrew/core/scripts/documento/corpo.mjs +51 -0
  30. package/templates/_opencrew/core/scripts/documento/estilos.mjs +42 -0
  31. package/templates/_opencrew/core/scripts/documento/gravar.mjs +44 -0
  32. package/templates/_opencrew/core/scripts/documento/linha.mjs +58 -0
  33. package/templates/_opencrew/core/scripts/documento/marcacoes.mjs +55 -0
  34. package/templates/_opencrew/core/scripts/documento/markdown.mjs +92 -0
  35. package/templates/_opencrew/core/scripts/documento/pacote.mjs +82 -0
  36. package/templates/_opencrew/core/scripts/documento/perfil.mjs +70 -0
  37. package/templates/_opencrew/core/scripts/documento/png.mjs +20 -0
  38. package/templates/_opencrew/core/scripts/documento/projeto.mjs +60 -0
  39. package/templates/_opencrew/core/scripts/documento/tabelas.mjs +57 -0
  40. package/templates/_opencrew/core/scripts/documento/timbre.mjs +64 -0
  41. package/templates/_opencrew/core/scripts/documento/xml.mjs +95 -0
  42. package/templates/_opencrew/core/scripts/documento/zip.mjs +86 -0
  43. package/templates/_opencrew/core/scripts/documento.mjs +150 -0
  44. package/templates/_opencrew/core/scripts/entrega/canais.mjs +7 -3
  45. package/templates/_opencrew/core/scripts/entrega/comparar.mjs +2 -2
  46. package/templates/_opencrew/core/scripts/entrega/copia.mjs +1 -1
  47. package/templates/_opencrew/core/scripts/entrega/documentos.mjs +104 -0
  48. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +3 -3
  49. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +3 -2
  50. package/templates/_opencrew/core/scripts/entrega/passos.mjs +12 -2
  51. package/templates/_opencrew/core/scripts/entrega/separar.mjs +3 -0
  52. package/templates/_opencrew/core/scripts/entregar.mjs +12 -5
  53. package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +30 -5
@@ -0,0 +1,70 @@
1
+ // O perfil de documento oficial: um arquivo de texto do projeto, com linhas `chave: valor`.
2
+ // Aqui ele é lido e validado, sem tocar o disco; o logotipo é conferido em `projeto.mjs`.
3
+ // Spec: fase-u3b-documento-word.md, §3 e regra 10 (repositório do OpenCrew).
4
+
5
+ /** O que vale quando a chave não vem, ou vem vazia. */
6
+ export const PADRAO = Object.freeze({
7
+ logotipo: '', logotipo_largura_cm: 2.5, cabecalho_1: '', cabecalho_2: '', cabecalho_3: '', rodape: '', numero_pagina: true,
8
+ margem_esquerda_cm: 3, margem_direita_cm: 2, margem_superior_cm: 2.5, margem_inferior_cm: 2.5, fonte: 'Arial', tamanho_corpo_pt: 11,
9
+ });
10
+
11
+ export const MSG = {
12
+ chave: (n, chave) => `Perfil, linha ${n}: não conheço a chave ${chave}.`,
13
+ valor: (n, chave, esperado, valor) => `Perfil, linha ${n}: ${chave} precisa ser ${esperado}. Recebi: ${valor}.`,
14
+ };
15
+
16
+ // `chave: valor`; o espaço depois dos dois-pontos é opcional, mas um endereço (`https://…`) não é chave.
17
+ const LINHA = /^([a-z0-9_]+):(?!\/\/)[ \t]*(.*)$/;
18
+ const NUMERO = /^\d+(?:[.,]\d+)?$/;
19
+ const numero = (valor) => (NUMERO.test(valor) ? Number(valor.replace(',', '.')) : NaN);
20
+ const texto = { esperado: '', ler: (valor) => valor };
21
+ const deUmASeis = { esperado: 'um número de 1 a 6', ler: (valor) => (numero(valor) >= 1 && numero(valor) <= 6 ? numero(valor) : undefined) };
22
+ const SIM_OU_NAO = { sim: true, nao: false, 'não': false };
23
+
24
+ /** Como cada chave é lida: `ler` devolve o valor, ou `undefined` quando ele não serve. */
25
+ const CHAVES = {
26
+ logotipo: texto,
27
+ logotipo_largura_cm: deUmASeis,
28
+ cabecalho_1: texto,
29
+ cabecalho_2: texto,
30
+ cabecalho_3: texto,
31
+ rodape: texto,
32
+ numero_pagina: { esperado: 'sim ou nao', ler: (valor) => (Object.hasOwn(SIM_OU_NAO, valor.toLowerCase()) ? SIM_OU_NAO[valor.toLowerCase()] : undefined) },
33
+ margem_esquerda_cm: deUmASeis,
34
+ margem_direita_cm: deUmASeis,
35
+ margem_superior_cm: deUmASeis,
36
+ margem_inferior_cm: deUmASeis,
37
+ fonte: { esperado: 'um nome com até 40 letras, dígitos e espaços', ler: (valor) => (/^[\p{L}\p{N} ]{1,40}$/u.test(valor) ? valor : undefined) },
38
+ tamanho_corpo_pt: { esperado: 'um número de 8 a 14 (aceita meio ponto)', ler: (valor) => (numero(valor) >= 8 && numero(valor) <= 14 && Number.isInteger(numero(valor) * 2) ? numero(valor) : undefined) },
39
+ };
40
+
41
+ /** Tira as aspas em volta do valor, se as duas pontas têm a mesma. */
42
+ function semAspas(valor) {
43
+ const v = valor.trim();
44
+ return v.length >= 2 && (v[0] === '"' || v[0] === "'") && v.at(-1) === v[0] ? v.slice(1, -1).trim() : v;
45
+ }
46
+
47
+ /**
48
+ * Lê o texto do perfil. Só valem as linhas `chave: valor` cuja chave tem letras minúsculas,
49
+ * dígitos e `_`, a partir da primeira coluna; qualquer outra linha é comentário. Valor vazio vale
50
+ * o padrão. Chave repetida: vale a última.
51
+ * @param {string} bruto
52
+ * @returns {{ perfil: object, linhas: Record<string, number>, erro: string|null }} `perfil` com os
53
+ * padrões aplicados; `linhas`: o número da linha de cada chave lida; `erro`: a mensagem em PT-BR
54
+ */
55
+ export function lerPerfil(bruto) {
56
+ const perfil = { ...PADRAO };
57
+ const linhas = {};
58
+ const semBom = bruto.charCodeAt(0) === 0xfeff ? bruto.slice(1) : bruto;
59
+ for (const [i, linha] of semBom.split(/\r\n?|\n/).entries()) {
60
+ const [, chave, escrito = ''] = LINHA.exec(linha.trimEnd()) ?? [];
61
+ if (!chave) continue;
62
+ if (!Object.hasOwn(CHAVES, chave)) return { perfil, linhas, erro: MSG.chave(i + 1, chave) };
63
+ linhas[chave] = i + 1;
64
+ const valor = semAspas(escrito);
65
+ const lido = valor === '' ? PADRAO[chave] : CHAVES[chave].ler(valor);
66
+ if (lido === undefined) return { perfil, linhas, erro: MSG.valor(i + 1, chave, CHAVES[chave].esperado, valor) };
67
+ perfil[chave] = lido;
68
+ }
69
+ return { perfil, linhas, erro: null };
70
+ }
@@ -0,0 +1,20 @@
1
+ // Leitura mínima de um PNG: a assinatura de 8 bytes e a largura e a altura do bloco IHDR, para a
2
+ // proporção do logotipo. A imagem não é aberta nem regravada: vai para o documento como veio.
3
+ // Spec: fase-u3b-documento-word.md, §3 (repositório do OpenCrew).
4
+
5
+ const ASSINATURA = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
6
+ /** Tamanho máximo do logotipo: 2 MB. */
7
+ export const MAXIMO = 2 * 1024 * 1024;
8
+
9
+ /**
10
+ * @param {Uint8Array} bytes
11
+ * @returns {{ largura: number, altura: number }|null} null quando não é um PNG
12
+ */
13
+ export function lerPng(bytes) {
14
+ const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
15
+ if (b.length < 24 || ASSINATURA.some((valor, i) => b[i] !== valor)) return null;
16
+ if (b.toString('latin1', 12, 16) !== 'IHDR') return null;
17
+ const largura = b.readUInt32BE(16);
18
+ const altura = b.readUInt32BE(20);
19
+ return largura > 0 && altura > 0 ? { largura, altura } : null;
20
+ }
@@ -0,0 +1,60 @@
1
+ // O perfil de documento oficial no disco do projeto: onde mora, como nasce do modelo (e nunca é
2
+ // sobrescrito) e a leitura do logotipo que ele cita. Perfil inválido não gera documento: o erro
3
+ // diz a linha.
4
+ // Spec: fase-u3b-documento-word.md, regras 10 e 11 (repositório do OpenCrew).
5
+ import { promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dentroDoProjeto } from '../comum.mjs';
9
+ import { lerPerfil } from './perfil.mjs';
10
+ import { MAXIMO, lerPng } from './png.mjs';
11
+
12
+ /** Onde o perfil do projeto mora (o `update` não toca esta pasta). */
13
+ export const PERFIL = '_opencrew/_memory/documento-oficial.md';
14
+ /** O modelo do pacote, ao lado dos scripts: `_opencrew/core/modelos/documento-oficial.md`. */
15
+ const MODELO = fileURLToPath(new URL('../../modelos/documento-oficial.md', import.meta.url));
16
+
17
+ export const MSG = {
18
+ semLogotipo: (n, arquivo) => `Perfil, linha ${n}: não encontrei o logotipo ${arquivo}.`,
19
+ logotipo: (n, arquivo) => `Perfil, linha ${n}: o logotipo precisa ser um arquivo PNG de até 2 MB, dentro do projeto. Recebi: ${arquivo}.`,
20
+ };
21
+
22
+ /** Os bytes do logotipo citado no perfil: `{ bytes }`, ou `{ erro }` com a mensagem da linha. */
23
+ async function lerLogotipo(raiz, arquivo, linha) {
24
+ if (!dentroDoProjeto(raiz, arquivo)) return { erro: MSG.logotipo(linha, arquivo) };
25
+ const info = await fs.stat(path.resolve(raiz, arquivo)).catch(() => null);
26
+ if (!info) return { erro: MSG.semLogotipo(linha, arquivo) };
27
+ if (!info.isFile() || info.size > MAXIMO) return { erro: MSG.logotipo(linha, arquivo) };
28
+ const bytes = await fs.readFile(path.resolve(raiz, arquivo));
29
+ return lerPng(bytes) ? { bytes } : { erro: MSG.logotipo(linha, arquivo) };
30
+ }
31
+
32
+ /**
33
+ * Lê o perfil de um arquivo do projeto, com o logotipo que ele cita.
34
+ * @param {string} raiz a pasta do projeto
35
+ * @param {string} arquivo o perfil, relativo à raiz ou absoluto (já conferido: existe e fica no projeto)
36
+ * @returns {Promise<{ perfil?: object, logotipo?: Buffer, erro?: string }>}
37
+ */
38
+ export async function lerPerfilDoProjeto(raiz, arquivo) {
39
+ const { perfil, linhas, erro } = lerPerfil(await fs.readFile(path.resolve(raiz, arquivo), 'utf8'));
40
+ if (erro) return { erro };
41
+ if (!perfil.logotipo) return { perfil };
42
+ const logotipo = await lerLogotipo(raiz, perfil.logotipo, linhas.logotipo);
43
+ return logotipo.erro ? { erro: logotipo.erro } : { perfil, logotipo: logotipo.bytes };
44
+ }
45
+
46
+ /**
47
+ * Cria o perfil do projeto a partir do modelo, só se ele não existir.
48
+ * @returns {Promise<boolean>} true quando criou; false quando já existia (e não foi tocado)
49
+ */
50
+ export async function criarPerfil(raiz) {
51
+ const destino = path.join(raiz, PERFIL);
52
+ await fs.mkdir(path.dirname(destino), { recursive: true });
53
+ try {
54
+ await fs.writeFile(destino, await fs.readFile(MODELO), { flag: 'wx' });
55
+ return true;
56
+ } catch (erro) {
57
+ if (erro.code === 'EEXIST') return false;
58
+ throw erro;
59
+ }
60
+ }
@@ -0,0 +1,57 @@
1
+ // As tabelas do corpo: a tabela de dados (bordas finas, primeira linha em negrito sobre cinza,
2
+ // colunas iguais) e o bloco de assinaturas (sem bordas, duas por linha, a ímpar no centro).
3
+ // Nenhuma linha parte entre páginas. Toda célula termina em parágrafo.
4
+ // Spec: fase-u3b-documento-word.md, §4 e regra 3 (repositório do OpenCrew).
5
+ import { RECUO, borda, paragrafo, twips } from './xml.mjs';
6
+
7
+ const LADOS = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'];
8
+ const BORDAS = `<w:tblBorders>${LADOS.map((lado) => borda(lado, { cor: 'CCCCCC' })).join('')}</w:tblBorders>`;
9
+ const MARGENS = '<w:tblCellMar><w:top w:w="40" w:type="dxa"/><w:left w:w="100" w:type="dxa"/><w:bottom w:w="40" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tblCellMar>';
10
+ export const SEM_MARGENS = '<w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar>';
11
+ const FUNDO = '<w:shd w:val="clear" w:color="auto" w:fill="F0F0F0"/>';
12
+ const NA_CELULA = { espaco: { after: 0 }, jc: 'left' };
13
+ /** Espaço em branco para a assinatura à mão, acima da linha: 36 pt. */
14
+ const ANTES_DA_LINHA = 720;
15
+ const RECUO_DA_SOZINHA = twips(4);
16
+
17
+ /**
18
+ * A moldura de uma tabela: propriedades, grade e linhas, nessa ordem.
19
+ * @param {number[]} colunas largura de cada coluna, em twips
20
+ * @param {{ bordas?: string, margens?: string }} partes o que vai em `tblPr`, além da largura e do leiaute fixo
21
+ * @param {string[]} linhas o XML das células de cada linha
22
+ */
23
+ export function tabela(colunas, { bordas = '', margens = '' }, linhas) {
24
+ const largura = colunas.reduce((a, b) => a + b, 0);
25
+ const grade = colunas.map((w) => `<w:gridCol w:w="${w}"/>`).join('');
26
+ const corpo = linhas.map((l) => `<w:tr><w:trPr><w:cantSplit/></w:trPr>${l}</w:tr>`).join('');
27
+ return `<w:tbl><w:tblPr><w:tblW w:w="${largura}" w:type="dxa"/>${bordas}<w:tblLayout w:type="fixed"/>${margens}</w:tblPr><w:tblGrid>${grade}</w:tblGrid>${corpo}</w:tbl>`;
28
+ }
29
+
30
+ /** Uma célula: largura, o que mais vier em `tcPr` (na ordem do formato) e os parágrafos. */
31
+ export const celula = (largura, propriedades, paragrafos) => `<w:tc><w:tcPr><w:tcW w:w="${largura}" w:type="dxa"/>${propriedades}</w:tcPr>${paragrafos}</w:tc>`;
32
+
33
+ /** Tabela de dados, na largura da área de texto, com colunas iguais. */
34
+ export function tabelaDeDados(bloco, larguraDoTexto) {
35
+ const coluna = Math.floor(larguraDoTexto / bloco.linhas[0].length);
36
+ const linhas = bloco.linhas.map((linha, n) => linha.map((pedacos) => celula(coluna, n === 0 ? FUNDO : '', paragrafo(NA_CELULA, pedacos, { b: n === 0 }))).join(''));
37
+ return tabela(bloco.linhas[0].map(() => coluna), { bordas: BORDAS, margens: MARGENS }, linhas);
38
+ }
39
+
40
+ /** Uma assinatura: a linha (borda de cima), o nome em negrito e caixa alta por formatação, e o cargo. */
41
+ function assinatura({ nome, cargo }, recuo) {
42
+ const daLinha = { bordas: borda('top', { sz: 6, cor: '000000', espaco: 1 }), espaco: { before: ANTES_DA_LINHA, after: 0 }, recuo: { left: recuo, right: recuo }, jc: 'center' };
43
+ const doNome = paragrafo(daLinha, [{ texto: nome }], { b: true, caps: true });
44
+ return cargo ? `${doNome}${paragrafo({ espaco: { after: 0 }, jc: 'center' }, [{ texto: cargo }])}` : doNome;
45
+ }
46
+
47
+ /** Bloco de assinaturas: duas por linha, na ordem escrita; a que sobra ocupa a linha inteira. */
48
+ export function tabelaDeAssinaturas(bloco, larguraDoTexto) {
49
+ const coluna = Math.floor(larguraDoTexto / 2);
50
+ const linhas = [];
51
+ for (let i = 0; i < bloco.pessoas.length; i += 2) {
52
+ const par = bloco.pessoas.slice(i, i + 2);
53
+ if (par.length === 2) linhas.push(par.map((p) => celula(coluna, '', assinatura(p, RECUO))).join(''));
54
+ else linhas.push(celula(coluna * 2, '<w:gridSpan w:val="2"/>', assinatura(par[0], RECUO_DA_SOZINHA)));
55
+ }
56
+ return tabela([coluna, coluna], { margens: SEM_MARGENS }, linhas);
57
+ }
@@ -0,0 +1,64 @@
1
+ // O papel timbrado: cabeçalho (`word/header1.xml`: logotipo e até três linhas, com uma linha
2
+ // preta embaixo) e rodapé (`word/footer1.xml`: o texto do perfil e "Página X de Y").
3
+ // Spec: fase-u3b-documento-word.md, §4 e regra 2 (repositório do OpenCrew).
4
+ import { SEM_MARGENS, celula, tabela } from './tabelas.mjs';
5
+ import { DECLARACAO, NS_R, NS_W, borda, pPr, paragrafo, rPr, trecho, twips } from './xml.mjs';
6
+
7
+ const NS_WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing';
8
+ const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
9
+ const NS_PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
10
+ /** Unidades do desenho (EMU) por centímetro. */
11
+ const EMU_POR_CM = 360000;
12
+ /** Folga entre o logotipo e as linhas do cabeçalho: 0,4 cm. */
13
+ const FOLGA = twips(0.4);
14
+ const SEM_ESPACO = { espaco: { after: 0 }, jc: 'left' };
15
+ const LINHAS = [['cabecalho_1', { b: true, sz: 12 }], ['cabecalho_2', { sz: 10 }], ['cabecalho_3', { cor: '666666', sz: 10 }]];
16
+ const DO_RODAPE = { sz: 8.5 };
17
+
18
+ /** O cabeçalho existe quando há logotipo ou alguma das três linhas. */
19
+ export const temCabecalho = (perfil, logotipo) => Boolean(logotipo) || LINHAS.some(([chave]) => perfil[chave]);
20
+ /** O rodapé existe quando há texto de rodapé ou número de página. */
21
+ export const temRodape = (perfil) => Boolean(perfil.rodape) || perfil.numero_pagina;
22
+
23
+ /** A imagem, na largura do perfil e na altura proporcional. `rId`: a relação com `media/logo.png`. */
24
+ function desenho(perfil, logotipo, rId) {
25
+ const cx = Math.round(perfil.logotipo_largura_cm * EMU_POR_CM);
26
+ const cy = Math.round((cx * logotipo.altura) / logotipo.largura);
27
+ const figura = `<pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${rId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>`;
28
+ const moldura = `<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Logotipo"/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>`;
29
+ return `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">${moldura}<a:graphic><a:graphicData uri="${NS_PIC}">${figura}</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
30
+ }
31
+
32
+ /**
33
+ * @param {object} perfil o perfil completo
34
+ * @param {number} largura a largura da área de texto, em twips
35
+ * @param {{ largura: number, altura: number }|null} logotipo as medidas do PNG, em pontos da imagem
36
+ * @param {string} rId a relação da imagem, em `word/_rels/header1.xml.rels`
37
+ */
38
+ export function montarCabecalho(perfil, largura, logotipo, rId) {
39
+ const linhas = LINHAS.filter(([chave]) => perfil[chave]).map(([chave, letra]) => paragrafo(SEM_ESPACO, [{ texto: perfil[chave] }], letra));
40
+ const texto = linhas.join('') || paragrafo(SEM_ESPACO);
41
+ const daImagem = logotipo ? twips(perfil.logotipo_largura_cm) + FOLGA : 0;
42
+ const centro = '<w:vAlign w:val="center"/>';
43
+ const imagem = logotipo ? celula(daImagem, centro, `<w:p>${pPr(SEM_ESPACO)}${desenho(perfil, logotipo, rId)}</w:p>`) : '';
44
+ const colunas = logotipo ? [daImagem, largura - daImagem] : [largura];
45
+ const bordas = `<w:tblBorders>${borda('bottom', { sz: 8, cor: '000000' })}</w:tblBorders>`;
46
+ const quadro = tabela(colunas, { bordas, margens: SEM_MARGENS }, [`${imagem}${celula(colunas.at(-1), centro, texto)}`]);
47
+ const espacos = `xmlns:w="${NS_W}" xmlns:r="${NS_R}" xmlns:wp="${NS_WP}" xmlns:a="${NS_A}" xmlns:pic="${NS_PIC}"`;
48
+ return `${DECLARACAO}<w:hdr ${espacos}>${quadro}${paragrafo(SEM_ESPACO)}</w:hdr>`;
49
+ }
50
+
51
+ /** Um campo do Word (`PAGE`, `NUMPAGES`), com "1" como valor até o Word calcular. */
52
+ function campo(instrucao) {
53
+ const marca = (tipo) => `<w:r>${rPr(DO_RODAPE)}<w:fldChar w:fldCharType="${tipo}"/></w:r>`;
54
+ const comando = `<w:r>${rPr(DO_RODAPE)}<w:instrText xml:space="preserve"> ${instrucao} </w:instrText></w:r>`;
55
+ return `${marca('begin')}${comando}${marca('separate')}${trecho('1', DO_RODAPE)}${marca('end')}`;
56
+ }
57
+
58
+ /** @param {object} perfil o perfil completo · @param {number} largura a da área de texto, em twips */
59
+ export function montarRodape(perfil, largura) {
60
+ const props = { bordas: borda('top', { cor: 'AAAAAA', espaco: 4 }), tabs: `<w:tab w:val="right" w:pos="${largura}"/>`, espaco: { after: 0 }, jc: 'left' };
61
+ const texto = trecho(perfil.rodape, { ...DO_RODAPE, i: true, cor: '666666' });
62
+ const pagina = perfil.numero_pagina ? `${trecho('\tPágina ', DO_RODAPE)}${campo('PAGE')}${trecho(' de ', DO_RODAPE)}${campo('NUMPAGES')}` : '';
63
+ return `${DECLARACAO}<w:ftr xmlns:w="${NS_W}"><w:p>${pPr(props)}${texto}${pagina}</w:p></w:ftr>`;
64
+ }
@@ -0,0 +1,95 @@
1
+ // Tijolos do XML do Word: texto seguro (escape e caracteres proibidos), medidas, e as propriedades
2
+ // de parágrafo e de letra com os filhos sempre na ordem que o formato exige.
3
+ // Spec: fase-u3b-documento-word.md, regras 3 (g) e 4 (repositório do OpenCrew).
4
+
5
+ export const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
6
+ export const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
7
+ export const DECLARACAO = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
8
+ /** Um nível de recuo: 0,75 cm. */
9
+ export const RECUO = 425;
10
+
11
+ /** Centímetros → twips (1/20 de ponto). */
12
+ export const twips = (cm) => Math.round(cm * 566.93);
13
+ /** Pontos → meios-pontos (a unidade do tamanho de letra). */
14
+ export const meios = (pt) => Math.round(pt * 2);
15
+
16
+ const ehAlta = (c) => c >= 0xd800 && c <= 0xdbff;
17
+ const ehBaixa = (c) => c >= 0xdc00 && c <= 0xdfff;
18
+ /** Caractere que o XML 1.0 não aceita (as metades de par substituto são vistas à parte). */
19
+ const proibido = (c) => (c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) || c === 0xfffe || c === 0xffff || ehAlta(c) || ehBaixa(c);
20
+
21
+ /**
22
+ * Tira o que não pode estar num XML 1.0: controles (menos tabulação e quebras de linha), U+FFFE,
23
+ * U+FFFF e metade solta de par substituto.
24
+ * @returns {{ texto: string, removidos: number }}
25
+ */
26
+ export function limpar(texto) {
27
+ let saida = '';
28
+ let removidos = 0;
29
+ for (let i = 0; i < texto.length; i++) {
30
+ const c = texto.charCodeAt(i);
31
+ if (ehAlta(c) && ehBaixa(texto.charCodeAt(i + 1))) saida += texto[i] + texto[++i];
32
+ else if (proibido(c)) removidos++;
33
+ else saida += texto[i];
34
+ }
35
+ return { texto: saida, removidos };
36
+ }
37
+
38
+ /** Texto para dentro do XML, em conteúdo ou em atributo. */
39
+ export const esc = (texto) => limpar(String(texto)).texto.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
40
+
41
+ const attrs = (o) => Object.entries(o).map(([nome, valor]) => ` w:${nome}="${valor}"`).join('');
42
+
43
+ /** Uma borda simples: `borda('top', { sz: 4, cor: 'CCCCCC' })`. `sz` em oitavos de ponto. */
44
+ export const borda = (lado, { sz = 4, cor, espaco = 0 }) => `<w:${lado} w:val="single" w:sz="${sz}" w:space="${espaco}" w:color="${cor}"/>`;
45
+
46
+ /**
47
+ * Propriedades do parágrafo, na ordem do formato: estilo, keepNext, quebra de página antes, lista,
48
+ * bordas, tabulações, espaço, recuo, alinhamento e nível de tópico.
49
+ * `espaco`: `{ before, after, line, lineRule }` · `recuo`: `{ left, right, hanging }` (twips).
50
+ */
51
+ export function pPr(o = {}) {
52
+ const partes = [
53
+ o.estilo && `<w:pStyle w:val="${o.estilo}"/>`,
54
+ o.keepNext && '<w:keepNext/>',
55
+ o.quebra && '<w:pageBreakBefore/>',
56
+ o.lista != null && `<w:numPr><w:ilvl w:val="${o.lista}"/><w:numId w:val="1"/></w:numPr>`,
57
+ o.bordas && `<w:pBdr>${o.bordas}</w:pBdr>`,
58
+ o.tabs && `<w:tabs>${o.tabs}</w:tabs>`,
59
+ o.espaco && `<w:spacing${attrs(o.espaco)}/>`,
60
+ o.recuo && `<w:ind${attrs(o.recuo)}/>`,
61
+ o.jc && `<w:jc w:val="${o.jc}"/>`,
62
+ o.topico != null && `<w:outlineLvl w:val="${o.topico}"/>`,
63
+ ].filter(Boolean);
64
+ return partes.length ? `<w:pPr>${partes.join('')}</w:pPr>` : '';
65
+ }
66
+
67
+ /** Propriedades da letra, na ordem do formato. `sz` em pontos; `fonte` já validada. */
68
+ export function rPr(o = {}) {
69
+ const partes = [
70
+ o.fonte && `<w:rFonts w:ascii="${esc(o.fonte)}" w:hAnsi="${esc(o.fonte)}" w:cs="${esc(o.fonte)}"/>`,
71
+ o.b && '<w:b/>',
72
+ o.i && '<w:i/>',
73
+ o.caps && '<w:caps/>',
74
+ o.cor && `<w:color w:val="${o.cor}"/>`,
75
+ o.sz && `<w:sz w:val="${meios(o.sz)}"/><w:szCs w:val="${meios(o.sz)}"/>`,
76
+ o.idioma && `<w:lang w:val="${o.idioma}"/>`,
77
+ ].filter(Boolean);
78
+ return partes.length ? `<w:rPr>${partes.join('')}</w:rPr>` : '';
79
+ }
80
+
81
+ /** O texto de um trecho: `w:t` (sempre com `xml:space="preserve"`) e `w:tab` em cada tabulação. */
82
+ const conteudo = (texto) => texto.split('\t').map((parte) => (parte ? `<w:t xml:space="preserve">${esc(parte)}</w:t>` : '')).join('<w:tab/>');
83
+
84
+ /** Um trecho de texto com a sua letra. Texto vazio não gera trecho. */
85
+ export const trecho = (texto, letra = {}) => (texto ? `<w:r>${rPr(letra)}${conteudo(texto)}</w:r>` : '');
86
+
87
+ /**
88
+ * Um parágrafo a partir dos pedaços lidos da linha (`[{ texto, b, i }]`).
89
+ * @param {object} props o que `pPr` recebe
90
+ * @param {object} [letra] letra comum a todos os pedaços (soma com o negrito e o itálico de cada um)
91
+ */
92
+ export function paragrafo(props, pedacos = [], letra = {}) {
93
+ const trechos = pedacos.map((p) => trecho(p.texto, { ...letra, b: letra.b || p.b, i: letra.i || p.i })).join('');
94
+ return `<w:p>${pPr(props)}${trechos}</w:p>`;
95
+ }
@@ -0,0 +1,86 @@
1
+ // Contêiner zip do documento Word, escrito à mão: toda entrada sem compressão (método 0), com
2
+ // CRC-32 próprio e data fixa (1980-01-01 00:00). O mesmo conteúdo dá os mesmos bytes, em qualquer
3
+ // máquina e em qualquer versão do Node. Não importa `node:zlib`.
4
+ // Spec: fase-u3b-documento-word.md, regra 1 (repositório do OpenCrew).
5
+
6
+ const TABELA = Array.from({ length: 256 }, (_, n) => {
7
+ let c = n;
8
+ for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
9
+ return c >>> 0;
10
+ });
11
+
12
+ /** CRC-32 (o do zip e do PNG) de um bloco de bytes. */
13
+ export function crc32(bytes) {
14
+ let c = 0xffffffff;
15
+ for (let i = 0; i < bytes.length; i++) c = TABELA[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
16
+ return (c ^ 0xffffffff) >>> 0;
17
+ }
18
+
19
+ const VERSAO = 20;
20
+ const DATA = 0x0021; // 1980-01-01
21
+ const HORA = 0;
22
+ /** Bit 11 (nome em UTF-8): só quando o nome sai do ASCII. */
23
+ const marcas = (nome) => (nome.length === Buffer.byteLength(nome, 'utf8') ? 0 : 0x0800);
24
+
25
+ function cabecalhoLocal(e) {
26
+ const b = Buffer.alloc(30);
27
+ b.writeUInt32LE(0x04034b50, 0);
28
+ b.writeUInt16LE(VERSAO, 4);
29
+ b.writeUInt16LE(marcas(e.nome), 6);
30
+ b.writeUInt16LE(0, 8); // método 0: sem compressão
31
+ b.writeUInt16LE(HORA, 10);
32
+ b.writeUInt16LE(DATA, 12);
33
+ b.writeUInt32LE(e.crc, 14);
34
+ b.writeUInt32LE(e.bytes.length, 18);
35
+ b.writeUInt32LE(e.bytes.length, 22);
36
+ b.writeUInt16LE(e.nomeEmBytes.length, 26);
37
+ return Buffer.concat([b, e.nomeEmBytes]);
38
+ }
39
+
40
+ function registroCentral(e) {
41
+ const b = Buffer.alloc(46);
42
+ b.writeUInt32LE(0x02014b50, 0);
43
+ b.writeUInt16LE(VERSAO, 4);
44
+ b.writeUInt16LE(VERSAO, 6);
45
+ b.writeUInt16LE(marcas(e.nome), 8);
46
+ b.writeUInt16LE(0, 10);
47
+ b.writeUInt16LE(HORA, 12);
48
+ b.writeUInt16LE(DATA, 14);
49
+ b.writeUInt32LE(e.crc, 16);
50
+ b.writeUInt32LE(e.bytes.length, 20);
51
+ b.writeUInt32LE(e.bytes.length, 24);
52
+ b.writeUInt16LE(e.nomeEmBytes.length, 28);
53
+ b.writeUInt32LE(e.inicio, 42);
54
+ return Buffer.concat([b, e.nomeEmBytes]);
55
+ }
56
+
57
+ function registroFinal(total, tamanho, inicio) {
58
+ const b = Buffer.alloc(22);
59
+ b.writeUInt32LE(0x06054b50, 0);
60
+ b.writeUInt16LE(total, 8);
61
+ b.writeUInt16LE(total, 10);
62
+ b.writeUInt32LE(tamanho, 12);
63
+ b.writeUInt32LE(inicio, 16);
64
+ return b;
65
+ }
66
+
67
+ /**
68
+ * Monta o zip, na ordem recebida.
69
+ * @param {{ nome: string, bytes: Buffer|string }[]} partes nomes com `/`; texto vai em UTF-8
70
+ * @returns {Buffer}
71
+ */
72
+ export function zipar(partes) {
73
+ const locais = [];
74
+ const centrais = [];
75
+ let inicio = 0;
76
+ for (const parte of partes) {
77
+ const bytes = Buffer.isBuffer(parte.bytes) ? parte.bytes : Buffer.from(parte.bytes, 'utf8');
78
+ const e = { nome: parte.nome, nomeEmBytes: Buffer.from(parte.nome, 'utf8'), bytes, crc: crc32(bytes), inicio };
79
+ const local = cabecalhoLocal(e);
80
+ locais.push(local, bytes);
81
+ centrais.push(registroCentral(e));
82
+ inicio += local.length + bytes.length;
83
+ }
84
+ const diretorio = Buffer.concat(centrais);
85
+ return Buffer.concat([...locais, diretorio, registroFinal(partes.length, diretorio.length, inicio)]);
86
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Documento Word: converte um texto em markdown (.md ou .txt) num `.docx`, com as mesmas palavras
3
+ // e os mesmos números, na mesma ordem. Com o perfil de documento oficial do projeto, sai em papel
4
+ // timbrado: cabeçalho com logotipo, rodapé com "Página X de Y", margens e letra do perfil.
5
+ // Uso (na pasta do projeto): node _opencrew/core/scripts/documento.mjs "<arquivo.md>"
6
+ // [--saida <arquivo.docx|pasta>] [--perfil <arquivo>] [--sem-perfil] [--substituir] [--ajuda]
7
+ // node _opencrew/core/scripts/documento.mjs --criar-perfil
8
+ // --saida: onde gravar (padrão: ao lado do texto, com o mesmo nome e `.docx`); a pasta é criada.
9
+ // --perfil: outro arquivo de perfil (padrão: `_opencrew/_memory/documento-oficial.md`, se existir).
10
+ // --sem-perfil: ignora o perfil do projeto (sem timbre, com os padrões).
11
+ // --substituir: autoriza trocar um `.docx` diferente que já existe no destino.
12
+ // --criar-perfil: cria o perfil do projeto a partir do modelo, só se ele não existir.
13
+ // Grava só o `.docx` pedido (por um temporário ao lado, renomeado no fim) e, com --criar-perfil, o
14
+ // perfil que faltava. Nunca apaga nada. Um `.docx` igual ao que seria gravado não é tocado.
15
+ // Última linha da saída: DOCUMENTO:OK (o Word está no destino) · PERFIL:CRIADO ou PERFIL:JA-EXISTE
16
+ // (com --criar-perfil).
17
+ // Código de saída: 0 quando a linha final sai, e em --ajuda · 1 = erro: uma mensagem em PT-BR, sem
18
+ // linha DOCUMENTO: e nada gravado.
19
+ // Para outros scripts: `gerarDocx({ texto, perfil, logotipo })` → `{ bytes, avisos }`, sem tocar o
20
+ // disco; `lerPerfilDoProjeto(raiz, arquivo)` → `{ perfil, logotipo }` ou `{ erro }`.
21
+ // Spec: fase-u3b-documento-word.md (repositório do OpenCrew).
22
+ import { existsSync, promises as fs, statSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { MSG as COMUM, dentroDoProjeto, ehPrincipal, relativoAoProjeto } from './comum.mjs';
25
+ import { MSG, USO, lerArgs, limpar } from './documento/argumentos.mjs';
26
+ import { gravarDocx } from './documento/gravar.mjs';
27
+ import { gerarDocx } from './documento/pacote.mjs';
28
+ import { PERFIL, criarPerfil, lerPerfilDoProjeto } from './documento/projeto.mjs';
29
+
30
+ export { gerarDocx } from './documento/pacote.mjs';
31
+ export { lerPerfil } from './documento/perfil.mjs';
32
+ export { PERFIL, lerPerfilDoProjeto } from './documento/projeto.mjs';
33
+
34
+ const TEXTO = /\.(md|txt)$/i;
35
+ const ehPasta = (p) => existsSync(p) && statSync(p).isDirectory();
36
+ const ehArquivo = (p) => existsSync(p) && statSync(p).isFile();
37
+ const erro = (mensagem, comUso = false) => ({ erro: mensagem, comUso });
38
+
39
+ /** O arquivo de texto pedido: um só, dentro do projeto, `.md` ou `.txt`, e que existe. */
40
+ function conferirOrigem(raiz, arquivos) {
41
+ if (arquivos.length === 0) return erro(MSG.semArquivo, true);
42
+ if (arquivos.length > 1) return erro(MSG.maisDeUm(arquivos.length), true);
43
+ if (!ehPasta(path.join(raiz, '_opencrew'))) return erro(COMUM.semRaiz);
44
+ if (!dentroDoProjeto(raiz, arquivos[0])) return erro(COMUM.foraDoProjeto(limpar(arquivos[0])));
45
+ const rel = relativoAoProjeto(raiz, arquivos[0]);
46
+ if (!TEXTO.test(rel)) return erro(MSG.extensao(rel));
47
+ return ehArquivo(path.resolve(raiz, arquivos[0])) ? { rel, abs: path.resolve(raiz, arquivos[0]) } : erro(MSG.naoEncontrei(rel));
48
+ }
49
+
50
+ /** Onde o Word vai: ao lado do texto, ou no arquivo `.docx` ou na pasta de `--saida`. */
51
+ function conferirDestino(raiz, saida, origem) {
52
+ const nome = `${path.basename(origem.abs).replace(TEXTO, '')}.docx`;
53
+ if (saida === undefined) return { destino: path.join(path.dirname(origem.abs), nome) };
54
+ const recusada = erro(MSG.saida(limpar(saida)));
55
+ if (!saida.trim() || !dentroDoProjeto(raiz, saida)) return recusada;
56
+ const alvo = path.resolve(raiz, saida);
57
+ if (/\.docx$/i.test(alvo)) return ehPasta(alvo) ? recusada : { destino: alvo };
58
+ if (ehPasta(alvo)) return { destino: path.join(alvo, nome) };
59
+ return existsSync(alvo) || path.extname(alvo) ? recusada : { destino: path.join(alvo, nome) };
60
+ }
61
+
62
+ /** O arquivo de perfil que vale nesta chamada (`arquivo` fica sem valor quando não há perfil). */
63
+ function conferirPerfil(raiz, args) {
64
+ if (args.semPerfil) return {};
65
+ if (args.perfil === undefined) return ehArquivo(path.join(raiz, PERFIL)) ? { arquivo: PERFIL } : {};
66
+ if (!dentroDoProjeto(raiz, args.perfil)) return erro(COMUM.foraDoProjeto(limpar(args.perfil)));
67
+ return ehArquivo(path.resolve(raiz, args.perfil)) ? { arquivo: args.perfil } : erro(MSG.semPerfil(limpar(args.perfil)));
68
+ }
69
+
70
+ /** Tudo o que a chamada pede, conferido antes de ler ou gravar qualquer coisa. */
71
+ function conferir(raiz, args) {
72
+ const origem = conferirOrigem(raiz, args.arquivos);
73
+ if (origem.erro) return origem;
74
+ const destino = conferirDestino(raiz, args.saida, origem);
75
+ const perfil = conferirPerfil(raiz, args);
76
+ return destino.erro ? destino : perfil.erro ? perfil : { origem, destino: destino.destino, perfil: perfil.arquivo };
77
+ }
78
+
79
+ /** O texto do arquivo, em UTF-8; `null` quando os bytes não são UTF-8. */
80
+ async function lerTexto(arquivo) {
81
+ try {
82
+ return new TextDecoder('utf-8', { fatal: true }).decode(await fs.readFile(arquivo));
83
+ } catch (falha) {
84
+ if (falha instanceof TypeError) return null;
85
+ throw falha;
86
+ }
87
+ }
88
+
89
+ /** Monta o documento na memória: `{ bytes, avisos }`, ou `{ erro }` com a mensagem. */
90
+ async function montar(raiz, pedido) {
91
+ const texto = await lerTexto(pedido.origem.abs);
92
+ if (texto === null) return erro(MSG.naoUtf8(pedido.origem.rel));
93
+ const lido = pedido.perfil ? await lerPerfilDoProjeto(raiz, pedido.perfil) : {};
94
+ if (lido.erro) return erro(lido.erro);
95
+ const documento = gerarDocx({ texto, perfil: lido.perfil, logotipo: lido.logotipo });
96
+ return documento.vazio ? erro(MSG.semTexto(pedido.origem.rel)) : documento;
97
+ }
98
+
99
+ function relatorio(raiz, pedido, estado, avisos) {
100
+ const destino = relativoAoProjeto(raiz, pedido.destino);
101
+ return [
102
+ estado === 'igual' ? MSG.igual(destino) : MSG.gerado(destino),
103
+ pedido.perfil ? MSG.perfil(relativoAoProjeto(raiz, pedido.perfil)) : MSG.nenhumPerfil,
104
+ ...(avisos.length ? [MSG.avisos, ...avisos.map((a) => `- ${a}`)] : []),
105
+ ...MSG.dicas,
106
+ 'DOCUMENTO:OK',
107
+ ];
108
+ }
109
+
110
+ /** Converte e grava; devolve as linhas da saída e o código. */
111
+ async function converter(raiz, args, disco) {
112
+ const pedido = conferir(raiz, args);
113
+ if (pedido.erro) return { linhas: [pedido.erro, ...(pedido.comUso ? [USO] : [])], code: 1 };
114
+ const documento = await montar(raiz, pedido);
115
+ if (documento.erro) return { linhas: [documento.erro], code: 1 };
116
+ const estado = await gravarDocx(pedido.destino, documento.bytes, { substituir: args.substituir, disco });
117
+ const destino = relativoAoProjeto(raiz, pedido.destino);
118
+ if (estado === 'diferente') return { linhas: [MSG.jaExiste(destino)], code: 1 };
119
+ if (estado === 'falha') return { linhas: [MSG.falha(destino)], code: 1 };
120
+ return { linhas: relatorio(raiz, pedido, estado, documento.avisos), code: 0 };
121
+ }
122
+
123
+ async function criar(raiz) {
124
+ if (!ehPasta(path.join(raiz, '_opencrew'))) return { linhas: [COMUM.semRaiz], code: 1 };
125
+ const criado = await criarPerfil(raiz);
126
+ return { linhas: criado ? [MSG.perfilCriado(PERFIL), 'PERFIL:CRIADO'] : [MSG.perfilJaExiste(PERFIL), 'PERFIL:JA-EXISTE'], code: 0 };
127
+ }
128
+
129
+ /**
130
+ * @param {string[]} argv
131
+ * @param {object} [deps] `cwd` (a pasta do projeto), `escrever` e `disco` (as funções de
132
+ * `fs.promises` usadas na gravação do `.docx`)
133
+ * @returns {Promise<number>} 0 = a linha final saiu (ou `--ajuda`) · 1 = erro (uma mensagem em
134
+ * PT-BR, sem linha `DOCUMENTO:`, nada gravado)
135
+ */
136
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`), disco = fs } = {}) {
137
+ const args = lerArgs(argv);
138
+ const sair = ({ linhas, code }) => linhas.forEach((l) => escrever(l)) ?? code;
139
+ if (args.ajuda) return sair({ linhas: [USO], code: 0 });
140
+ if (args.desconhecida) return sair({ linhas: [MSG.desconhecida(limpar(args.desconhecida)), USO], code: 1 });
141
+ try {
142
+ return sair(args.criarPerfil ? await criar(cwd) : await converter(cwd, args, disco));
143
+ } catch (falha) {
144
+ return sair({ linhas: [`Não consegui gerar o documento: ${limpar(falha?.message ?? falha)}`], code: 1 });
145
+ }
146
+ }
147
+
148
+ if (ehPrincipal(import.meta.url)) {
149
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
150
+ }
@@ -6,7 +6,9 @@ import path from 'node:path';
6
6
  import { lerFrontmatter, lerTexto } from '../verificar/leitura.mjs';
7
7
 
8
8
  /** Pasta do canal → nome no LEIA-ME, na ordem em que as seções aparecem. */
9
- export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube' };
9
+ export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube', documentos: 'Documentos' };
10
+ /** Plataforma cuja pasta tem outro nome (fase-u3b-documento-word.md, regra 12). */
11
+ const PASTA_DA_PLATAFORMA = { documento: 'documentos' };
10
12
  export const OUTROS = 'outros';
11
13
  export const EDITAVEIS = 'editaveis';
12
14
  export const ehCanal = (pasta) => Object.hasOwn(CANAIS, pasta);
@@ -23,13 +25,15 @@ async function plataformaEm(raiz, pasta, formato) {
23
25
  /**
24
26
  * Canal de um formato: o `platform:` de `_opencrew/best-practices.local/<formato>.md`, quando o
25
27
  * arquivo o declara; senão, o do core. Sem formato, sem best-practice, sem `platform:` ou com
26
- * plataforma que não é uma das sete pastas: null (o arquivo vai para `outros/`).
28
+ * plataforma que não é uma das oito pastas: null (o arquivo vai para `outros/`). A plataforma
29
+ * `documento` é a pasta `documentos`.
27
30
  */
28
31
  export async function canalDoFormato(raiz, formato) {
29
32
  if (!formato || !/^[a-z0-9-]+$/.test(formato)) return null;
30
33
  const local = await plataformaEm(raiz, ['best-practices.local'], formato);
31
34
  const plataforma = local ?? (await plataformaEm(raiz, ['core', 'best-practices'], formato));
32
- return plataforma && ehCanal(plataforma) ? plataforma : null;
35
+ const pasta = Object.hasOwn(PASTA_DA_PLATAFORMA, plataforma ?? '') ? PASTA_DA_PLATAFORMA[plataforma] : plataforma;
36
+ return pasta && ehCanal(pasta) ? pasta : null;
33
37
  }
34
38
 
35
39
  const PASTAS_DE_SERVICO = new Set(['entrega', 'entrega.tmp', 'export']);
@@ -11,8 +11,8 @@ import path from 'node:path';
11
11
  const DE_TEXTO = new Set(['.txt', '.md', '.html', '.htm', '.csv', '.json']);
12
12
  export const emLf = (texto) => texto.replace(/\r\n/g, '\n');
13
13
 
14
- /** Os bytes que um arquivo da entrega tem: o texto gerado, ou os do arquivo de origem. */
15
- export const bytesDe = async (a) => (a.texto != null ? Buffer.from(a.texto, 'utf8') : fs.readFile(a.de));
14
+ /** Os bytes que um arquivo da entrega tem: os gerados (o Word), o texto gerado, ou os do arquivo de origem. */
15
+ export const bytesDe = async (a) => a.bytes ?? (a.texto != null ? Buffer.from(a.texto, 'utf8') : fs.readFile(a.de));
16
16
 
17
17
  const ehTexto = (nome) => DE_TEXTO.has(path.extname(nome).toLowerCase());
18
18
  const mesmoConteudo = (nome, a, b) => a.equals(b) || (ehTexto(nome) && emLf(a.toString('utf8')) === emLf(b.toString('utf8')));