Pular para o conteúdo

ADR-0021 Markdown

Renderização de Markdown padrão da empresa — TanStack Markdown atrás de um componente partilhado por app, HTML em bruto desligado e uma só política de links e imagens para texto de modelos e de utilizadores.

Decisão
TanStack MarkdownConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

As apps da nossa stack renderizam Markdown a partir de três origens:

  • Respostas de modelos — chegam token a token (ADR-0025) e são renderizadas de novo várias vezes por segundo enquanto ainda estão incompletas
  • Texto escrito por utilizadores — comentários, notas, descrições: tudo o que um cliente escreve
  • Texto que escrevemos nós — páginas de ajuda, notas de lançamento, textos de estados vazios

As duas primeiras são entrada não fiável. Um modelo que leu uma página web, o resultado de uma ferramenta ou um registo guardado pode emitir tudo o que um atacante lá tenha posto.

Decisões à volta:

  • SSR: o TanStack Start (ADR-0002) renderiza no servidor e hidrata; o resultado do Markdown tem de ser igual nos dois lados
  • Estilos: Tailwind v4 e tokens do shadcn/ui (ADR-0011), sem injeção de estilos em runtime no caminho de renderização — os estilos dos elementos são CSS nosso
  • Validação: um campo Markdown é uma string numa fronteira como qualquer outra (ADR-0005)
  • Os blocos de código são coloridos pelo realce de sintaxe do ADR-0022

O que está em aberto: que renderizador usar, a política para HTML em bruto, links e imagens, como se renderiza Markdown parcial enquanto chega em streaming e onde vive a configuração.

Restrições:

  • O HTML em bruto é um vetor de XSS. Um renderizador que o deixe passar torna-nos responsáveis por o sanitizar
  • Links e imagens são pedidos de saída. Um link javascript: executa código ao clique. Uma imagem remota carrega sem clique nenhum, por isso um modelo vítima de prompt injection pode pôr dados da conversa no URL de uma imagem e o browser envia-os — a própria documentação de segurança do Streamdown descreve exatamente este ataque
  • Entrada parcial. Em streaming, o texto acaba a meio de um bloco de código, de um negrito, de um link. O resultado tem de se manter estável e nunca mostrar um elemento a meio de ser construído
  • Resultado igual no servidor e no cliente, senão há erros de hidratação
  • Bundle do browser. O renderizador segue em todas as apps que têm chat ou comentários

Um parser que produz uma AST serializável, mais renderizadores de HTML e de React. @tanstack/markdown 1.0.0 (2026-10-01), MIT segundo o package.json. Não há um pacote React separado: o adaptador é a entrada @tanstack/markdown/react, com React 18+ como peer opcional.

Prós

  • Seguro por predefinição. O HTML em bruto é escapado e mostrado como texto. Os URLs de links e imagens com um esquema que não seja http, https, mailto ou tel são removidos, o texto do link fica. Corremos javascript:, vbscript:, file:, data: e ftp: em links inline e por referência na 1.0.0; todos foram removidos
  • urlTransform(url, kind, defaultUrl) corre durante o parsing, para cada destino de link, imagem e referência. Devolver null remove o elemento e mantém o texto — é o nosso ponto de entrada para uma política de imagens
  • Perfil de streaming. streamingMarkdownExtension() volta a analisar todo o texto acumulado em cada atualização, esconde títulos, citações e itens de lista finais vazios e renderiza um bloco de código por fechar como código. **, links e code spans por fechar ficam como texto literal até fecharem. O resultado em HTML e em React é testado quanto à paridade estrutural em cada fronteira de caráter
  • Síncrono, sem dependências em runtime, sem efeitos. O componente React constrói os elementos diretamente, por isso renderiza no servidor e no cliente com o mesmo resultado; o output de SSR é testado contra o renderizador de HTML
  • AST serializável: analisar uma vez, guardar em cache, renderizar mais tarde para HTML ou React. components substitui qualquer tag emitida (a, img, pre) por um componente React nosso
  • Pequeno: parser de 5,2 KB gzip, adaptador React de 7,0 KB, 7,2 KB com a extensão de streaming (medição dos mantenedores; runtime da framework e realce de sintaxe excluídos). Os números deles para os outros: Marked 12,5 KB, unified + remark + rehype 36,8 KB, markdown-it 52,7 KB
  • O realce de sintaxe é um callback, com um adaptador oficial do TanStack Highlight, pelo que o Markdown nunca inclui uma gramática no bundle (ADR-0022). Os metadados do bloco de código (título, ficheiro, {2,4-6}) são analisados e entregues
  • A mesma organização do Router, do Query e do Start. O próprio TextPart do TanStack AI já renderiza com ele

Contras

  • Não é CommonMark nem GFM, por desenho. O README di-lo. A contabilidade dos próprios mantenedores dá 403 de 652 exemplos do CommonMark 0.31.2 (61,8%) e chama-lhe contabilidade, não um objetivo. Verificado na 1.0.0: títulos setext e blocos de código indentados renderizam como parágrafos simples, e URLs soltos e autolinks <https://…> ficam como texto. Tabelas, listas de tarefas, rasurado, blocos de código delimitados e notas de rodapé funcionam
  • Sem matemática, sem Mermaid, sem MDX. As extensões são hooks síncronos pequenos; os plugins do unified não se reutilizam
  • Jovem. Primeira publicação no npm a 2026-06-21, a 1.0.0 vinte dias antes deste ADR. O repositório do GitHub não tem ficheiro LICENSE; o MIT está declarado apenas no package.json
  • Não é um sanitizador. Os renderizadores confiam nas ASTs que lhes entregam, nos URLs devolvidos pelo urlTransform, no HTML das extensões e no output do realce de sintaxe; o filtro de URLs corre no parsing, não na renderização
  • As imagens remotas passam o filtro predefinido (só o esquema é verificado), e //host/x.png também
  • O componente Markdown não tem memoização: volta a analisar a cada render. Os limites cobrem o aninhamento e as pesquisas inline, não o tamanho total da entrada

react-markdown 10.1.0 (última versão no npm de 2025-03-07), MIT, 11 dependências.

Prós

  • O renderizador de referência para React; seguro por predefinição — sem dangerouslySetInnerHTML, HTML em bruto escapado ou ignorado (skipHtml), URLs filtrados pelo defaultUrlTransform (relativos, http, https, irc, ircs, mailto, xmpp)
  • 100% CommonMark, e 100% GFM com remark-gfm; o ecossistema de plugins mais vasto (matemática, diretivas, tudo o que o remark ou o rehype tenham)
  • O rehype-sanitize dá uma allowlist baseada em schema como segunda camada

Contras

  • Sem perfil de streaming: o Markdown parcial renderiza como o parser o vê, e cada atualização volta a analisar a string inteira através do pipeline do unified
  • Mais pesado: 36,8 KB gzip para a stack unified na medição dos mantenedores acima; a documentação dele aponta cerca de 60 KB minzipped para o rehype-raw, por cima
  • A segurança depende de cada plugin que acrescentarmos; o README diz que os plugins e os components podem ser inseguros

@mdx-js/mdx 3.1.1, MIT, 25 dependências.

Prós: componentes dentro do conteúdo; o padrão para documentação escrita por nós. Contras: o MDX compila para JavaScript. A documentação dele: “If you trust your authors, that’s fine. If you don’t, it’s unsafe. Do not let random people from the internet write MDX.” Isto exclui-o para texto de modelos e de utilizadores. Continua disponível para texto que escrevemos nós, como decisão à parte.

markdown-it 15.0.3, MIT, 6 dependências.

Prós: maduro; html: false por predefinição; grande ecossistema de plugins; um fluxo de tokens que se pode percorrer. Contras: sem renderizador React — o output predefinido é uma string de HTML, pelo que teríamos de escrever o mapeamento de tokens para React ou usar dangerouslySetInnerHTML. O validateLink é uma denylist (vbscript:, javascript:, file:, data: exceto tipos de imagem comuns), pelo que todos os outros esquemas passam. 52,7 KB gzip na medição dos mantenedores.

marked 18.1.1 (MIT, sem dependências) e dompurify 3.4.17 (MPL-2.0 OR Apache-2.0).

Prós: o marked é pequeno (12,5 KB gzip na medição dos mantenedores), rápido e familiar. Contras: o README do marked: “does not sanitize the output HTML”, pelo que o DOMPurify é obrigatório. O DOMPurify precisa de um DOM, o que no servidor significa jsdom — e a documentação dele diz que o happy-dom não é considerado seguro. O output é uma string de HTML: sem mapeamento de componentes, dangerouslySetInnerHTML em todo o lado, e o HTML em bruto passa para ser sanitizado em vez de nunca chegar a ser analisado.

streamdown 2.7.1, Apache-2.0, 15 dependências, feito para streams de IA como substituto direto do react-markdown.

Prós

  • Repara Markdown por terminar durante o streaming (remend: negrito, itálico, links, código inline, KaTeX), memoiza blocos e traz GFM, matemática com KaTeX, Mermaid e plugins CJK
  • Realce de sintaxe com Shiki, linguagens carregadas a pedido e temas claro/escuro; o rehype-harden pode restringir protocolos de links e origens de imagens
  • Publicado ativamente

Contras

  • Predefinições permissivas. De origem permite todos os links, todos os protocolos, imagens base64 e HTML em bruto (através do rehype-raw mais o rehype-sanitize); a documentação diz para apertar isto com conteúdo não fiável. Definir a prop rehypePlugins substitui o array predefinido inteiro, pelo que omitir o sanitize remove em silêncio a proteção contra XSS
  • Assenta no marked mais a stack unified; precisa de uma linha @source do Tailwind e traz a sua própria UI (controlos nos blocos de código, como botões de copiar) para reestilizar
  • O plugin de código traz o Shiki, que o ADR-0022 não adota como predefinição
  • O benchmark de browser do próprio TanStack (Chrome 152, Streamdown 2.6.0, código como texto simples) coloca a latência por atualização do TanStack Markdown abaixo da do Streamdown em blocos de código por fechar. É o benchmark do concorrente e não o reproduzimos, por isso não o pesamos

A. Desligado — o HTML dentro do Markdown é mostrado como texto

Seção intitulada “A. Desligado — o HTML dentro do Markdown é mostrado como texto”

Prós

  • Nada para sanitizar, logo sem sanitizador, sem allowlist para manter e sem DOM no servidor
  • A menor superfície de ataque; a mesma regra para texto de modelos, de utilizadores e escrito por nós

Contras

  • Sem HTML inline (<details>, <kbd>, <sub>); READMEs colados mostram as tags como texto

allowHtml: true mais DOMPurify, ou rehype-raw mais rehype-sanitize.

Prós: o conteúdo rico sobrevive. Contras: uma segunda dependência no pipeline, uma allowlist que alguém tem de rever, um DOM no servidor para o DOMPurify, e o TanStack Markdown insere o HTML em bruto com dangerouslySetInnerHTML por nó, pelo que sanitizar implica trabalhar sobre a string final.

Prós: HTML para o texto que escrevemos nós. Contras: a uma prop de distância de ser ligado para texto de utilizadores; a própria documentação da biblioteca diz para não o ativar com conteúdo não fiável.

O TanStack Markdown renderiza todo o Markdown das nossas apps — respostas de modelos, texto de utilizadores e texto escrito por nós — com o HTML em bruto desligado e uma só política de links e imagens. É o padrão da empresa para todas as novas apps.

Em concreto:

  • Pacote: @tanstack/markdown 1.x. Um só pacote; a ligação ao React é a entrada @tanstack/markdown/react, não uma instalação à parte. Usar entradas estreitas (/react, /parser, /extensions/streaming), não a entrada predefinida, em código de browser. Não acrescentar react-markdown, remark-*, rehype-*, marked, markdown-it, streamdown nem dompurify para Markdown; um produto que precise de um escreve o seu próprio ADR

  • Configuração num módulo, renderização num componente. src/lib/markdown.ts é TypeScript puro, sem React e sem imports exclusivos do servidor, para que servidor e cliente o importem. Exporta as opções partilhadas: a política de URLs, a extensão de streaming, frontmatter: false e headingIds: false (o frontmatter não pode reinterpretar o início de uma resposta de modelo, e um título a crescer não pode mudar de id a cada token). src/components/markdown/markdown.tsx exporta Markdown({ source, className }), que só renderiza: aplica essas opções, passa o realce de sintaxe do ADR-0022 e fornece um mapa components. Os ecrãs, as mensagens de chat e os comentários chamam Markdown; mais nada importa @tanstack/markdown. Um produto estende-o através de props (extensions, components), nunca fazendo fork do módulo

  • O HTML em bruto fica desligado. allowHtml nunca é definido. Qualquer produto que precise de HTML em bruto escreve o seu próprio ADR com um sanitizador, e isso não pode aplicar-se a texto de modelos nem de utilizadores

  • Links: manter o filtro incorporado (relativos, http, https, mailto, tel). A substituição de a em components acrescenta rel="noopener noreferrer nofollow" e target="_blank" aos links http(s) absolutos. Decidir se um URL é permitido fica em src/lib/markdown.ts, nunca num componente

  • As imagens remotas ficam desligadas. Só são permitidas a origem da app e o nosso próprio armazenamento ou CDN. Comparar origens depois de resolver, não prefixos — //host/x.png não tem esquema e passa o filtro da biblioteca:

    src/lib/markdown.ts
    import type { UrlTransform } from "@tanstack/markdown";
    const IMAGE_ORIGINS = new Set([APP_ORIGIN]); // mais a origem do nosso armazenamento ou CDN
    export const urlTransform: UrlTransform = (url, kind, defaultUrl) => {
    if (kind === "link") return defaultUrl;
    try {
    return IMAGE_ORIGINS.has(new URL(url, APP_ORIGIN).origin) ? defaultUrl : null;
    } catch {
    return null;
    }
    };

    null mantém o texto alternativo e remove a imagem, pelo que o URL de uma imagem injetada nunca é pedido. As imagens data: já são removidas pelo filtro predefinido

  • A fronteira de confiança é a string de origem. Renderizar apenas ASTs que a nossa própria chamada a parseMarkdown construiu a partir de uma string que temos; nunca aceitar uma AST ou um documento JSON vindo de um cliente — os renderizadores não voltam a filtrar URLs. Não escrever extensões com hooks renderHtml (o output delas é HTML de confiança). Os metadados de um bloco de código são dados: escolher campos conhecidos, nunca os avaliar

  • Validar o Markdown na fronteira: z.string().max(…) no schema partilhado (ADR-0005), com um limite por campo. A biblioteca limita o aninhamento e as pesquisas, não o tamanho total

  • SSR: o Markdown é síncrono e não tem efeitos, por isso renderiza-se diretamente a partir de source na árvore de componentes — o mesmo markup no servidor e no cliente. Sem useEffect, sem wrapper só de cliente, sem suppressHydrationWarning

  • Memoizar: o Markdown vai envolvido em memo e recebe uma string simples, porque a biblioteca volta a analisar a cada render. Uma mensagem em streaming não volta então a analisar as já terminadas que estão por cima

  • Streaming: a extensão de streaming fica ligada para todo o texto, também depois de a resposta terminar. Manter a string acumulada em estado e passar a string inteira; não há estado incremental do parser. Cada atualização volta a analisar tudo, por isso atualizar no máximo uma vez por frame ou limitar com o ADR-0023. **, links e code spans por fechar aparecem como texto literal até fecharem, e uma tabela pode reorganizar o último bloco — aceitar, sem acrescentar uma camada de reparação

  • Estilos: os estilos dos elementos vivem em src/styles/app.css sob uma classe de contentor .markdown-renderer, usando os tokens do shadcn/ui (ADR-0011); sem estilos inline. A mesma classe de contentor é a que o ADR-0022 usa para o código

  • O texto do chat vindo do SDK de IA (ADR-0025) passa por este componente Markdown, para que as respostas de modelos tenham a mesma política que o texto de utilizadores

  • Testes (ADR-0014): um corpus de ataques em bun test renderizado através do Markdown — <script> em bruto, <img onerror>, links javascript: e data: (inline e por referência), imagens //host/x.png — a verificar que não há <script, atributos de event handlers, javascript: nem data: em href ou src, nem <img> de uma origem estranha. Um segundo teste renderiza cada prefixo de uma resposta de exemplo sem lançar erros. Correr ambos em cada atualização; correções do parser podem alterar o output

Os fatores decisivos: predefinições seguras com o HTML em bruto nunca analisado, pelo que não há sanitizador para correr nem manter; perfil de streaming e SSR síncrono num só pacote pequeno; uma AST serializável; e realce de sintaxe oficial. Aceitamos uma cobertura de sintaxe mais estreita do que o CommonMark e o risco de uma 1.0 com vinte dias.

Mais fácil:

  • Um só renderizador e uma só política para chat, comentários e notas; as regras de XSS e de links revêem-se em dois ficheiros
  • Sem sanitizador no pipeline, e sem DOM necessário no servidor
  • Bundle pequeno no browser; o streaming funciona com uma string em estado e sem ciclo de vida do parser
  • Servidor e cliente renderizam o mesmo markup; a AST pode ser guardada em cache quando o conteúdo é armazenado
  • O realce de sintaxe é um callback explícito, pelo que as gramáticas nunca entram no bundle do Markdown

Mais difícil:

  • A sintaxe é um subconjunto documentado: URLs soltos não viram links, títulos setext e código indentado ficam como texto simples, e só 403 de 652 exemplos do CommonMark coincidem. Passar uma amostra de respostas reais de modelos por ele antes do lançamento, e avisar os autores que colam Markdown de outros sítios
  • Não há ecossistema remark, rehype nem MDX: matemática, Mermaid e tudo o resto é nosso para construir como extensão, ou para dispensar
  • A política de URLs e imagens é nossa. A biblioteca confia no que lhe damos na renderização, por isso o teste do corpus de ataques não é opcional
  • O pacote está a poucas semanas da 1.0 e o repositório não tem ficheiro LICENSE: fixar em 1.x, ler o changelog em cada minor e pedir ao jurídico que confirme a declaração MIT antes de um produto ir para produção com ele
  • O @tanstack/ai-react 0.31.0 depende de @tanstack/markdown ^0.0.13, e um caret em 0.0.x só aceita essa versão exata — usar o TextPart dele põe uma segunda cópia da biblioteca ao lado da nossa. O texto do chat deve passar antes pelo nosso componente Markdown
  • Sem HTML em bruto: os utilizadores não podem embeber <details> nem <kbd>, e o HTML colado aparece como texto
  • O Markdown volta a analisar a cada render, pelo que esquecer o memo custa CPU em chats longos

Do que abdicamos: da completude do CommonMark e do GFM e do ecossistema de plugins do unified, da reparação de Markdown parcial de origem do Streamdown, da matemática e do Mermaid, e do MDX para tudo o que não seja fiável. Aceite — predefinições seguras, streaming e SSR num só pacote pequeno pesam mais, para texto de modelos e de utilizadores, do que sintaxe exótica; um produto que precise de CommonMark completo ou de matemática escreve um ADR.