Voltar ao Blog

Como construímos modelos personalizados estruturados: deixar os usuários mudar o estilo sem abrir CSS arbitrário

MD-TO Team

Recebemos sempre o mesmo pedido dos usuários: “O modelo GitHub é legal, mas dá para mudar a cor do título para a cor da nossa marca? E aumentar um pouco o espaçamento entre linhas?”

Parece uma pequena funcionalidade, mas há um trade-off inevitável por trás: abrir CSS arbitrário ou não?

Este post percorre o caminho que acabamos escolhendo — sobreposições estruturadas em cima dos modelos de sistema existentes — e por que isso se encaixa melhor numa ferramenta como md-to.com, que segue o princípio “um conteúdo, vários formatos de exportação”.


Dois extremos comuns que não funcionam direito

A maioria das ferramentas Markdown lida com “estilo personalizado” de uma destas duas formas:

Opção 1: apenas modelos fixos. Simples e estável, mas não chega. O usuário escolhe o modelo mais próximo e logo trava no próximo passo: “queria só mudar a cor do título”.

Opção 2: CSS arbitrário. Liberdade total, dá para ajustar a área de pré-visualização sem limite. O problema: ficar bonito no navegador não significa ficar bonito depois da exportação.

Construímos uma ferramenta que converte Markdown em PDF / Word / HTML / imagem, e várias dessas trilhas de exportação não consomem CSS:

  • O Word (.docx) usa estilos OOXML. Não existe o conceito de “CSS arbitrário” ali.
  • A impressão de PDF tem suas próprias regras de paginação e margens. Layouts ajustados puramente com CSS de navegador costumam quebrar na hora de imprimir.
  • A exportação de imagem passa por captura de canvas, e o suporte a algumas funcionalidades CSS (partes de filter, backdrop-filter) é limitado.

Se deixarmos os usuários escreverem CSS arbitrário, a pré-visualização fica boa mas as exportações quebram silenciosamente. Esse descompasso “o que você vê não é o que você obtém” é uma péssima experiência.

Por isso pegamos um terceiro caminho.


Nossa abordagem: modelo base + sobreposições estruturadas

A ideia central numa frase: o usuário escolhe primeiro um modelo de sistema, depois muda um pequeno conjunto de campos estruturados em cima dele.

Não armazenamos um modelo completo — armazenamos “ID do modelo base + sobreposições”:

{
  version: 1,
  baseTemplateId: 'classic',
  overrides: {
    colors: {
      heading: '#0F766E'
    },
    fonts: {
      body: 'Noto Sans SC'
    },
    fontSizes: {
      body: 13
    },
    spacing: {
      lineHeight: 1.7
    }
  }
}

Por que não armazenar um modelo completo? Três razões:

  1. Os usuários se beneficiam quando a base melhora. Se mais tarde apertarmos o padding dos blocos de código no Classic, os modelos personalizados pegam o novo valor automaticamente — porque a sobreposição não tocava esse campo.
  2. Payload pequeno, validação simples nas duas pontas. O backend só precisa validar um punhado de campos da lista branca, e não um objeto de modelo inteiro.
  3. A semântica “baseado em qual modelo” é preservada explicitamente. A UI pode dizer ao usuário “você fez 3 mudanças em cima do Classic”.

Abaixo está a interface atual do editor, cobrindo quatro grupos de campos de alta frequência: cores, fontes, tamanhos de fonte e espaçamento:

Editor de modelo personalizado


O runtime: de um clique até uma exportação pronta

O sistema todo se resume a três pipelines.

Pipeline 1: pré-visualização ao vivo

O usuário muda uma cor no editor e a pré-visualização precisa atualizar imediatamente:

  1. O editor escreve os novos valores de sobreposição em localStorage.customDocumentTemplate.
  2. O editor dispara um evento template-change.
  3. MarkdownPreview ouve o evento e chama getActiveTemplate().
  4. getActiveTemplate()documentTemplate (a base atual) e customDocumentTemplate (as sobreposições). Se baterem, chama createCustomTemplate(baseTemplate, overrides) para fundir num modelo completo.
  5. generatePreviewCSS(template) regenera os estilos da pré-visualização.

A lógica de fusão é direta — um merge raso por grupo de campos:

return {
  ...baseTemplate,
  id: `custom:${baseTemplate.id}`,
  colors: {
    ...baseTemplate.colors,
    ...(overrides.colors ?? {}),
  },
  fonts: {
    ...baseTemplate.fonts,
    ...(overrides.fonts ?? {}),
  },
  fontSizes: {
    ...baseTemplate.fontSizes,
    ...(overrides.fontSizes ?? {}),
  },
  spacing: {
    ...baseTemplate.spacing,
    ...(overrides.spacing ?? {}),
  },
};

Pipeline 2: salvar como modelo privado

Quando o usuário clica em “Salvar Modelo”, o adapter da ferramenta empacota o estado atual:

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

Esse payload vai para saved_templates.settings_json. Repare que documentTemplateId e customDocumentTemplate são armazenados separadamente — um modelo privado significa “Y modificações em cima do modelo X”, não um snapshot de estilo desconectado da origem.

Pipeline 3: aplicar um modelo privado

Quando o usuário escolhe um de volta no PrivateTemplateSelector, ou carrega via parâmetro ?tpl=<id>:

  1. O adapter chama applySettings(settingsJson).
  2. Se o payload tiver um customDocumentTemplate, ele é gravado de volta no localStorage; se não, é limpo.
  3. documentTemplateId é aplicado.
  4. template-change é disparado, e os caminhos de pré-visualização / exportação recebem o modelo completo já fundido.

Um detalhe fácil de perder mas crítico: limpar as sobreposições ao escolher um modelo do sistema

Adicionamos uma única linha dentro de setActiveTemplate(templateId):

localStorage.removeItem('customDocumentTemplate');

Por quê? Sem isso, surge um cenário confuso:

  • O usuário personaliza o Classic com título verde, e isso fica no localStorage.
  • O usuário troca para o Dracula.
  • O usuário volta para o Classic — e o título está verde de novo, mas ele não lembra de ter mudado.

Definimos “escolher um modelo de sistema” como um reset explícito: você escolhe um modelo limpo, recebe o modelo limpo. Para personalizar de novo, clique em “Personalizar” novamente. Essa semântica é muito mais clara do que “preservar as últimas edições do usuário sempre que possível”.

TemplatePickerModal faz a mesma limpeza quando “Aplicar” é clicado.


Validação no backend: a lista branca é a rede de segurança

O validateTemplateInput() no backend valida estritamente settingsJson.customDocumentTemplate:

  • customDocumentTemplate é opcional; se presente, deve ser um objeto.
  • version precisa ser 1 (espaço para futuras migrações).
  • baseTemplateId precisa ser uma string não vazia.
  • overrides precisa ser um objeto.
  • Campos fora da lista branca são rejeitados.

A lista branca cobre tudo o que a UI expõe hoje, com um pouco de folga para futuros campos:

GrupoCampos permitidos
colorsheading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven
fontsheading, body, code
fontSizesh1, h2, h3, h4, h5, h6, body, code
spacingheadingBefore, headingAfter, paragraphBefore, paragraphAfter, lineHeight

A lista branca é a rede de segurança de todo este desenho: como não aceitamos chaves arbitrárias, mesmo que mais tarde adicionemos lógica de renderização a um campo ou o renomeemos, chaves antigas salvas pelos usuários não vão se infiltrar no modelo de runtime e poluir a saída.


O ganho: as trilhas de exportação não precisam saber que existem “modelos personalizados”

Depois da fusão, temos um DocumentTemplate completo, então nenhuma das funções de exportação rio abaixo precisa mudar:

  • Pré-visualização: generatePreviewCSS(template)
  • PDF / impressão: generatePdfStyles() / generatePrintStyles()
  • HTML: generateExportHTML()
  • Cópia em texto rico: generateRichTextHTML()
  • Word: templateToDocxStyles(template)

É exatamente o ganho que tivemos ao recusar CSS arbitrário desde o começo: enquanto toda saída continuar consumindo DocumentTemplate como representação intermediária unificada, a consistência entre formatos é praticamente de graça. A cor de título escolhida pelo usuário aparece idêntica na pré-visualização do navegador, no PDF, no Word e nas exportações de imagem — não “parecida”, mas literalmente o mesmo valor hex, porque tudo é lido do mesmo modelo fundido.


Limitações atuais

A primeira versão é intencionalmente estreita:

  • Apenas campos de alta frequência são expostos; nem toda cor, tamanho de fonte ou valor de espaçamento é editável.
  • Não há entrada dedicada para “nomear seu modelo personalizado” — a nomeação ainda passa pela caixa de “Salvar Modelo”.
  • Sem import / export JSON para modelos.
  • Sem CSS arbitrário.
  • A UI do editor é uma primeira passada leve — painéis agrupados, paletas de cores predefinidas e layout mobile ainda têm muito espaço para crescer.

Para onde isso vai

Alguns próximos passos claros para esta estrutura:

  • Mais campos editáveis. Cor de borda de tabela, fundo de linhas alternadas, fundo de citação, cor do texto de código — a lista branca já tem os espaços; o que falta é UI.
  • Um schema unificado de campos. Que o formulário do frontend e a validação do backend compartilhem uma única definição, evitando que se afastem.
  • Uma entrada “Editar” na lista de modelos do workspace. Hoje só dá para salvar uma nova cópia; não dá para continuar editando um modelo privado existente.
  • Um fluxo explícito “duplicar modelo do sistema como modelo privado”. Tornar “quero basear um privado no Classic” uma ação mais direta.
  • Import / export JSON de modelos personalizados. Útil para compartilhar estilos dentro de uma equipe.

Resumindo

Olhando para trás, a chave deste design não é o que construímos — é o que não construímos:

  • Não permitimos CSS arbitrário — o que evitou quebras entre formatos.
  • Não armazenamos snapshots completos de modelos — o que preservou o benefício das atualizações de modelo base.
  • Não mantivemos silenciosamente as sobreposições ao trocar de modelo de sistema — o que evitou a confusão “não mudei nada, mas o estilo está diferente”.
  • O backend não aceita campos fora da lista branca — o que deixa espaço para o design evoluir.

Restrições, na verdade, entregam uma experiência mais previsível. Numa ferramenta que abrange vários formatos de exportação, essa previsibilidade vale mais que liberdade.

Se você também está construindo um produto “um conteúdo, várias exportações”, visite md-to.com e experimente este sistema de modelos personalizados — veja se é o tipo de “liberdade na medida certa” que você estava procurando.