feat(documents): editor de texto rico (Tiptap) para templates de documento

Substitui o textarea de texto puro por um editor visual real, seguindo o
print de referência do usuário do editor "Contrato de Software" do
OrçaFácil legado: toolbar completa (negrito/itálico/sublinhado/tachado,
alinhamento, listas, tabela, imagem, quebra de página), variáveis inseridas
como chip inline via catálogo fechado (nunca {{texto livre}}), painel
"Variáveis usadas neste modelo" (com badge obrigatório ao vivo) e
"Histórico de versões".

Backend: editor-content-renderer.ts e merge-engine.ts portados/reescritos
a partir do renderEditorContent.js/variableRegistry.js reais do legado
(achados em .tmp/orcafacil-reference) — resolve a árvore Tiptap
diretamente em vez de regex sobre HTML cacheado. Novo catálogo de
variáveis + GET /documents/variable-catalog.

Fora de escopo desta leva (documentado em docs/progress.md): nó
`conditional`, repetição de linha de tabela, bloqueio por variável
obrigatória ausente, e auto-resolução de dados reais do cadastro na hora
de gerar o documento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 19:49:42 -03:00
parent 553c0d9533
commit 1545666f17
20 changed files with 1999 additions and 312 deletions

View File

@@ -1,49 +1,29 @@
/**
* Motor de merge de variáveis — versão SIMPLIFICADA para esta leva,
* inspirada no `resolveDocument()` do legado (eden.md §3.2), mas
* operando sobre TEXTO PURO com placeholders `{{variavel}}` em vez da
* árvore Tiptap completa. A árvore Tiptap real (nós `mergeField`,
* `conditional`, `tableRow.attrs.repeat`, catálogo de variáveis
* `VARIABLE_CATALOG` com formatação por tipo — eden.md §2.2, §3.1, §3.3,
* §3.4) fica para quando o editor visual real existir; não é
* reimplementada aqui.
*
* Pura e sem I/O — testável isoladamente (ver
* apps/api/test/merge-engine.spec.ts). Diferença deliberada em relação
* ao motor completo do legado: aquele lança `MISSING_REQUIRED_FIELDS`/
* `UNRESOLVED_VARIABLES` (HTTP 422) e bloqueia a geração; este NUNCA
* lança exceção — variáveis ausentes do contexto são substituídas por
* string vazia e apenas reportadas em `missingVariables` (deduplicadas,
* na ordem da primeira ocorrência). O chamador decide o que fazer com
* elas (bloquear, avisar, etc.); o motor não decide isso sozinho.
* Motor de resolução de variáveis — opera direto sobre a árvore Tiptap
* (`editor_content`), não mais sobre `{{variavel}}` em texto puro (essa era
* a versão stand-in de uma leva anterior, antes do editor visual real
* existir). Inspirado em `resolveDocument()` do legado (eden.md §3.2), mas
* simplificado: sem nó `conditional`, sem expansão de `tableRow.attrs.repeat`
* (fora de escopo — o editor/catálogo desta leva nunca produz nenhum dos
* dois) e sem bloquear com `MISSING_REQUIRED_FIELDS`/`UNRESOLVED_VARIABLES`
* (HTTP 422) — uma `mergeField` sem valor em `context` vira string vazia e é
* só reportada em `missingVariables` (deduplicada, ordem da primeira
* ocorrência); o chamador decide o que fazer com isso.
*/
import { collectMergeFieldKeys, renderToHtml } from "./editor-content-renderer";
export interface MergeResult {
/** Texto com todo `{{variavel}}` substituído — nunca sobra placeholder pendente, mesmo quando ausente do contexto (vira string vazia). */
resolved: string;
/** Chaves referenciadas no texto mas ausentes de `context`, deduplicadas, na ordem da primeira ocorrência. */
export interface ResolveDocumentResult {
/** HTML final, com toda `mergeField` já substituída pelo valor do contexto (ou string vazia, quando ausente). */
html: string;
/** Chaves referenciadas no documento mas ausentes de `context`, deduplicadas, na ordem de `collectMergeFieldKeys`. */
missingVariables: string[];
}
// Mesma sintaxe de chave do catálogo legado (eden.md §3.1): namespace.campo,
// ex. "cliente.razao_social". Espaços em volta da chave são tolerados
// ("{{ cliente.razao_social }}") e ignorados.
const MERGE_FIELD_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
export function resolveDocument(editorContent: unknown, context: Record<string, string>): ResolveDocumentResult {
const keys = collectMergeFieldKeys(editorContent);
const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key));
export function mergeVariables(text: string, context: Record<string, string>): MergeResult {
const missingVariables: string[] = [];
const seenMissing = new Set<string>();
const html = renderToHtml(editorContent, (key) => context[key] ?? "");
const resolved = text.replace(MERGE_FIELD_PATTERN, (_match: string, key: string) => {
if (Object.prototype.hasOwnProperty.call(context, key)) {
return context[key] ?? "";
}
if (!seenMissing.has(key)) {
seenMissing.add(key);
missingVariables.push(key);
}
return "";
});
return { resolved, missingVariables };
return { html, missingVariables };
}