Pular para o conteúdo

ADR-0022 Realce de sintaxe

Realce de sintaxe padrão da empresa — TanStack Highlight com uma lista explícita de linguagens, HTML escapado baseado em classes e cores vindas das nossas próprias variáveis CSS em claro e escuro.

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

O código aparece em três sítios: blocos de código dentro de Markdown renderizado (ADR-0021) — sobretudo respostas de modelos (ADR-0025) e texto de utilizadores — e excertos isolados, como comandos para copiar ou exemplos de JSON. Tudo deve ser colorido, no servidor tal como no cliente.

Decisões à volta:

  • SSR: o TanStack Start (ADR-0002) envia o HTML primeiro. O código colorido deve estar nesse HTML, sem flash de texto simples, e hidratar para o mesmo markup
  • Estilos: as cores vêm de variáveis CSS com valores claro e escuro, como funcionam os tokens do shadcn/ui (ADR-0011); sem injeção de estilos em runtime no caminho de renderização
  • Markdown: o TanStack Markdown recebe o realce de sintaxe como um callback síncrono e insere o valor devolvido em <code> como HTML de confiança — não distingue markup de tokens de uma injeção (ADR-0021)

O que está em aberto: que ferramenta de realce usar, que linguagens, como as cores se ligam aos nossos tokens e o que acontece com código enorme ou desconhecido.

Restrições:

  • O output é HTML de confiança. Uma ferramenta de realce que não escape o texto de origem transforma cada bloco de código num buraco de XSS — e o código vem de modelos e de utilizadores
  • Síncrono. O callback corre durante a renderização, no servidor e outra vez no cliente. Uma ferramenta assíncrona não tem lugar aqui
  • Bundle: o browser só deve receber as linguagens que usamos
  • Temas: claro/escuro a partir das nossas variáveis, uma só árvore de markup, sem cores inline
  • Streaming: um bloco de código por fechar é realçado de novo em cada atualização (ADR-0021), pelo que o custo tem de se manter pequeno
  • Entrada desconhecida: uma linguagem desconhecida ou um excerto inválido tem de degradar para texto simples, não lançar erros

@tanstack/highlight 1.1.0 (2026-10-08), MIT, ESM, sem dependências, Node 18+.

Prós

  • Síncrono, sem inicialização. Sem WASM, sem worker, sem promise; as mesmas linguagens registadas devolvem o mesmo output no servidor e no browser (corremo-lo duas vezes: idêntico)
  • Seletivo por construção. @tanstack/highlight/core não traz linguagens; cada uma é o seu próprio import. Números gzip dos mantenedores: core 1,82 KB, core + TSX 4,26 KB, um conjunto de nove linguagens de documentação 6,20 KB, as 39 linguagens 17,73 KB
  • Output escapado, sem estilos inline. Uma só árvore <pre><code> com classes th-* estáveis para os tokens e sem cores inline. Corremos um bloco com <img src=x onerror=…>, um com uma string de linguagem hostil e uma linguagem desconhecida: tudo saiu escapado, com as linguagens desconhecidas como texto simples
  • Os temas são variáveis CSS sobre uma só árvore de markup. As cores são variáveis --th-*, pelo que claro e escuro é uma troca de CSS, sem segundo markup nem segunda passagem de realce
  • Adaptador oficial para o Markdown (createTanStackMarkdownHighlighter), mais remark, rehype e metadados do bloco de código: título, números de linha, {2,4-6}, ins e del, decorações de linhas e de intervalos
  • Rápido em código de tamanho de documentação. Os mantenedores mediram 4,6 ms para 334 fixtures de documentação com a cache quente, contra 182 ms do Shiki (medido antes da 1.0, e não é trabalho equivalente: o Shiki analisa muito mais fundo). A nossa própria verificação (Node 26, um portátil, não é um benchmark): cerca de 0,14 ms por KiB
  • Política de compatibilidade para a 1.x: os pontos de entrada, o significado das classes th-* e a estrutura do wrapper mantêm-se estáveis; linguagens e temas novos saem em minors. A 1.1.0 acrescentou Java, Kotlin, Rust, Ruby, C#, Dart, Lua, Perl e Swift
  • A mesma organização do Router, do Query e do Start

Contras

  • Um realçador pequeno para documentação, não um motor de gramáticas. Foi feito para código válido que se encontra em documentação. Sem gramáticas TextMate nem do VS Code, sem deteção automática de linguagem, sem tokens semânticos; código invulgar ou inválido pode ser colorido de forma errada
  • 39 linguagens. Sem GraphQL, PowerShell nem Terraform/HCL, e c não é alias de cpp — um bloco c fica como texto simples. Uma linguagem não registada, ou um alias cujo destino não está registado, cai no fallback de texto simples
  • Jovem. Primeira publicação no npm a 2026-06-21, 1.0.0 a 2026-09-30. As correções do parser saem como patches e podem mudar a classe que um token recebe, pelo que o HTML exato não está congelado
  • Os temas são só objetos de cor: sem peso de letra, itálico nem verificações de contraste. Só vêm 11, e nenhum usa os nossos tokens
  • O custo síncrono cresce com o tamanho do bloco; a documentação diz que entrada ilimitada precisa de um limite nosso

shiki 4.5.0 (2026-10-01), MIT, gramáticas TextMate — o motor por trás do realce do VS Code.

Prós

  • O mais preciso: gramáticas reais, um conjunto muito grande de linguagens e temas, compatibilidade com temas do VS Code, transformers, um plugin rehype
  • Temas duplos claro/escuro através de variáveis CSS (--shiki-light, --shiki-dark); bundles de granularidade fina; um motor de regex em JavaScript como alternativa ao WASM
  • Ativo, e a ferramenta de realce por trás do plugin de código do Streamdown

Contras

  • Pesado por predefinição. Os bundles pré-construídos têm 6,4 MB minificados (1,2 MB gzip) em shiki/bundle/full e 3,8 MB (695 KB gzip) em shiki/bundle/web, chunks assíncronos incluídos; para o browser são precisas as entradas de granularidade fina
  • Configuração assíncrona. await createHighlighter() com o Oniguruma compilado para WASM é o caminho predefinido. Existe um core síncrono (createHighlighterCoreSync, desde a 1.16), mas exige linguagens e temas como objetos simples e um motor JavaScript explícito. Um callback do Markdown é síncrono, por isso é ligação extra
  • Estilos inline em cada token (style="--shiki-dark:…;--shiki-light:…"), e com o output de tema duplo predefinido o modo escuro precisa de overrides com !important, a menos que defaultColor seja false. Na comparação dos mantenedores produziu 3,4 vezes mais HTML para a mesma documentação
  • Sem adaptador oficial para o TanStack Markdown; escreveríamos o callback e seríamos donos do escape

prismjs 1.30.0 (2025-03-10), prism-react-renderer 2.4.1 (2025-08-14), ambos MIT.

Prós: maduro e muito usado (o Docusaurus usa o prism-react-renderer); síncrono; tokens baseados em classes com temas CSS; linguagens modulares. Contras: as versões do prismjs são lentas (1.29.0 em 2022-08, depois 1.30.0 em 2025-03); o prism-react-renderer inclui um Prism modificado com uma lista curta de linguagens por predefinição e o seu próprio formato de tema; sem adaptador para o TanStack Markdown, pelo que o callback e o seu escape são nossos.

highlight.js 11.12.0 (2026-08-12), BSD-3-Clause, sem dependências.

Prós: 193 linguagens e 516 temas; deteção automática de linguagem; síncrono; baseado em classes. Contras: o maior core das opções pequenas (29,54 KB minificados, 11,12 KB gzip só para TypeScript, no benchmark do Sugar High). A deteção automática é trabalho desperdiçado — os blocos indicam a linguagem — e um palpite errado colore o código como a linguagem errada. Sem adaptador para o Markdown.

sugar-high 2.6.1 (2026-10-19), MIT, sem dependências.

Prós: minúsculo (4,35 KB gzip para TypeScript no seu próprio benchmark); síncrono; 32 linguagens na v2; as cores dos tokens são propriedades CSS personalizadas; existem @sugar-high/react e @sugar-high/remark. Contras: o tema documentado tem nove variáveis de cor (o Highlight tem 21 classes de token), pelo que a coloração é mais grosseira; sem adaptador oficial para o TanStack Markdown. A comparação do TanStack mediu o Sugar High 1.2.1 (pelo que 78 ms contra 377 ms e 6,7 MiB contra 44,8 MiB de HTML está desatualizado para a 2.x). É a alternativa mais próxima e o nosso plano B se o Highlight parar.

A. As nossas próprias variáveis CSS, escritas uma vez em app.css

Seção intitulada “A. As nossas próprias variáveis CSS, escritas uma vez em app.css”

O Highlight lê --th-background, --th-token e uma variável --th-<classe> por classe de token. Definimo-las em :root e .dark, como o shadcn/ui faz.

Prós

  • As cores seguem os nossos tokens: o modo escuro e a marca por produto trocam variáveis, não código (ADR-0011)
  • Nenhum <style> construído em runtime; o CSS está na única folha de estilos estática
  • O contraste é verificado por nós, por modo

Contras

  • Escrevemos e revemos 22 variáveis (21 classes de token mais o fundo) em dois modos, e copiamos as regras dos tokens uma vez

createThemeCss({ light, dark }) com githubLightTheme e githubDarkTheme (vêm 11 temas).

Prós: nada para escrever; paletas ao estilo do GitHub. Contras: cores hex fixas, sem relação com os nossos tokens, pelo que uma mudança de marca é uma mudança de código. Os exemplos React da documentação dele injetam o resultado como um elemento <style> dentro de um componente, contra a regra de não haver estilos em runtime do ADR-0011. Os temas não trazem validação de contraste, e os objetos de tema são configuração de confiança, não CSS sanitizado.

O TanStack Highlight realça todo o código das nossas apps, com uma lista explícita de linguagens e cores vindas das nossas próprias variáveis CSS. É o padrão da empresa para todas as novas apps.

Em concreto:

  • Pacote: @tanstack/highlight 1.x. Importar apenas /core, /languages/<nome>, /markdown e /theme. O highlight() da entrada raiz e a entrada agregada /languages puxam o build com todas as linguagens (cerca de 18 KB gzip); não os usar em código de app. Atualizá-lo em conjunto com o @tanstack/markdown (ADR-0021)

  • Um módulo por app, src/lib/highlight.ts, isomórfico e sem imports exclusivos do servidor. Regista as linguagens, constrói o realçador uma só vez, envolve-o para o Markdown e limita o trabalho:

    src/lib/highlight.ts
    import { createHighlighter, escapeHtml } from "@tanstack/highlight/core";
    import { css } from "@tanstack/highlight/languages/css";
    import { diff } from "@tanstack/highlight/languages/diff";
    import { html } from "@tanstack/highlight/languages/html";
    import { js } from "@tanstack/highlight/languages/js";
    import { json } from "@tanstack/highlight/languages/json";
    import { jsx } from "@tanstack/highlight/languages/jsx";
    import { plaintext } from "@tanstack/highlight/languages/plaintext";
    import { shell } from "@tanstack/highlight/languages/shell";
    import { sql } from "@tanstack/highlight/languages/sql";
    import { ts } from "@tanstack/highlight/languages/ts";
    import { tsx } from "@tanstack/highlight/languages/tsx";
    import { yaml } from "@tanstack/highlight/languages/yaml";
    import { createTanStackMarkdownHighlighter } from "@tanstack/highlight/markdown";
    import type { CodeHighlighter } from "@tanstack/markdown";
    export const highlighter = createHighlighter({
    languages: [plaintext, js, jsx, ts, tsx, json, html, css, shell, diff, yaml, sql],
    });
    export const MAX_HIGHLIGHT_CHARS = 20_000; // limite nosso, não da biblioteca
    const highlightFence = createTanStackMarkdownHighlighter(highlighter);
    export const highlightMarkdownCode: CodeHighlighter = (code, lang, options) =>
    code.length > MAX_HIGHLIGHT_CHARS ? escapeHtml(code) : highlightFence(code, lang, options);

    Um realçador ao nível do módulo é aceitável aqui, ao contrário de um QueryClient (ADR-0012): guarda apenas o registo de linguagens e nenhum dado de utilizador. Acrescentar uma linguagem é um import e uma entrada no array; os produtos estendem a lista, mais nada muda

  • Lista de linguagens. Registar html juntamente com js e css: o Highlight passa então a colorir os corpos de <script> e <style>, e só as linguagens registadas o fazem. Os aliases (typescript, sh, jsonc) só resolvem quando o destino está registado. Deixar o fallback em plaintext; os blocos de linguagem desconhecida renderizam como texto escapado, e não há deteção automática para ligar

  • O escape é inegociável. O Markdown só recebe o realçador através de createTanStackMarkdownHighlighter, porque insere a string devolvida como HTML de confiança. Nunca escrever um callback que devolva o texto de origem sem escape ou construa markup por concatenação de strings. Os excertos isolados usam um único componente CodeBlock que renderiza highlighter.highlight(code, { lang }).html — o único sítio, além do callback do Markdown, onde o output do realce vira HTML — e recorre a um <pre><code> simples de React acima de MAX_HIGHLIGHT_CHARS

  • SSR e hidratação: o realce corre durante a renderização, no servidor e outra vez no cliente, a partir do mesmo módulo e das mesmas linguagens registadas, pelo que o markup é idêntico. Sem useEffect, sem realce preguiçoso ou só de cliente, sem listas de linguagens separadas para servidor e cliente

  • Tema: as nossas variáveis, em src/styles/app.css. Definir as variáveis --th-* em :root e .dark, tirar os neutros dos tokens do shadcn/ui e estilizar o pre nós. Nunca chamar createThemeCss nem renderizar um elemento <style> para isto:

    src/styles/app.css
    :root {
    --th-background: var(--muted);
    --th-token: var(--foreground);
    --th-comment: var(--muted-foreground);
    --th-keyword: oklch(…); /* uma variável por classe de token, 21 ao todo */
    }
    .dark {
    --th-keyword: oklch(…); /* os mesmos nomes, valores escuros */
    }
    .markdown-renderer pre.tm-code,
    pre.th-code {
    overflow-x: auto;
    padding: 1rem;
    background: var(--th-background);
    color: var(--th-token);
    }
    .th-token {
    color: var(--th-token);
    } /* primeiro: os tokens levam th-token mais a sua própria classe */
    .th-keyword {
    color: var(--th-keyword);
    } /* uma regra por classe */
    .th-line--highlighted {
    background: color-mix(in srgb, var(--th-token) 10%, transparent);
    }

    As 21 classes são token, attr, code-inline, command, comment, deleted, function, heading, inserted, keyword, link, literal, meta, number, operator, property, selector, string, tag, type e variable. Partir de githubLightTheme e githubDarkTheme, converter os valores uma vez para oklch e verificar que cada cor tem pelo menos 4,5:1 contra --th-background nos dois modos — os temas do Highlight não trazem verificações de contraste. O modo escuro é a classe .dark e não precisa de uma segunda passagem de realce: claro e escuro partilham o mesmo markup. .markdown-renderer é a classe de contentor do ADR-0021, e pre.tm-code é o <pre> que o Markdown controla. Os números de linha estão desligados; um produto que os ligue acrescenta as regras de createThemeBaseCss()

  • Guarda contra desvios: um teste bun test verifica que cada nome em themeTokenClasses (exportado por @tanstack/highlight/theme) tem uma variável --th-<nome> em app.css. Uma classe nova só chegaria com uma versão major; o teste transforma isso numa verificação que falha em vez de código sem cor

  • Streaming: o Markdown volta a renderizar o texto acumulado e o Highlight volta a colorir o bloco de código por fechar em cada atualização. O custo cresce linearmente com o tamanho do bloco: a nossa verificação local mediu cerca de 2 ms para 16 KiB e 9 ms para 64 KiB, e um bloco de 16 KiB em streaming com atualizações de 32 carateres teve uma média de 0,9 ms por atualização, incluindo o parsing do Markdown. Limitar as atualizações (ADR-0023); os blocos acima de MAX_HIGHLIGHT_CHARS ficam como texto simples, porque a documentação do Highlight diz que entrada ilimitada precisa de um limite nosso

  • Os metadados de um bloco de código são dados: título, {2,4-6}, anotações de linhas e lineNumbers são lidos como texto e nunca avaliados. Confirmar a linguagem contra a nossa própria lista antes de a usar para qualquer outra coisa

  • Testes (ADR-0014): verificar que o texto de origem sobrevive, que <img onerror> dentro de um bloco sai escapado, que uma linguagem desconhecida cai em texto simples e que um token conhecido recebe a sua classe th-*. Não fazer snapshots do HTML realçado — os patches podem mudar as fronteiras dos tokens

Os fatores decisivos: um caminho síncrono e idêntico em SSR que encaixa num callback do Markdown; output escapado e baseado em classes, sem estilos inline, que as nossas variáveis CSS conseguem colorir nos dois modos; imports por linguagem; e um adaptador oficial para o Markdown da mesma equipa. Aceitamos um tokenizador heurístico e uma biblioteca a poucas semanas da 1.0.

Mais fácil:

  • O código colorido está no HTML do servidor e hidrata sem alterações: sem inicialização assíncrona, sem WASM, sem flash de texto simples
  • Um módulo lista as linguagens e um bloco de CSS guarda as cores; uma mudança de marca é editar variáveis, e o modo escuro não custa markup extra
  • O browser só recebe as linguagens que importamos
  • Linguagens desconhecidas e blocos grandes demais degradam para texto escapado; nada lança erros
  • O escape acontece num só sítio, o renderizador da biblioteca, que não escrevemos à mão

Mais difícil:

  • A precisão é menor do que a do Shiki: o tokenizador é heurístico, pelo que sintaxe invulgar ou código inválido pode ficar mal colorido
  • Só vêm 39 linguagens. Um bloco numa linguagem não registada ou inexistente (GraphQL, Terraform, c simples) renderiza como texto simples, e os utilizadores veem código sem cor
  • Somos donos de 22 variáveis em dois modos e do seu contraste; o teste de desvios cobre nomes, não cores
  • Os seletores estão acoplados ao contentor pre.tm-code do TanStack Markdown, e a biblioteca é jovem — ler os dois changelogs em cada atualização, e não fazer snapshots de HTML
  • Os excertos isolados são o único sítio, além do Markdown, onde pomos o output do realce em HTML; o componente CodeBlock tem de continuar a ser o único
  • O realce é síncrono, pelo que o limite de tamanho e a limitação de atualizações são trabalho nosso; o limite é um número que escolhemos, não um limite da biblioteca

Do que abdicamos: da precisão de gramáticas do Shiki, da compatibilidade com temas do VS Code e do seu conjunto muito grande de linguagens, além da deteção automática de linguagem. Aceite — output pequeno, síncrono, idêntico em SSR e com markup escapado pesa mais, para código em chat e comentários, do que coloração ao nível de um editor; um produto que precise de gramáticas exatas (por exemplo um visualizador de código) escreve um ADR para o Shiki.