From 3b096df6827feda732b356eb2a05da562e3e2757 Mon Sep 17 00:00:00 2001 From: nate Date: Tue, 24 Mar 2026 18:04:45 +0400 Subject: [PATCH] improve: html highlighting --- apps/web/src/views/detail.ejs | 144 +++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 20 deletions(-) diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index 6742287..9740cdd 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -194,26 +194,130 @@ ); } - function highlightHtml(str) { - const esc = escapeHtml(str); - return esc - // Comments: - .replace(/<!--[\s\S]*?-->/g, '$&') - // DOCTYPE - .replace(/<!(DOCTYPE[^&]*?)>/gi, '<!$1>') - // Tags: <$1$2') - // Closing > and /> - .replace(/(\/?)\s*>/g, '$1>') - // Attributes: name="value" or name='value' - .replace(/([\w:-]+)(=)("[^&]*?"|'[^&]*?')/g, - '$1$2$3') - // Boolean/valueless attributes (standalone word between tag name and >) - .replace(/(<\/span>)\s+([\w:-]+)(?=\s|)/g, - '$1 $2') - // Inline CSS: style content between quotes (already green, make more specific) - // Entity references: & < etc - .replace(/&[\w#]+;/g, '$&'); + function highlightHtml(raw) { + let out = ''; + let i = 0; + const s = raw; + const n = s.length; + + while (i < n) { + // Comment + if (s.startsWith('', i + 4); + const chunk = end === -1 ? s.slice(i) : s.slice(i, end + 3); + out += '' + escapeHtml(chunk) + ''; + i += chunk.length; + continue; + } + // CDATA + if (s.startsWith('', i + 9); + const chunk = end === -1 ? s.slice(i) : s.slice(i, end + 3); + out += '' + escapeHtml(chunk) + ''; + i += chunk.length; + continue; + } + // DOCTYPE / processing instruction + if (s.startsWith('', i); + const chunk = end === -1 ? s.slice(i) : s.slice(i, end + 1); + out += '' + escapeHtml(chunk) + ''; + i += chunk.length; + continue; + } + // Script/style: highlight tag but treat content as plain text + const scriptMatch = s.slice(i).match(/^<(script|style)([\s>])/i); + if (scriptMatch) { + const tagName = scriptMatch[1]; + const closeTag = '', i); + if (tagEnd !== -1) { + // Opening tag + out += highlightTag(s.slice(i, tagEnd + 1)); + const contentStart = tagEnd + 1; + if (closeIdx !== -1) { + // Content as plain + const content = s.slice(contentStart, closeIdx); + if (content) out += '' + escapeHtml(content) + ''; + // Closing tag + const closeEnd = s.indexOf('>', closeIdx); + const closeChunk = closeEnd === -1 ? s.slice(closeIdx) : s.slice(closeIdx, closeEnd + 1); + out += highlightTag(closeChunk); + i = closeIdx + closeChunk.length; + } else { + i = tagEnd + 1; + } + continue; + } + } + // Regular tag + if (s[i] === '<' && (s[i+1] === '/' || /[a-zA-Z]/.test(s[i+1] || ''))) { + const end = s.indexOf('>', i); + if (end !== -1) { + out += highlightTag(s.slice(i, end + 1)); + i = end + 1; + continue; + } + } + // Entity reference + if (s[i] === '&') { + const semi = s.indexOf(';', i); + if (semi !== -1 && semi - i < 10) { + const ent = s.slice(i, semi + 1); + out += '' + escapeHtml(ent) + ''; + i = semi + 1; + continue; + } + } + // Plain text + const next = s.indexOf('<', i + 1); + const ampNext = s.indexOf('&', i + 1); + let textEnd = n; + if (next !== -1) textEnd = next; + if (ampNext !== -1 && ampNext < textEnd) textEnd = ampNext; + out += escapeHtml(s.slice(i, textEnd)); + i = textEnd; + } + return out; + } + + function highlightTag(tag) { + // Parse: + const m = tag.match(/^(<\/?)([^\s/>]+)([\s\S]*?)(\/?>\s*)$/); + if (!m) return '' + escapeHtml(tag) + ''; + const [, open, name, attrs, close] = m; + let result = '' + escapeHtml(open) + ''; + result += '' + escapeHtml(name) + ''; + if (attrs.trim()) result += highlightAttrs(attrs); + result += '' + escapeHtml(close) + ''; + return result; + } + + function highlightAttrs(str) { + let out = ''; + let i = 0; + const n = str.length; + while (i < n) { + // Whitespace + if (/\s/.test(str[i])) { out += str[i]; i++; continue; } + // attr=value or attr="value" or attr='value' or boolean attr + const am = str.slice(i).match(/^([\w:.@-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/); + if (am) { + out += '' + escapeHtml(am[1]) + ''; + if (am[0].includes('=')) { + out += '='; + const val = am[2] ?? am[3] ?? am[4] ?? ''; + const q = am[2] != null ? '"' : am[3] != null ? "'" : ''; + out += '' + escapeHtml(q + val + q) + ''; + } + i += am[0].length; + } else { + out += escapeHtml(str[i]); + i++; + } + } + return out; } function highlightBody(body, contentType) {