Aller au contenu principal

Pattern Interpreter

Définissez une grammaire sous forme de fonctions composables et évaluez les expressions en parcourant la structure.


Le Problème

Vous devez parser du Markdown en HTML. L'approche naïve : un tas de remplacements regex.

function renderMarkdown(source: string): string {
let html = source;
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/`(.+?)`/g, "<code>$1</code>");
// ... 30 regex de plus, l'ordre compte, des cas limites partout
return html;
}

Ça casse dès que vous avez du formatage imbriqué (**gras *et italique***), des blocs de code qui contiennent des *astérisques*, ou des liens dans des titres. Chaque nouvelle fonctionnalité ajoute plus de regex, plus de bugs d'ordonnancement, plus de cas limites. Ça ne passe pas à l'échelle.


La Solution

Séparez la grammaire de l'évaluation. Définissez l'AST comme une discriminated union, puis parcourez-le récursivement.

// 1. Définir la grammaire comme des types
type MdNode =
| { type: "document"; children: MdNode[] }
| { type: "heading"; level: number; children: MdNode[] }
| { type: "paragraph"; children: MdNode[] }
| { type: "bold"; children: MdNode[] }
| { type: "italic"; children: MdNode[] }
| { type: "code-block"; lang: string; code: string }
| { type: "text"; value: string }
// ... autant de types de nœuds que votre grammaire en a besoin

// 2. Évaluer récursivement : un switch, une fonction
function evalNode(node: MdNode): string {
switch (node.type) {
case "document":
return node.children.map(evalNode).join("\n");
case "heading":
return `<h${node.level}>${node.children.map(evalNode).join("")}</h${node.level}>`;
case "paragraph":
return `<p>${node.children.map(evalNode).join("")}</p>`;
case "bold":
return `<strong>${node.children.map(evalNode).join("")}</strong>`;
case "italic":
return `<em>${node.children.map(evalNode).join("")}</em>`;
case "code-block":
return `<pre><code>${escapeHtml(node.code)}</code></pre>`;
case "text":
return escapeHtml(node.value);
}
}

// 3. Pipeline : source → tokens → AST → HTML
const tokens = tokenize(source);
const ast = parse(tokens);
const html = evalNode(ast);

Ajouter un nouveau type de nœud ? Ajoutez un variant à l'union, ajoutez un case au switch. TypeScript détecte les cas manquants à la compilation. L'imbrication fonctionne gratuitement : evalNode s'appelle lui-même.

Absorbé par le Langage

Cette solution n'utilise pas Pithos. C'est justement le point.

En TypeScript, les discriminated unions + switch sont le pattern Interpreter. Eidos exporte une fonction @deprecated interpret() qui n'existe que pour vous guider ici, tout comme Taphos marque les API natives qui ont remplacé les utilitaires Arkhe.


Démo

Tapez du Markdown à gauche, voyez le HTML évalué à droite. Basculez sur AST pour voir l'arbre que l'évaluateur parcourt.

Code
/**
* Markdown Interpreter — evaluator (the Interpreter pattern).
*
* This file IS the pattern: a recursive fold over a discriminated union AST.
* No library needed — just a switch on the discriminant + recursion.
*/

import { tokenizeWithRefs, parse, type MdNode } from "./parsing-pipeline";
import { escape as escapeHtml } from "@pithos/core/arkhe/string/escape";
import { evalChildren, evalListItem, evalTable } from "./evalHelpers";

export type { MdNode } from "./parsing-pipeline";

// ─── Evaluator (AST → HTML) — the Interpreter pattern ──────────────

type EvalContext = { indent: number };

function evalNode(node: MdNode, ctx: EvalContext): string {
switch (node.type) {
case "document":
return node.children.map((c) => evalNode(c, ctx)).join("\n");
case "heading":
return `<h${node.level}>${evalChildren(node.children, ctx, evalNode)}</h${node.level}>`;
case "paragraph":
return `<p>${evalChildren(node.children, ctx, evalNode)}</p>`;
case "blockquote":
return `<blockquote>${node.children.map((c) => evalNode(c, ctx)).join("\n")}</blockquote>`;
case "code-block":
return `<pre><code class="language-${escapeHtml(node.lang)}">${escapeHtml(node.code)}</code></pre>`;
case "hr":
return "<hr />";
case "unordered-list":
return `<ul>${node.items.map((item) => evalListItem(item, ctx, evalNode)).join("")}</ul>`;
case "ordered-list":
return `<ol>${node.items.map((item) => evalListItem(item, ctx, evalNode)).join("")}</ol>`;
case "table":
return evalTable(node, ctx, evalNode);
case "text":
return escapeHtml(node.value);
case "bold":
return `<strong>${evalChildren(node.children, ctx, evalNode)}</strong>`;
case "italic":
return `<em>${evalChildren(node.children, ctx, evalNode)}</em>`;
case "bold-italic":
return `<strong><em>${evalChildren(node.children, ctx, evalNode)}</em></strong>`;
case "strikethrough":
return `<del>${evalChildren(node.children, ctx, evalNode)}</del>`;
case "inline-code":
return `<code>${escapeHtml(node.value)}</code>`;
case "link":
return `<a href="${escapeHtml(node.href)}" target="_blank" rel="noopener noreferrer">${evalChildren(node.children, ctx, evalNode)}</a>`;
case "autolink":
return `<a href="${escapeHtml(node.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(node.href)}</a>`;
case "image":
return `<img src="${escapeHtml(node.src)}" alt="${escapeHtml(node.alt)}" />`;
case "checkbox":
return node.checked
? `<input type="checkbox" checked disabled /> `
: `<input type="checkbox" disabled /> `;
case "line-break":
return "<br />";
case "html-block":
return node.content;
case "inline-html":
return node.content;
}
}

// ─── Public API ─────────────────────────────────────────────────────

/** Full pipeline: source → tokens → AST → HTML */
export function renderMarkdown(source: string): { ast: MdNode; html: string } {
const { tokens, refLinks } = tokenizeWithRefs(source);
const ast = parse(tokens, refLinks);
const html = evalNode(ast, { indent: 0 });
return { ast, html };
}


























Result

Au-delà du Markdown

Le même pattern fonctionne pour n'importe quelle grammaire. Voici un DSL de requêtes :

type Query =
| { type: "select"; fields: string[] }
| { type: "where"; source: Query; predicate: (row: Row) => boolean }
| { type: "limit"; source: Query; count: number };

function execute(query: Query, data: Row[]): Row[] {
switch (query.type) {
case "select":
return data.map(row => pick(row, query.fields));
case "where":
return execute(query.source, data).filter(query.predicate);
case "limit":
return execute(query.source, data).slice(0, query.count);
}
}

Une discriminated union pour la grammaire. Une fonction récursive pour l'évaluation. C'est tout le pattern.


API

  • interpreter @deprecated — utilisez les discriminated unions avec l'évaluation récursive

Liens connexes