diff --git a/src/components/MatchingAnimation.module.scss b/src/components/MatchingAnimation.module.scss new file mode 100644 index 00000000..def87432 --- /dev/null +++ b/src/components/MatchingAnimation.module.scss @@ -0,0 +1,297 @@ +@import "~bootstrap/scss/_functions.scss"; +@import "~bootstrap/scss/_variables.scss"; + +.container { + border: 1px solid $gray-300; + border-radius: 8px; + overflow: hidden; + margin: 2rem 0; +} + +.panel { + min-width: 0; + border-right: 1px solid $gray-300; + display: flex; + flex-direction: column; + + &:last-child { + border-right: none; + } +} + +.panelHeader { + background-color: $gray-700; + color: $gray-200; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.source { + padding: 0.75rem 0; + font-family: $font-family-monospace; + font-size: 0.82rem; + line-height: 1.6; + flex: 1; +} + +.sourceLine { + padding: 0.1rem 0.75rem; + color: $gray-700; + white-space: pre; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +.highlightedLine { + background-color: rgba($info, 0.12); + color: $gray-900; +} + +// Parse tree panel + +.tree { + padding: 0.75rem 0; + font-family: $font-family-monospace; + font-size: 0.72rem; + line-height: 1.5; + flex: 1; +} + +.treeLine { + padding: 0.05rem 0.75rem; + color: $gray-500; + white-space: pre; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +.treeLineHighlight { + color: $gray-700; + background-color: rgba($info, 0.08); +} + +.treeLineForced { + color: $gray-900; + background-color: rgba($warning, 0.15); +} + +.treeLineErroneous { + color: $gray-900; + background-color: rgba($danger, 0.15); +} + +.controls { + padding: 0.75rem; + border-top: 1px solid $gray-300; +} + +.description { + font-size: 0.9rem; + color: $gray-700; + min-height: 2.5em; + margin-bottom: 0.75rem; +} + +// Work area + +.workArea { + padding: 1rem; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} + +// Fork cards + +.forksArea { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-start; + width: 100%; +} + +.forkCard { + border: 2px solid $warning; + border-radius: 8px; + padding: 0.75rem; + flex: 1; + min-width: 180px; +} + +.forkCardDimmed { + opacity: 0.5; +} + +.forkHeader { + font-family: $font-family-monospace; + font-size: 0.85rem; + font-weight: 700; + color: darken($warning, 25%); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.skipLabel { + font-size: 0.7rem; + color: $success; + font-weight: 600; + letter-spacing: 0.05em; +} + +.collectLabel { + font-size: 0.7rem; + color: $danger; + font-weight: 600; + letter-spacing: 0.05em; +} + +.branchRow { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0; + flex-wrap: wrap; +} + +.branchLabel { + font-family: $font-family-monospace; + font-size: 0.8rem; + font-weight: 600; + color: $gray-600; + min-width: 1.2rem; +} + +.emptyLabel { + font-size: 0.78rem; + color: $gray-400; + font-style: italic; +} + +// Tag pills + +.tagPill { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 4px; + font-family: $font-family-monospace; + font-size: 0.78rem; + font-weight: 600; +} + +.openTag { + background-color: rgba($info, 0.15); + color: darken($info, 15%); + border: 1px solid rgba($info, 0.3); +} + +.closeTag { + background-color: rgba($purple, 0.15); + color: darken($purple, 10%); + border: 1px solid rgba($purple, 0.3); +} + +// Balance icons + +.balanceIcon { + font-size: 0.85rem; + font-weight: 700; + margin-left: 0.25rem; +} + +.balanced { + color: $success; +} + +.unbalanced { + color: $danger; +} + +// Collected annotation + +.collectedAnnotation { + width: 100%; + text-align: center; + font-size: 0.85rem; + color: $gray-600; + padding-top: 0.5rem; + border-top: 1px dashed $gray-300; + margin-top: 0.25rem; +} + +// Enumerate phase + +.enumerateArea { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; +} + +.enumerationRow { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: center; +} + +.assignmentPill { + font-family: $font-family-monospace; + font-size: 0.9rem; + padding: 0.35rem 0.75rem; + border-radius: 6px; + background-color: rgba($purple, 0.1); + color: darken($purple, 10%); + border: 1px solid rgba($purple, 0.2); +} + +.eventsStrip { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + justify-content: center; +} + +.resultBadge { + padding: 0.4rem 0.8rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; +} + +.resultBalanced { + background-color: rgba($success, 0.1); + color: $success; + border: 1px solid rgba($success, 0.3); +} + +.resultUnbalanced { + background-color: rgba($danger, 0.1); + color: $danger; + border: 1px solid rgba($danger, 0.3); +} + +// Result alert + +.resultAlert { + padding: 1rem 1.5rem; + border-radius: 8px; + background-color: rgba($danger, 0.08); + border: 1px solid rgba($danger, 0.2); + color: darken($danger, 10%); + font-size: 0.95rem; + font-weight: 500; + text-align: center; + font-family: $font-family-monospace; +} diff --git a/src/components/MatchingAnimation.tsx b/src/components/MatchingAnimation.tsx new file mode 100644 index 00000000..11076891 --- /dev/null +++ b/src/components/MatchingAnimation.tsx @@ -0,0 +1,491 @@ +import { useState } from "react"; +import classNames from "classnames"; +import { motion } from "framer-motion"; + +import { formatDescription } from "./animationUtils"; +import styles from "./MatchingAnimation.module.scss"; + +const SOURCE_LINES = [ + "{{#bold}}{{/bold}}", + "{{#bold}}{{/bold}}", + "{{#italic}}{{/italic}}", +]; + +const TREE_LINES = [ + "(mustache_section", + " (html_element", + " (html_start_tag)", + " (html_forced_end_tag))", + " ...)", + "(mustache_section", + " (html_erroneous_end_tag)", + " ...)", + "(mustache_section", + " (html_element", + " (html_start_tag)", + " (html_forced_end_tag))", + " ...)", +]; + +interface TagEvent { + tag: string; + type: "open" | "close"; +} + +interface ForkData { + id: string; + sectionName: string; + trueEvents: TagEvent[]; + falseEvents: TagEvent[]; + trueBalanced?: boolean; + falseBalanced?: boolean; + dimmed?: boolean; +} + +type Phase = "extract" | "merge" | "balance" | "enumerate"; + +interface EnumerationPath { + assignment: { sectionName: string; value: boolean }; + flattenedEvents: TagEvent[]; + result: { balanced: boolean; message: string }; +} + +interface MatchingStep { + phase: Phase; + phaseLabel: string; + highlightLine: number | null; + forks: ForkData[]; + description: string; + enumerations?: EnumerationPath[]; + resultMessage?: string; + collectedSections?: string[]; + pathCount?: number; + treeHighlight?: number[]; + treeEmphasis?: number[]; +} + +const PHASE_COLORS: Record = { + extract: "#fd7e14", + merge: "#0d6efd", + balance: "#198754", + enumerate: "#6f42c1", +}; + +const STEPS: MatchingStep[] = [ + { + phase: "extract", + phaseLabel: "Extract", + highlightLine: 0, + forks: [ + { + id: "bold-1", + sectionName: "bold", + trueEvents: [{ tag: "b", type: "open" }], + falseEvents: [], + }, + ], + description: + "Line 1: `forced_end_tag` on `` \u2192 `fork(bold, T=[], F=[])`", + treeHighlight: [0, 1, 2, 3, 4], + treeEmphasis: [3], + }, + { + phase: "extract", + phaseLabel: "Extract", + highlightLine: 1, + forks: [ + { + id: "bold-1", + sectionName: "bold", + trueEvents: [{ tag: "b", type: "open" }], + falseEvents: [], + }, + { + id: "bold-2", + sectionName: "bold", + trueEvents: [{ tag: "b", type: "close" }], + falseEvents: [], + }, + ], + description: "Line 2: erroneous `` \u2192 `fork(bold, T=[], F=[])`", + treeHighlight: [5, 6, 7], + treeEmphasis: [6], + }, + { + phase: "extract", + phaseLabel: "Extract", + highlightLine: 2, + forks: [ + { + id: "bold-1", + sectionName: "bold", + trueEvents: [{ tag: "b", type: "open" }], + falseEvents: [], + }, + { + id: "bold-2", + sectionName: "bold", + trueEvents: [{ tag: "b", type: "close" }], + falseEvents: [], + }, + { + id: "italic-1", + sectionName: "italic", + trueEvents: [{ tag: "i", type: "open" }], + falseEvents: [], + }, + ], + description: + "Line 3: `forced_end_tag` on `` \u2192 `fork(italic, T=[], F=[])`", + treeHighlight: [8, 9, 10, 11, 12], + treeEmphasis: [11], + }, + { + phase: "merge", + phaseLabel: "Merge", + highlightLine: null, + forks: [ + { + id: "bold-merged", + sectionName: "bold", + trueEvents: [ + { tag: "b", type: "open" }, + { tag: "b", type: "close" }, + ], + falseEvents: [], + }, + { + id: "italic-1", + sectionName: "italic", + trueEvents: [{ tag: "i", type: "open" }], + falseEvents: [], + }, + ], + description: + "Two adjacent `bold` forks merge \u2192 `fork(bold, T=[, ], F=[])`", + }, + { + phase: "balance", + phaseLabel: "Balance", + highlightLine: null, + forks: [ + { + id: "bold-merged", + sectionName: "bold", + trueEvents: [ + { tag: "b", type: "open" }, + { tag: "b", type: "close" }, + ], + falseEvents: [], + trueBalanced: true, + falseBalanced: true, + dimmed: true, + }, + { + id: "italic-1", + sectionName: "italic", + trueEvents: [{ tag: "i", type: "open" }], + falseEvents: [], + trueBalanced: false, + falseBalanced: true, + dimmed: false, + }, + ], + collectedSections: ["italic"], + pathCount: 2, + description: + "`bold`: both branches balanced \u2192 skip. `italic`: T unbalanced \u2192 collect. 2\u00b9 = 2 paths to check.", + }, + { + phase: "enumerate", + phaseLabel: "Enumerate", + highlightLine: null, + forks: [], + enumerations: [ + { + assignment: { sectionName: "italic", value: false }, + flattenedEvents: [ + { tag: "b", type: "open" }, + { tag: "b", type: "close" }, + ], + result: { balanced: true, message: "Balanced" }, + }, + { + assignment: { sectionName: "italic", value: true }, + flattenedEvents: [ + { tag: "b", type: "open" }, + { tag: "b", type: "close" }, + { tag: "i", type: "open" }, + ], + result: { balanced: false, message: "Unclosed " }, + }, + ], + resultMessage: "Unclosed HTML tag: (when italic is truthy)", + description: + "Enumerate all paths: `italic=false` is balanced, `italic=true` has unclosed ``.", + }, +]; + +function TagPill({ event }: { event: TagEvent }) { + const label = event.type === "open" ? `<${event.tag}>` : ``; + return ( + + {label} + + ); +} + +function BalanceIcon({ balanced }: { balanced: boolean }) { + return ( + + {balanced ? "\u2713" : "\u2717"} + + ); +} + +function TreePanel({ step }: { step: MatchingStep }) { + const highlightSet = new Set(step.treeHighlight ?? []); + const emphasisSet = new Set(step.treeEmphasis ?? []); + + return ( +
+ {TREE_LINES.map((line, i) => { + const isHighlighted = highlightSet.has(i); + const isEmphasis = emphasisSet.has(i); + const isErroneous = line.includes("erroneous"); + + return ( +
+ {line} +
+ ); + })} +
+ ); +} + +function ForkCard({ + fork, + showBalance, +}: { + fork: ForkData; + showBalance: boolean; +}) { + return ( + +
+ {fork.sectionName} + {showBalance && fork.dimmed && ( + SKIP + )} + {showBalance && !fork.dimmed && ( + COLLECT + )} +
+
+ T: + {fork.trueEvents.length > 0 ? ( + fork.trueEvents.map((e, i) => ) + ) : ( + (empty) + )} + {showBalance && fork.trueBalanced !== undefined && ( + + )} +
+
+ F: + {fork.falseEvents.length > 0 ? ( + fork.falseEvents.map((e, i) => ) + ) : ( + (empty) + )} + {showBalance && fork.falseBalanced !== undefined && ( + + )} +
+
+ ); +} + +function ForksArea({ step }: { step: MatchingStep }) { + const showBalance = step.phase === "balance"; + return ( +
+ {step.forks.map((fork) => ( + + ))} + {step.collectedSections && ( + + Collected: [{step.collectedSections.join(", ")}] —{" "} + {step.pathCount} paths + + )} +
+ ); +} + +function EnumerateArea({ step }: { step: MatchingStep }) { + return ( + + {step.enumerations?.map((path, i) => ( +
+
+ {path.assignment.sectionName} ={" "} + {String(path.assignment.value)} +
+
+ {path.flattenedEvents.map((e, j) => ( + + ))} +
+
+ {path.result.message} +
+
+ ))} + {step.resultMessage && ( +
{step.resultMessage}
+ )} +
+ ); +} + +const PANEL_HEADERS: Record = { + extract: "Forks", + merge: "Merge Adjacent", + balance: "Balance Check", + enumerate: "Enumerate Paths", +}; + +export function MatchingAnimation() { + const [stepIndex, setStepIndex] = useState(0); + const step = STEPS[stepIndex]; + + return ( +
+
+ {/* Source panel */} +
+
+
Source
+
+ {SOURCE_LINES.map((line, i) => ( +
+ {line} +
+ ))} +
+
+
+ + {/* Parse tree panel */} +
+
+
Parse Tree
+ +
+
+ + {/* Working area */} +
+
+
+ {PANEL_HEADERS[step.phase]} +
+
+ {(step.phase === "extract" || + step.phase === "merge" || + step.phase === "balance") && } + {step.phase === "enumerate" && } +
+
+
+
+ + {/* Controls */} +
+
+ + {step.phaseLabel} + + + Step {stepIndex + 1} of {STEPS.length} + +
+
+ {formatDescription(step.description)} +
+
+ + +
+
+
+ ); +} diff --git a/src/components/StackAnimation.module.scss b/src/components/StackAnimation.module.scss new file mode 100644 index 00000000..b011d615 --- /dev/null +++ b/src/components/StackAnimation.module.scss @@ -0,0 +1,147 @@ +@import "~bootstrap/scss/_functions.scss"; +@import "~bootstrap/scss/_variables.scss"; + +.container { + border: 1px solid $gray-300; + border-radius: 8px; + overflow: hidden; + margin: 2rem 0; +} + +.panel { + min-width: 0; + border-right: 1px solid $gray-300; + display: flex; + flex-direction: column; + + &:last-child { + border-right: none; + } +} + +.panelHeader { + background-color: $gray-700; + color: $gray-200; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.source { + padding: 0.75rem 0; + font-family: $font-family-monospace; + font-size: 0.82rem; + line-height: 1.6; + flex: 1; +} + +.sourceLine { + padding: 0.1rem 0.75rem; + color: $gray-700; + white-space: pre; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +.highlightedLine { + background-color: rgba($info, 0.12); + color: $gray-900; +} + +.stackContainer { + display: flex; + flex-direction: column; + align-items: stretch; + min-height: 160px; + padding: 0.75rem; + gap: 0.35rem; + position: relative; + flex: 1; +} + +.stackItem { + padding: 0.4rem 0.75rem; + border-radius: 4px; + font-family: $font-family-monospace; + font-size: 0.85rem; + font-weight: 600; + text-align: center; +} + +.htmlTag { + background-color: rgba($info, 0.15); + color: darken($info, 15%); + border: 1px solid rgba($info, 0.3); +} + +.mustacheTag { + background-color: rgba($warning, 0.15); + color: darken($warning, 25%); + border: 1px solid rgba($warning, 0.3); +} + +.fence { + border-top: 2px dashed rgba($danger, 0.5); + position: relative; + margin: 0.15rem 0; +} + +.fenceLabel { + position: absolute; + top: -0.6rem; + right: 0.5rem; + font-size: 0.65rem; + color: $danger; + background-color: white; + padding: 0 0.25rem; + font-weight: 600; + letter-spacing: 0.03em; +} + +.output { + padding: 0.75rem 0; + font-family: $font-family-monospace; + font-size: 0.78rem; + line-height: 1.5; + height: 340px; + overflow-y: auto; +} + +.outputLine { + padding: 0.05rem 0.75rem; + color: $gray-500; + white-space: pre; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +.newOutputLine { + background-color: rgba($success, 0.1); + color: $gray-900; +} + +.erroneousLine { + background-color: rgba($danger, 0.12); + color: $gray-900; +} + +.erroneousOutputLine { + background-color: rgba($danger, 0.1); + color: $gray-900; +} + +.controls { + padding: 0.75rem; + border-top: 1px solid $gray-300; +} + +.description { + font-size: 0.9rem; + color: $gray-700; + min-height: 2.5em; + margin-bottom: 0.75rem; +} diff --git a/src/components/StackAnimation.tsx b/src/components/StackAnimation.tsx new file mode 100644 index 00000000..0abec7b5 --- /dev/null +++ b/src/components/StackAnimation.tsx @@ -0,0 +1,450 @@ +import React, { useState, useRef, useEffect } from "react"; +import classNames from "classnames"; +import { motion, AnimatePresence } from "framer-motion"; + +import { formatDescription } from "./animationUtils"; +import styles from "./StackAnimation.module.scss"; + +const SOURCE_LINES = [ + "{{#bold}}", + " ", + " {{#italic}}", + " ", + " {{/italic}}", + "{{/bold}}", + "{{#bold}}", + " ", + "{{/bold}}", +]; + +interface StackItem { + label: string; + id: string; +} + +interface Step { + htmlStack: StackItem[]; + mustacheStack: StackItem[]; + highlightLine: number; + snapshotFence: number | null; + description: string; + action: "push" | "pop" | "implicit-pop" | "check" | "erroneous"; + outputLines: string[]; + newLineCount: number; +} + +// Build output lines incrementally — each entry is the new lines added at that step. +const OUTPUT_ADDITIONS: string[][] = [ + // Step 0: {{#bold}} opens + [ + "(mustache_section", + " (mustache_section_begin", + " (mustache_tag_name))", + ], + // Step 1: opens + [" (html_element", " (html_start_tag", " (html_tag_name))"], + // Step 2: {{#italic}} opens (nested inside ) + [ + " (mustache_section", + " (mustache_section_begin", + " (mustache_tag_name))", + ], + // Step 3: opens (inside italic, child of ) + [ + " (html_element", + " (html_start_tag", + " (html_tag_name))", + ], + // Step 4: check for {{/italic}} + [], + // Step 5: forced_end_tag + [" (html_forced_end_tag))"], + // Step 6: {{/italic}} consumed + [" (mustache_section_end", " (mustache_tag_name)))"], + // Step 7: check for {{/bold}} + [], + // Step 8: forced_end_tag + [" (html_forced_end_tag))"], + // Step 9: {{/bold}} consumed + [" (mustache_section_end", " (mustache_tag_name)))"], + // Step 10: Second {{#bold}} opens + [ + "(mustache_section", + " (mustache_section_begin", + " (mustache_tag_name))", + ], + // Step 11: erroneous + [" (html_erroneous_end_tag", " (html_tag_name))"], + // Step 12: {{/bold}} consumed + [" (mustache_section_end", " (mustache_tag_name)))"], +]; + +const OUTPUT_LINES: string[][] = (() => { + const result: string[][] = []; + let accumulated: string[] = []; + for (const addition of OUTPUT_ADDITIONS) { + accumulated = [...accumulated, ...addition]; + result.push([...accumulated]); + } + return result; +})(); + +const STEPS: Step[] = [ + // {{#bold}} + { + htmlStack: [], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold1" }], + highlightLine: 0, + snapshotFence: 0, + description: + "`{{#bold}}` opens \u2014 pushed onto the Mustache stack, snapshots HTML stack size = 0.", + action: "push", + outputLines: OUTPUT_LINES[0], + newLineCount: OUTPUT_ADDITIONS[0].length, + }, + // + { + htmlStack: [{ label: "B", id: "b1" }], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold1" }], + highlightLine: 1, + snapshotFence: 0, + description: + "`` opens inside the section \u2014 pushed onto the HTML stack.", + action: "push", + outputLines: OUTPUT_LINES[1], + newLineCount: OUTPUT_ADDITIONS[1].length, + }, + // {{#italic}} + { + htmlStack: [{ label: "B", id: "b1" }], + mustacheStack: [ + { label: "bold (html_size=0)", id: "bold1" }, + { label: "italic (html_size=1)", id: "italic1" }, + ], + highlightLine: 2, + snapshotFence: 1, + description: + "`{{#italic}}` opens \u2014 pushed onto the Mustache stack, snapshots HTML stack size = 1.", + action: "push", + outputLines: OUTPUT_LINES[2], + newLineCount: OUTPUT_ADDITIONS[2].length, + }, + // + { + htmlStack: [ + { label: "B", id: "b1" }, + { label: "I", id: "i1" }, + ], + mustacheStack: [ + { label: "bold (html_size=0)", id: "bold1" }, + { label: "italic (html_size=1)", id: "italic1" }, + ], + highlightLine: 3, + snapshotFence: 1, + description: + "`` opens inside the section \u2014 pushed onto the HTML stack.", + action: "push", + outputLines: OUTPUT_LINES[3], + newLineCount: OUTPUT_ADDITIONS[3].length, + }, + // {{/italic}} — check + { + htmlStack: [ + { label: "B", id: "b1" }, + { label: "I", id: "i1" }, + ], + mustacheStack: [ + { label: "bold (html_size=0)", id: "bold1" }, + { label: "italic (html_size=1)", id: "italic1" }, + ], + highlightLine: 4, + snapshotFence: 1, + description: + "Scanner sees `{{/` \u2014 HTML stack size (2) > italic\u2019s snapshot (1). Unclosed tags detected!", + action: "check", + outputLines: OUTPUT_LINES[4], + newLineCount: OUTPUT_ADDITIONS[4].length, + }, + // {{/italic}} — force-close + { + htmlStack: [{ label: "B", id: "b1" }], + mustacheStack: [ + { label: "bold (html_size=0)", id: "bold1" }, + { label: "italic (html_size=1)", id: "italic1" }, + ], + highlightLine: 4, + snapshotFence: 1, + description: + "Emits `forced_end_tag` for `` and pops it. Stack shrinks back to italic\u2019s snapshot fence.", + action: "implicit-pop", + outputLines: OUTPUT_LINES[5], + newLineCount: OUTPUT_ADDITIONS[5].length, + }, + // {{/italic}} — consumed + { + htmlStack: [{ label: "B", id: "b1" }], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold1" }], + highlightLine: 4, + snapshotFence: 0, + description: + "`{{/italic}}` is consumed normally \u2014 popped from the Mustache stack.", + action: "pop", + outputLines: OUTPUT_LINES[6], + newLineCount: OUTPUT_ADDITIONS[6].length, + }, + // {{/bold}} — check + { + htmlStack: [{ label: "B", id: "b1" }], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold1" }], + highlightLine: 5, + snapshotFence: 0, + description: + "Scanner sees `{{/` \u2014 HTML stack size (1) > bold\u2019s snapshot (0). Unclosed tags detected!", + action: "check", + outputLines: OUTPUT_LINES[7], + newLineCount: OUTPUT_ADDITIONS[7].length, + }, + // {{/bold}} — force-close + { + htmlStack: [], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold1" }], + highlightLine: 5, + snapshotFence: 0, + description: + "Emits `forced_end_tag` for `` and pops it. Stack shrinks back to bold\u2019s snapshot fence.", + action: "implicit-pop", + outputLines: OUTPUT_LINES[8], + newLineCount: OUTPUT_ADDITIONS[8].length, + }, + // {{/bold}} — consumed + { + htmlStack: [], + mustacheStack: [], + highlightLine: 5, + snapshotFence: null, + description: + "`{{/bold}}` is consumed normally \u2014 popped from the Mustache stack.", + action: "pop", + outputLines: OUTPUT_LINES[9], + newLineCount: OUTPUT_ADDITIONS[9].length, + }, + // {{#bold}} (second) + { + htmlStack: [], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold2" }], + highlightLine: 6, + snapshotFence: 0, + description: + "`{{#bold}}` opens \u2014 pushed onto the Mustache stack, snapshots HTML stack size = 0.", + action: "push", + outputLines: OUTPUT_LINES[10], + newLineCount: OUTPUT_ADDITIONS[10].length, + }, + // — erroneous + { + htmlStack: [], + mustacheStack: [{ label: "bold (html_size=0)", id: "bold2" }], + highlightLine: 7, + snapshotFence: 0, + description: + "`` has no matching open tag on the HTML stack \u2014 erroneous end tag.", + action: "erroneous", + outputLines: OUTPUT_LINES[11], + newLineCount: OUTPUT_ADDITIONS[11].length, + }, + // {{/bold}} — consumed + { + htmlStack: [], + mustacheStack: [], + highlightLine: 8, + snapshotFence: null, + description: + "`{{/bold}}` consumed \u2014 popped from the Mustache stack. Both stacks empty.", + action: "pop", + outputLines: OUTPUT_LINES[12], + newLineCount: OUTPUT_ADDITIONS[12].length, + }, +]; + +const itemVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 20 }, +}; + +const htmlStackVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: (action: string) => + action === "implicit-pop" + ? { opacity: 0, x: 40, backgroundColor: "rgba(220, 53, 69, 0.2)" } + : { opacity: 0, y: 20 }, +}; + +const springTransition = { + type: "spring" as const, + stiffness: 500, + damping: 30, +}; + +const FenceLine = () => ( + + tag depth + +); + +export function StackAnimation() { + const [stepIndex, setStepIndex] = useState(0); + const step = STEPS[stepIndex]; + const outputRef = useRef(null); + + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [stepIndex]); + + const isErroneous = step.action === "erroneous"; + + const oldLineCount = step.outputLines.length - step.newLineCount; + + return ( +
+
+ {/* Source panel */} +
+
+
Source
+
+ {SOURCE_LINES.map((line, i) => ( +
+ {line} +
+ ))} +
+
+
+ + {/* HTML stack panel */} +
+
+
HTML Stack
+
+ + {step.snapshotFence === 0 && } + {step.htmlStack.map((item, i) => ( + + + {item.label} + + {step.snapshotFence !== null && + step.snapshotFence > 0 && + step.snapshotFence === i + 1 && } + + ))} + +
+
+
+ + {/* Mustache stack panel */} +
+
+
Mustache Stack
+
+ + {step.mustacheStack.map((item) => ( + + {item.label} + + ))} + +
+
+
+ + {/* Parse tree output panel */} +
+
+
Parse Tree
+
+ {step.outputLines.map((line, i) => ( +
= oldLineCount && + (isErroneous + ? styles.erroneousOutputLine + : styles.newOutputLine), + )} + > + {line} +
+ ))} +
+
+
+
+ + {/* Controls */} +
+
+ {formatDescription(step.description)} +
+
+ + + Step {stepIndex + 1} of {STEPS.length} + + +
+
+
+ ); +} diff --git a/src/components/animationUtils.tsx b/src/components/animationUtils.tsx new file mode 100644 index 00000000..8e6045f3 --- /dev/null +++ b/src/components/animationUtils.tsx @@ -0,0 +1,8 @@ +export function formatDescription(text: string) { + return text.split(/(`[^`]+`)/g).map((part, i) => { + if (part.startsWith("`") && part.endsWith("`")) { + return {part.slice(1, -1)}; + } + return part; + }); +} diff --git a/src/pages/about/blog/stylish-mustaches/example.mustache b/src/pages/about/blog/stylish-mustaches/example.mustache new file mode 100644 index 00000000..0066beb2 --- /dev/null +++ b/src/pages/about/blog/stylish-mustaches/example.mustache @@ -0,0 +1,16 @@ +{{#inline}} + +{{/inline}} +{{^inline}} +
+{{/inline}} + +

Hello, world!

+ +{{#inline}} + +{{/inline}} +{{^inline}} +
+{{/inline}} + diff --git a/src/pages/about/blog/stylish-mustaches/index.mdx b/src/pages/about/blog/stylish-mustaches/index.mdx new file mode 100644 index 00000000..17027a48 --- /dev/null +++ b/src/pages/about/blog/stylish-mustaches/index.mdx @@ -0,0 +1,245 @@ +import { + BlogMarkdownLayout, + BlogImage, + BlogCalloutBox, +} from "../../../../components/BlogMarkdownLayout"; +import { StackAnimation } from "../../../../components/StackAnimation"; +import { MatchingAnimation } from "../../../../components/MatchingAnimation"; + +import releaseCover from "./stylish-mustache.png"; +import syntaxHighlighter from "./vscode.png"; +import linterScreenshot from "./linter.png"; + +export const meta = { + title: "Stylish mustaches", + date: "2026-02-25", + author: "Peter Stenger", + tags: ["Technical", "Development"], +}; + +# PrairieLearn finally has stylish mustaches! + +Unfortunately, I still can't grow a handlebar mustache. But all of our `question.html` and `*.mustache` files +authored in PrairieLearn's custom [portmanteau of Markdown and HTML](https://docs.prairielearn.com/question/template/) are now [formatted](https://github.com/PrairieLearn/PrairieLearn/pull/14195) and [linted](https://github.com/PrairieLearn/PrairieLearn/pull/14182)! The linter already caught bugs in 7 elements across our codebase. + + + +This is possible because of a new grammar, formatter, linter, and LSP (Language Server Protocol) server, [`treesitter-htmlmustache`](https://github.com/reteps/treesitter-htmlmustache). + +I did the original research and development for the grammar in July 2025. Then, in January / February 2026, I +added a syntax highlighter, LSP, formatter, and linter based on this grammar, driven by a test suite and Claude Code. + +## Exploring the landscape + +Before building a custom grammar, I explored several existing approaches to see if anything could handle our HTML+Mustache files. + +1. Can I use a pre-existing grammar? + +**Handlebars / Ember.** I originally looked into [Handlebars](https://github.com/handlebars-lang/handlebars-parser), which is a superset of Mustache that was taken over by the [Ember](https://emberjs.com/) team. +This formatter is built into prettier, so it seemed like a good starting point. +They already supported essentially our syntax, but I eventually abandoned the effort after discussions with the Ember team in Discord (Thanks [@NullVoxPopuli](https://github.com/NullVoxPopuli)), +as it seemed that they wanted to wait for the [TypeScript rewrite](https://github.com/handlebars-lang/handlebars-parser/pull/16) before making any changes to their parser (which was still in progress). There were also open issues around +[syntax differences](https://github.com/handlebars-lang/handlebars-parser/issues/5) between Handlebars and Mustache that were never resolved. Of course, this makes sense that they prioritize the language features that they need, +and not the underlying parsing capabilities. + +2. Is there an easy way I can format / syntax highlight with a TextMate grammar? + +**TextMate grammars.** Next, I looked into how editors like VSCode and Emacs do syntax highlighting. VSCode still uses [TextMate grammars](https://github.com/microsoft/vscode/issues/50140), which is an interesting artifact of the original implementation. There's also Atom's [language-mustache](https://github.com/atom/language-mustache) grammar. However, the problem with TextMate-based grammars +is that you can't get a parse tree from them easily (at least, I couldn't find a way). This means that you can never use them for formatting or linting. + +3. How can I define a proper grammar? + +I never took the [compilers class](https://charithm.web.illinois.edu/cs426/fa2024/) at UIUC, so I had limited experience with building my own lexer and parser. However, in CS 421, we did +leverage [ocamllex](https://ocaml.org/manual/Lexing.html) and [ocamlyacc](https://ocaml.org/manual/Parsing.html) to build a parser for a simple language. I felt that building a grammar in a similar way would be the most feasible approach. +This was before the [November 2025](https://simonwillison.net/tags/november-2025-inflection/) inflection point for AI agents being good enough to build a grammar for me. + +**Tree-sitter.** At the suggestion of [@shorden](https://feyor.sh/), I looked into building a [tree-sitter grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers/3-writing-the-grammar.html). +Initial results were promising, with tooling like [Topiary](https://github.com/topiary/topiary) built for turning a grammar into a formatter easily (though I ended up hitting [some limitations](https://github.com/topiary/topiary/issues/1036)). +I originally looked at basing it on the [tree-sitter-htmldjango](https://github.com/interdependence/tree-sitter-htmldjango) grammar, but the parsing (especially the tag matching) wasn't as good as I needed. Instead, I built on top of [tree-sitter-html](https://github.com/tree-sitter/tree-sitter-html). + +## Building the grammar + +The grammar is a custom [tree-sitter](https://tree-sitter.github.io/tree-sitter/) grammar. The core challenge was handling how Mustache and HTML interact. Mustache and HTML are two independent nesting systems that can cross each other's boundaries. +This is a relatively unique problem to templating languages (which in personal experience, have spotty linting, formatting, and syntax highlighting support). + +Since the Mustache templating is processed before the HTML templating, the "primary" nesting system is Mustache. However, for formatting and linting, we need to know about the HTML nesting system (for tag matching). + +This means that this is not a traditional regular grammar, but a [context-sensitive grammar](https://en.wikipedia.org/wiki/Context-sensitive_grammar). + +Tree-sitter grammars are typically defined using a combination of regular expressions and custom rules. However, in cases like these, you can use an [external scanner](https://tree-sitter.github.io/tree-sitter/creating-parsers/4-external-scanners.html) to handle the complex parsing logic. + +Inspired by [tree-sitter-htmldjango](https://github.com/interdependence/tree-sitter-htmldjango) and [tree-sitter-html](https://github.com/tree-sitter/tree-sitter-html), I built a custom scanner that can handle the complex parsing logic. + +### What context do we need to maintain? + +For each mustache section, we needed to know: + +- What was the state of the HTML stack when the mustache section started? +- What is the state of the HTML stack when the mustache section ended? + +The obvious solution is for each section-like mustache tag (e.g. `{{#section}}` and `{{/section}}`), we associate a list of the +HTML tags. However, these custom data structures (which contained variable-length lists of strings) must be manually [serialized](https://github.com/reteps/tree-sitter-htmlmustache/blob/3ba8be7aa5b837306db5a54c062e4b9c808499bb/src/scanner.c#L55-L116) and deserialized out of memory. +After struggling with this for a while, I realized there was a simpler approach. + +### Just count it! + +The key insight I made while developing the grammar was that you only need to maintain a count of the number of HTML tags that have been opened inside the current mustache section. +Separately, the scanner can maintain a stack of HTML tags that have been opened. This allows the scanner to know exactly which HTML tags are associated with the current mustache section, and which are not, and +vastly simplifies the amount of context that needs to be maintained. + +The scanner maintains two parallel stacks: + +```c +typedef struct { + Array(Tag) tags; // HTML tag stack + Array(MustacheTag) mustache_tags; // Mustache section stack +} Scanner; +``` + +Then, on each MustacheTag, we maintain a count of the number of HTML tags that have been opened inside the current mustache section: + +```c +typedef struct { + String tag_name; + unsigned html_tag_stack_size; +} MustacheTag; +``` + +Then, we follow a simple algorithm to maintain the context when closing a mustache section: + +1. Is the HTML stack taller than the count on the current MustacheTag? + +**Yes**: pop an HTML tag and emit an implicit end tag. Tree-sitter will hit this condition repeatedly until the HTML stack is the same height as the count on the current MustacheTag. + +**No**: Emit the mustache end tag, and continue parsing. + +```c +MustacheTag *current_mustache_tag = array_back(&scanner->mustache_tags); +if (scanner->tags.size > current_mustache_tag->html_tag_stack_size) { + // HTML tags were opened inside this section that haven't been closed! + pop_html_tag(scanner); + lexer->result_symbol = MUSTACHE_END_TAG_HTML_IMPLICIT_END_TAG; + return true; +} +``` + +### See it in action + +Click through the steps below to see this algorithm in action: + + + +The snapshot number creates a fence — the Mustache close tag knows exactly which HTML tags belong to it (those above the fence) and implicitly closes them. +In line 1, `` is force-closed when `{{/bold}}` arrives (the scanner sees the HTML stack is taller than the snapshot). +In line 2, `` appears with nothing on the HTML stack -- the parser records it as an erroneous end tag. + +## The linter + + + +Now that the parser can handle these patterns, how does the linter verify correctness? +The key challenge is that Mustache sections are conditionally rendered -- a rendered template might be valid (i.e. valid HTML) when a section is truthy but broken (i.e. invalid HTML) when it's falsy. + +### Mustache control flow is simple + +Luckily, Mustache control flow is simple: + +1. There are only if statements (`{{#if}}`) and if-not statements (i.e. `{{^if-not}}`) +2. The conditionals for each section are purely boolean true/false checks + +This means that we know the state space of the template is `2^m`, where `m` is the number of unique variables referenced in section conditionals. + +### How do we reduce the state space? + +We can reduce `m` by being smart about what conditions are necessary for unmatched tags to be present. +We need unmatched tags to cross mustache section boundaries. Thus, we can ignore entire sections where the inner contents are always matched. This is the vast +majority of the state space. We have super useful nodes for calculating this (as a result of the custom scanner): `html_forced_end_tag` and `html_erroneous_end_tag`. + +The linter uses a **balance-checking algorithm** that: + +1. **Extracts** fork points from the parse tree — each section that contains HTML "unmatched" events creates a fork with truthy (T) and falsy (F) branches +2. **Merges** adjacent forks for the same section name — this avoids redundant analysis +3. **Balance-checks** each fork's branches independently, skipping forks where both branches are balanced +4. **Enumerates** the remaining paths (2^n for n unbalanced sections) and flattens events to find errors + +Running the linter across the PrairieLearn codebase caught bugs in 7 elements — mismatched tags that had gone unnoticed because they only manifested in specific Mustache branch combinations. + +Click through the steps below to see this algorithm in action: + + + +## The formatter + +I built a formatter inspired by [Prettier's architecture](https://prettier.io/docs/technical-details). +Using a similar intermediate representation to Prettier's [command set](https://github.com/prettier/prettier/blob/main/commands.md) kept the formatter architecture +clean for agents to iterate on. + +For example, given a mustache section with block content: + +{/* prettier-ignore */} +```html +{{#show_hint}}
Check your units!
{{/show_hint}} +``` + +The formatter classifies `{{#show_hint}}` as a block-level mustache section (because it contains a block-level `
`), then produces this IR: + +```text +Group[ + "{{#show_hint}}" + Indent[ + Hardline + "
" + "Check your units!" + "
" + ] + Hardline + "{{/show_hint}}" +] +``` + +The `Group` tries to print everything flat first. Since the content contains a block-level child, a `Hardline` forces the group to break, and `Indent` adds one level of indentation to the contents. The final output is: + +{/* prettier-ignore */} +```html +{{#show_hint}} +
Check your units!
+{{/show_hint}} +``` + +## Bonus Features + +### Embedded languages + +PrairieLearn's templates don't just contain HTML and Mustache — they also embed CSS/JavaScript, and code snippets inside custom elements like ``. The LSP and formatter need to handle all of these. + +In the LSP, I recognize these regions and delegate syntax highlighting to the appropriate VS Code textmate grammar. I delegate formatting to Prettier. + +### Custom lint rules + +I also added support for custom lint rules using CSS-selector-like syntax, which is useful for enforcing patterns specific to PrairieLearn. For example, we can check for hidden inputs inside `{{#items}}` sections: + +```json +{ + "id": "no-hidden-inputs-in-list", + "selector": "#items > input[type=hidden]", + "message": "Hidden inputs inside {{#items}} sections are usually a mistake" +} +``` + +## Conclusion + +This was a fun project to build, and I learned a lot about tree-sitter in the process. Turning this grammar into a full LSP server (the grunt work) +would not have been possible without the help of AI agents. It's so exciting to see what is now possible with a clever idea and a few prompts. As I +mention in my [last blog post](https://www.prairielearn.com/about/blog/linting-pit-of-success), I believe that providing tooling to agents is essential for them to be successful -- creating the "missing tooling" for our +question format will pay dividends! + + + You can check out the full implementation in the + [treesitter-htmlmustache](https://github.com/reteps/treesitter-htmlmustache) + repository. If you have questions or feedback, open an issue on the + repository! + + +export default ({ children }) => ( + {children} +); diff --git a/src/pages/about/blog/stylish-mustaches/linter.png b/src/pages/about/blog/stylish-mustaches/linter.png new file mode 100644 index 00000000..37e11eb7 Binary files /dev/null and b/src/pages/about/blog/stylish-mustaches/linter.png differ diff --git a/src/pages/about/blog/stylish-mustaches/stylish-mustache.png b/src/pages/about/blog/stylish-mustaches/stylish-mustache.png new file mode 100644 index 00000000..c4067547 Binary files /dev/null and b/src/pages/about/blog/stylish-mustaches/stylish-mustache.png differ diff --git a/src/pages/about/blog/stylish-mustaches/vscode.png b/src/pages/about/blog/stylish-mustaches/vscode.png new file mode 100644 index 00000000..b25a199d Binary files /dev/null and b/src/pages/about/blog/stylish-mustaches/vscode.png differ