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
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:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
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 }}"
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user