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
Ligações
Contexto
Seção intitulada “Contexto”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
Opções consideradas
Seção intitulada “Opções consideradas”Renderizador
Seção intitulada “Renderizador”1. TanStack Markdown
Seção intitulada “1. TanStack Markdown”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,mailtooutelsão removidos, o texto do link fica. Corremosjavascript:,vbscript:,file:,data:eftp: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. Devolvernullremove 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.
componentssubstitui 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
TextPartdo 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 nopackage.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.pngtambém - O componente
Markdownnã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
2. react-markdown (unified: remark + rehype)
Seção intitulada “2. react-markdown (unified: remark + rehype)”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 pelodefaultUrlTransform(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-sanitizedá 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
componentspodem 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.
4. markdown-it
Seção intitulada “4. markdown-it”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.
5. marked + DOMPurify
Seção intitulada “5. marked + DOMPurify”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.
6. Streamdown (Vercel)
Seção intitulada “6. Streamdown (Vercel)”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-hardenpode 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-rawmais orehype-sanitize); a documentação diz para apertar isto com conteúdo não fiável. Definir a proprehypePluginssubstitui o array predefinido inteiro, pelo que omitir osanitizeremove em silêncio a proteção contra XSS - Assenta no
markedmais a stack unified; precisa de uma linha@sourcedo 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
HTML em bruto
Seção intitulada “HTML em bruto”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
B. Ligado, e sanitizar o resultado
Seção intitulada “B. Ligado, e sanitizar o resultado”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.
C. Ligado, só para autores de confiança
Seção intitulada “C. Ligado, só para autores de confiança”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.
Decisão
Seção intitulada “Decisão”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/markdown1.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 acrescentarreact-markdown,remark-*,rehype-*,marked,markdown-it,streamdownnemdompurifypara 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: falseeheadingIds: 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.tsxexportaMarkdown({ source, className }), que só renderiza: aplica essas opções, passa o realce de sintaxe do ADR-0022 e fornece um mapacomponents. Os ecrãs, as mensagens de chat e os comentários chamamMarkdown; 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.
allowHtmlnunca é 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 deaemcomponentsacrescentarel="noopener noreferrer nofollow"etarget="_blank"aos linkshttp(s)absolutos. Decidir se um URL é permitido fica emsrc/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.pngnã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 CDNexport 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;}};nullmantém o texto alternativo e remove a imagem, pelo que o URL de uma imagem injetada nunca é pedido. As imagensdata: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
parseMarkdownconstruiu 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 hooksrenderHtml(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 desourcena árvore de componentes — o mesmo markup no servidor e no cliente. SemuseEffect, sem wrapper só de cliente, semsuppressHydrationWarning -
Memoizar: o
Markdownvai envolvido emmemoe 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.csssob 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 testrenderizado através doMarkdown—<script>em bruto,<img onerror>, linksjavascript:edata:(inline e por referência), imagens//host/x.png— a verificar que não há<script, atributos de event handlers,javascript:nemdata:emhrefousrc, 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.
Consequências
Seção intitulada “Consequências”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 em1.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-react0.31.0 depende de@tanstack/markdown^0.0.13, e um caret em0.0.xsó aceita essa versão exata — usar oTextPartdele põe uma segunda cópia da biblioteca ao lado da nossa. O texto do chat deve passar antes pelo nosso componenteMarkdown - Sem HTML em bruto: os utilizadores não podem embeber
<details>nem<kbd>, e o HTML colado aparece como texto - O
Markdownvolta a analisar a cada render, pelo que esquecer omemocusta 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.

