Fix S-expression syntax highlighting - HTML was leaking into displayed text
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s

Cascading regex replacements corrupted their own output: the string regex
matched CSS class names inside previously-generated span tags. Replaced with
a single-pass character tokenizer that never re-processes its own HTML output.
Also added highlighting to recipe detail page (previously had none).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-02-07 01:02:08 +00:00
parent 589ea77b98
commit ca4e86d07e
2 changed files with 153 additions and 17 deletions

View File

@@ -116,12 +116,86 @@
<h2 class="text-lg font-semibold mb-4">Recipe (S-expression)</h2> <h2 class="text-lg font-semibold mb-4">Recipe (S-expression)</h2>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700"> <div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
{% if recipe.sexp %} {% if recipe.sexp %}
<pre class="text-sm font-mono text-gray-300 overflow-x-auto whitespace-pre-wrap">{{ recipe.sexp }}</pre> <pre class="text-sm font-mono text-gray-300 overflow-x-auto whitespace-pre-wrap sexp-code">{{ recipe.sexp }}</pre>
{% else %} {% else %}
<p class="text-gray-500">No source available</p> <p class="text-gray-500">No source available</p>
{% endif %} {% endif %}
</div> </div>
<script>
// Single-pass S-expression syntax highlighter (avoids regex corruption)
function highlightSexp(text) {
const SPECIAL = new Set(['plan','recipe','def','->','stream','let','lambda','if','cond','define']);
const PRIMS = new Set(['source','effect','sequence','segment','resize','transform','layer','blend','mux','analyze','fused-pipeline']);
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function span(cls, s) { return '<span class="' + cls + '">' + esc(s) + '</span>'; }
let out = '', i = 0, len = text.length;
while (i < len) {
if (text[i] === ';' && i + 1 < len && text[i+1] === ';') {
let end = text.indexOf('\n', i);
if (end === -1) end = len;
out += span('text-gray-500', text.slice(i, end));
i = end;
}
else if (text[i] === '"') {
let j = i + 1;
while (j < len && text[j] !== '"') { if (text[j] === '\\') j++; j++; }
if (j < len) j++;
out += span('text-green-400', text.slice(i, j));
i = j;
}
else if (text[i] === ':' && i + 1 < len && /[a-zA-Z_-]/.test(text[i+1])) {
let j = i + 1;
while (j < len && /[a-zA-Z0-9_-]/.test(text[j])) j++;
out += span('text-purple-400', text.slice(i, j));
i = j;
}
else if (text[i] === '(') {
out += span('text-yellow-500', '(');
i++;
let ws = '';
while (i < len && (text[i] === ' ' || text[i] === '\t')) { ws += text[i]; i++; }
out += esc(ws);
if (i < len && /[a-zA-Z_>-]/.test(text[i])) {
let j = i;
while (j < len && /[a-zA-Z0-9_>-]/.test(text[j])) j++;
let word = text.slice(i, j);
if (SPECIAL.has(word)) out += span('text-pink-400 font-semibold', word);
else if (PRIMS.has(word)) out += span('text-blue-400', word);
else out += esc(word);
i = j;
}
}
else if (text[i] === ')') {
out += span('text-yellow-500', ')');
i++;
}
else if (/[0-9]/.test(text[i]) && (i === 0 || /[\s(]/.test(text[i-1]))) {
let j = i;
while (j < len && /[0-9.]/.test(text[j])) j++;
out += span('text-orange-300', text.slice(i, j));
i = j;
}
else {
let j = i;
while (j < len && !'(;":)'.includes(text[j])) {
if (text[j] === ':' && j + 1 < len && /[a-zA-Z_-]/.test(text[j+1])) break;
if (/[0-9]/.test(text[j]) && (j === 0 || /[\s(]/.test(text[j-1]))) break;
j++;
}
if (j === i) { out += esc(text[i]); i++; }
else { out += esc(text.slice(i, j)); i = j; }
}
}
return out;
}
document.querySelectorAll('.sexp-code').forEach(el => {
el.innerHTML = highlightSexp(el.textContent);
});
</script>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center space-x-4 mt-8"> <div class="flex items-center space-x-4 mt-8">
<button hx-post="/runs/rerun/{{ recipe.recipe_id }}" <button hx-post="/runs/rerun/{{ recipe.recipe_id }}"

View File

@@ -550,23 +550,85 @@
} }
</style> </style>
<script> <script>
// Syntax highlight S-expressions // Single-pass S-expression syntax highlighter (avoids regex corruption)
function highlightSexp(text) {
const SPECIAL = new Set(['plan','recipe','def','->','stream','let','lambda','if','cond','define']);
const PRIMS = new Set(['source','effect','sequence','segment','resize','transform','layer','blend','mux','analyze','fused-pipeline']);
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function span(cls, s) { return '<span class="' + cls + '">' + esc(s) + '</span>'; }
let out = '', i = 0, len = text.length;
while (i < len) {
// Comments
if (text[i] === ';' && i + 1 < len && text[i+1] === ';') {
let end = text.indexOf('\n', i);
if (end === -1) end = len;
out += span('text-gray-500', text.slice(i, end));
i = end;
}
// Strings
else if (text[i] === '"') {
let j = i + 1;
while (j < len && text[j] !== '"') { if (text[j] === '\\') j++; j++; }
if (j < len) j++; // closing quote
out += span('text-green-400', text.slice(i, j));
i = j;
}
// Keywords (:keyword)
else if (text[i] === ':' && i + 1 < len && /[a-zA-Z_-]/.test(text[i+1])) {
let j = i + 1;
while (j < len && /[a-zA-Z0-9_-]/.test(text[j])) j++;
out += span('text-purple-400', text.slice(i, j));
i = j;
}
// Open paren - check for primitive/special after it
else if (text[i] === '(') {
out += span('text-yellow-500', '(');
i++;
// Skip whitespace after paren
let ws = '';
while (i < len && (text[i] === ' ' || text[i] === '\t')) { ws += text[i]; i++; }
out += esc(ws);
// Check if next word is a special form or primitive
if (i < len && /[a-zA-Z_>-]/.test(text[i])) {
let j = i;
while (j < len && /[a-zA-Z0-9_>-]/.test(text[j])) j++;
let word = text.slice(i, j);
if (SPECIAL.has(word)) out += span('text-pink-400 font-semibold', word);
else if (PRIMS.has(word)) out += span('text-blue-400', word);
else out += esc(word);
i = j;
}
}
// Close paren
else if (text[i] === ')') {
out += span('text-yellow-500', ')');
i++;
}
// Numbers
else if (/[0-9]/.test(text[i]) && (i === 0 || /[\s(]/.test(text[i-1]))) {
let j = i;
while (j < len && /[0-9.]/.test(text[j])) j++;
out += span('text-orange-300', text.slice(i, j));
i = j;
}
// Regular text
else {
let j = i;
while (j < len && !'(;":)'.includes(text[j])) {
if (text[j] === ':' && j + 1 < len && /[a-zA-Z_-]/.test(text[j+1])) break;
if (/[0-9]/.test(text[j]) && (j === 0 || /[\s(]/.test(text[j-1]))) break;
j++;
}
if (j === i) { out += esc(text[i]); i++; } // safety: advance at least 1 char
else { out += esc(text.slice(i, j)); i = j; }
}
}
return out;
}
document.querySelectorAll('.sexp-code').forEach(el => { document.querySelectorAll('.sexp-code').forEach(el => {
let html = el.textContent; el.innerHTML = highlightSexp(el.textContent);
// Comments
html = html.replace(/(;;.*)/g, '<span class="text-gray-500">$1</span>');
// Keywords (:keyword)
html = html.replace(/(:[a-zA-Z_-]+)/g, '<span class="text-purple-400">$1</span>');
// Strings
html = html.replace(/("(?:[^"\\]|\\.)*")/g, '<span class="text-green-400">$1</span>');
// Special forms
html = html.replace(/\b(plan|recipe|def|->)\b/g, '<span class="text-pink-400 font-semibold">$1</span>');
// Primitives
html = html.replace(/\((source|effect|sequence|segment|resize|transform|layer|blend|mux|analyze)\b/g,
'(<span class="text-blue-400">$1</span>');
// Parentheses
html = html.replace(/(\(|\))/g, '<span class="text-yellow-500">$1</span>');
el.innerHTML = html;
}); });
</script> </script>
{% else %} {% else %}