↑ top
← Back to blog
EN | ES

Syntax Highlighting Server-Side Sin una Librería

Por qué escribí un tokenizer personalizado en lugar de usar Prism o highlight.js en un Cloudflare Worker, y cómo funciona un scanner de tokens de izquierda a derecha.

✦ AI Summary

El syntax highlighting en la web normalmente funciona de two maneras: una librería client-side (Prism, highlight.js) or unalibrería server-side. En un Cloudflare Worker, ambos enfoques tienen problemas. Terminé escribiendo un tokenizer personalizado. Aquí está el porqué, y cómo funciona.

Syntax Highlighting Server-Side Sin una Librería

El syntax highlighting en la web normalmente funciona de dos maneras: una librería client-side (Prism, highlight.js) corre después de que la página carga y coloriza los bloques de código, o una librería server-side (Shiki, Prism con SSR) lo hace al momento de renderizar.

En un Cloudflare Worker, ambos enfoques tienen problemas. Terminé escribiendo un tokenizer personalizado. Aquí está el porqué, y cómo funciona.

Por Qué No Prism o highlight.js Client-Side

El highlighting client-side carga una librería JavaScript en el navegador — Prism son ~30KB minificado, highlight.js son ~30-900KB dependiendo de cuántos lenguajes incluyas. La librería corre después de que la página carga, parsea los bloques de código, y aplica los spans de color.

Para un blog estático, esto significa:

  1. El HTML llega con bloques de código sin color
  2. JS carga y se ejecuta
  3. Los bloques de código se colorean

Hay un flash de código sin estilo. En conexiones lentas es visible. En conexiones rápidas es solo trabajo desperdiciado — el servidor podría haberlo hecho.

Por Qué No Shiki Server-Side

Shiki es un highlighter server-side que usa las gramáticas TextMate de VS Code. La calidad de salida es excelente — maneja todos los lenguajes correctamente y coincide con los temas del editor con precisión.

El problema: el bundle completo de Shiki pesa 7-8MB. Los Cloudflare Workers tienen un límite de tamaño de script de 1MB. Shiki no cabe.

Hay maneras de evitar esto (imports dinámicos, Worker separado para highlighting, WASM alojado en CDN), pero añaden complejidad que no quería. El objetivo era un Worker de un solo archivo sin paso de build.

El Tokenizer Personalizado

El enfoque: escribir un tokenizer mínimo que maneje los lenguajes que realmente uso (JavaScript, TypeScript, shell, YAML, SQL, y algunos otros) y produzca salida razonable para todo lo demás.

El tokenizer trabaja de izquierda a derecha a través del código fuente, haciendo match del token de mayor prioridad en cada posición:

javascriptconst PATTERNS = [
  // Strings primero — previene falsos positivos dentro de contenido de strings
  { type: "str", re: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/y },
  // Comentarios de bloque /* ... */
  { type: "cmt", re: /(\/\*[\s\S]*?\*\/)/y },
  // Comentarios de línea (//) y de shell/YAML (#)
  { type: "cmt", re: /(\/\/[^\n]*|#[^\n]*)/y },
  // Claves de config: palabra al inicio de línea seguida de ":"
  { type: "key", re: /(?:^|\n)([\w-]+)\s*:/y },
  // Variables de shell $VAR o ${VAR}
  { type: "var", re: /(\$\{?[\w]+\}?)/y },
  // Keywords
  { type: "kw", re: /\b(const|let|var|function|async|await|return|if|else|for|while|class|import|export|from|default|new|typeof|instanceof|null|undefined|true|false|void)\b/y },
  // Números
  { type: "num", re: /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/y },
];

El flag y en cada regex es el flag sticky — solo hace match en la posición lastIndex actual. Esto es lo que hace funcionar el scan de izquierda a derecha sin backtracking.

Para cada posición en el string, el tokenizer intenta cada patrón en orden. El primer match gana. Los caracteres sin match se tratan como texto plano.

javascriptlet pos = 0;
while (pos < code.length) {
  let matched = false;
  for (const { type, re } of PATTERNS) {
    re.lastIndex = pos;
    const m = re.exec(code);
    if (m && m.index === pos) {
      output += `<span class="hl-${type}">${escapeHtml(m[1])}</span>`;
      pos += m[0].length;
      matched = true;
      break;
    }
  }
  if (!matched) {
    output += escapeHtml(code[pos]);
    pos++;
  }
}

Los tokens superpuestos se manejan por el orden de prioridad. Los strings tienen mayor prioridad — una keyword dentro de un literal de string no se colorea como keyword.

El Mapeo de Colores

Los tipos de token se mapean a la paleta de colores del sitio:

css.hl-kw  { color: var(--accent); }        /* amarillo-verde: keywords */
.hl-key { color: var(--accent2); }       /* cyan: claves de config */
.hl-var { color: var(--accent2); }       /* cyan: variables de shell */
.hl-str { color: #ff9f47; }              /* naranja: strings */
.hl-num { color: #ff9f47; }              /* naranja: números */
.hl-cmt { color: var(--text-dim); }      /* gris: comentarios */

Los colores coinciden con la animación de terminal en la página de inicio, así que los bloques de código se sienten visualmente consistentes con el resto del sitio.

Limitaciones

El tokenizer no parsea. Hace pattern-matching. No entiende scope, información de tipos, ni semántica específica del lenguaje. Una regex que parece un string en JavaScript se colorea como string. Una keyword usada como nombre de propiedad (.return, .class) se colorea como keyword.

Para un blog, estos casos extremos son raros y de bajo impacto. Los lectores siguen la lógica, no el coloreo exacto. Un tokenizer de 60 líneas que funciona para el 95% del código real en posts de blog es mejor que una librería de 7MB que funciona perfectamente.

Si estuviera construyendo un editor de código o un plugin de IDE, usaría un enfoque basado en gramáticas. Para un Worker que necesita resaltar bloques de código en posts de blog, esto es exactamente suficiente.

Was this helpful?