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.
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.
- interpreter.ts
- Usage
/**
* 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 };
}
import { useEffect } from "react";
import { FileText } from "lucide-react";
import { useMarkdownEditor } from "@/hooks/useMarkdownEditor";
import { useSyncScroll } from "@/hooks/useSyncScroll";
import { PREVIEW_STYLES } from "@/data/previewStyles";
import { PanelHeader } from "./PanelHeader";
import { AstTreeLine } from "./AstTreeLine";
import { TabBar } from "./TabBar";
import { RightPanelToggle } from "./RightPanelToggle";
export function MarkdownPreview() {
const { source, setSource, tab, setTab, rightPanel, setRightPanel, html, astLines } = useMarkdownEditor();
const { editorRef, previewRef, onEditorScroll, onPreviewScroll } = useSyncScroll();
useEffect(() => { editorRef.current?.focus(); }, [editorRef]);
const editorPanel = (
<div className="flex flex-col h-full min-h-0">
<PanelHeader icon={<FileText size={14} />} title="Markdown" />
<textarea ref={editorRef} value={source} onChange={(e) => setSource(e.target.value)} onScroll={onEditorScroll} spellCheck={false} className="flex-1 min-h-0 resize-none bg-transparent p-3 text-[13px] font-mono leading-relaxed text-zinc-300 placeholder-zinc-600 outline-none" placeholder="Type markdown here..." aria-label="Markdown editor" />
</div>
);
const previewContent = (
<div ref={previewRef} onScroll={onPreviewScroll} className="flex-1 min-h-0 overflow-auto p-4">
<style>{PREVIEW_STYLES}</style>
<div className="md-preview" dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
const astContent = (
<div className="flex-1 min-h-0 overflow-auto p-1">
{astLines.map((line, i) => <AstTreeLine key={i} line={line} />)}
</div>
);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.06] bg-white/[0.02] shrink-0">
<svg width="22" height="14" viewBox="0 0 208 128" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="198" height="118" rx="15" ry="15" fill="none" stroke="#f59e0b" strokeWidth="10" />
<path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39H30zm125 0l-30-33h20V30h20v35h20L155 98z" fill="#f59e0b" />
</svg>
<span className="text-sm font-semibold text-zinc-200">Markdown Interpreter</span>
<span className="text-[10px] text-zinc-600 ml-auto font-mono hidden sm:inline">tokenize → parse → evaluate</span>
</div>
{/* Mobile tabs */}
<TabBar active={tab} onChange={setTab} />
{/* Mobile: single panel */}
<div className="flex-1 min-h-0 sm:hidden">
{tab === "editor" && editorPanel}
{tab === "preview" && <div className="flex flex-col h-full min-h-0">{previewContent}</div>}
{tab === "ast" && <div className="flex flex-col h-full min-h-0">{astContent}</div>}
</div>
{/* Desktop: 2 columns */}
<div className="hidden sm:flex flex-1 min-h-0">
<div className="flex-1 min-w-0 border-r border-white/[0.06]">{editorPanel}</div>
<div className="flex-1 min-w-0 flex flex-col h-full">
<div className="flex items-center px-3 h-[42px] border-b border-white/[0.06] bg-white/[0.02] shrink-0">
<RightPanelToggle active={rightPanel} onChange={setRightPanel} />
</div>
{rightPanel === "preview" ? previewContent : astContent}
</div>
</div>
</div>
);
}
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
- Eidos : Module Design Patterns Les 23 patterns GoF réimaginés pour le TypeScript fonctionnel
- Pourquoi la FP plutôt que la POO ? La philosophie derrière Eidos : pas de classes, pas d'héritage, juste des fonctions et des types
- Zygos Result Gestion d'erreurs typée pour vos évaluateurs et pipelines