mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
v0.50.218: chat bubble overflow, project color picker, blockquote renderer (#1085)
* fix(css): add overflow-wrap:anywhere to chat bubbles — prevents long URL overflow (#1080) * fix(projects): rename now works via dblclick timer guard + right-click color picker (#1078) * fix(renderer): block-level constructs inside blockquotes now render Fenced code blocks, headings, horizontal rules, and ordered lists inside blockquotes now render correctly. Six related bugs documented in blockquote-rendering-bugs.md were collapsed into one architectural fix in renderMd(). Bugs fixed (all 6): 1. Fenced code blocks inside blockquotes -- > prefixes leaked into the <pre> body and the blockquote got fragmented around the rendered code, sometimes leaving raw <pre>/<div class="pre-header"> as visible text. 2. Blank > continuation lines fragmented multi-paragraph blockquotes into separate <blockquote> elements with literal > between them. 3. ## headings inside blockquotes rendered as literal "##" text. 4. Numbered lists inside blockquotes rendered as plain prose. 5. Complex blockquote (mixed headings + code + list + inline code) collapsed into a monospace blob with raw markdown syntax leaking everywhere. 6. Horizontal rules (---) inside blockquotes rendered as literal text. Root cause: The per-line passes for fenced code, headings, hr, ordered lists all ran BEFORE the blockquote handler and could not match lines that started with >, so by the time blockquote stripping ran those constructs had already been mishandled. Fix: A new blockquote pre-pass at the top of renderMd(): - Walks lines fence-aware so > -prefixed lines inside non-blockquote code fences (e.g. shell prompts in bash code blocks) are not miscaptured as a blockquote. - Groups consecutive > -prefixed lines, strips the > prefix, and recursively calls renderMd() on the stripped content. The recursive call handles all block-level constructs (fenced code, headings, hr, ordered/unordered lists, nested blockquotes) using the same pipeline. - Wraps the rendered HTML in <blockquote> and stashes it with a \x00Q token. Restored at the very end of renderMd() so no later pass can mangle the inner HTML. The old _applyBlockquotes regex-replace is removed entirely along with its limited inline branches for nested blockquotes and unordered lists. Behaviour change: Blockquotes now produce CommonMark-compliant <p> wrapping for text content (was: bare text directly inside <blockquote>). The visual output is the same in browsers but the HTML structure is now standard. Tests: - 14 new behavioural tests in tests/test_renderer_js_behaviour.py drive the actual renderMd() via node and lock all 6 bug fixes. - .local-review/test_blockquote_bugs.js -- node harness covering the same scenarios, runnable manually for fast iteration. - 2407/2408 tests pass (1 pre-existing macOS-only failure deselected). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(renderer): entity decode before blockquote pre-pass + CSS margin fix - Move the >/</& entity-decode to run at the very top of renderMd(), before the blockquote pre-pass. Previously decode() ran at line 756 (after the pre-pass at line 697), so LLM output containing >-encoded blockquotes was never matched by the pre-pass. - Add .msg-body blockquote p{margin:0} and .preview-md blockquote p{margin:0} so the new CommonMark-compliant <p> wrapping inside blockquotes doesn't add extra vertical spacing. Prior shape (bare text) had no default p-margins. - Add Node-driven tests: TestBlockquoteEntityEncodedInput covers > prefix and >-encoded fenced code inside blockquotes. - Add struct test: TestBlockquotePrePassOrdering::test_entity_decode_runs_before_blockquote_pre_pass locks decode < _bq_stash ordering in ui.js. Fixes found during Opus independent review of #1083. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: v0.50.218 release notes, test count 2458, roadmap update --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
## v0.50.218 — 2026-04-26
|
||||
|
||||
### Fixed
|
||||
- **Long URL / unbreakable string overflow** — chat bubble boundaries no longer overflow when a message contains very long URLs, file paths, or base64 data. `overflow-wrap: anywhere` added to `.msg-body` and the user-bubble variant so continuous non-whitespace text wraps at the column edge instead of bleeding into adjacent layout areas. (`static/style.css`) Closes #1080 [#1081]
|
||||
- **Project chip rename now works** — double-clicking a project chip now reliably triggers the rename input. Root cause: `onclick` was calling `renderSessionListFromCache()` which destroyed the chip DOM node before `ondblclick` could fire. Fixed with a 220ms `_clickTimer` delay on `onclick` (same pattern used by session items), so a double-click cancels the single-click and invokes rename instead. (`static/sessions.js`) Closes #1078 [#1082]
|
||||
- **Block-level constructs inside blockquotes** — fenced code blocks, headings, horizontal rules, and ordered lists inside blockquotes now render correctly; `>`-entity-encoded blockquotes from LLM output also render correctly (entity decode moved before the blockquote pre-pass). New pre-pass walks lines fence-aware, strips `>` prefix, recursively renders stripped content with the full pipeline, stashes rendered HTML with `\x00Q` token. (`static/ui.js`, `static/style.css`) [#1083]
|
||||
|
||||
### Added
|
||||
- **Project color picker** — right-clicking a project chip now shows a context menu with Rename, a row of color swatches, and Delete. Selecting a swatch updates the project color via `/api/projects/rename`. (`static/sessions.js`) Closes #1078 [#1082]
|
||||
## v0.50.217 — 2026-04-26
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.50.217 (April 26, 2026) — 2442 tests collected
|
||||
> Last updated: v0.50.218 (April 26, 2026) — 2458 tests collected
|
||||
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
|
||||
> Source: <repo>/
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated coverage: 2442 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Automated coverage: 2458 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
>
|
||||
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
|
||||
|
||||
+58
-3
@@ -779,9 +779,13 @@ function renderSessionListFromCache(){
|
||||
const nameSpan=document.createElement('span');
|
||||
nameSpan.textContent=p.name;
|
||||
chip.appendChild(nameSpan);
|
||||
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
|
||||
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
|
||||
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
|
||||
let _pClickTimer=null;
|
||||
chip.onclick=(e)=>{
|
||||
clearTimeout(_pClickTimer);
|
||||
_pClickTimer=setTimeout(()=>{_pClickTimer=null;_activeProject=p.project_id;renderSessionListFromCache();},220);
|
||||
};
|
||||
chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);};
|
||||
chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);};
|
||||
bar.appendChild(chip);
|
||||
}
|
||||
// Create button
|
||||
@@ -1232,6 +1236,57 @@ function _startProjectRename(proj, chip){
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
}
|
||||
|
||||
function _showProjectContextMenu(e, proj, chip){
|
||||
document.querySelectorAll('.project-ctx-menu').forEach(el=>el.remove());
|
||||
const menu=document.createElement('div');
|
||||
menu.className='project-ctx-menu';
|
||||
menu.style.cssText='position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.35);';
|
||||
menu.style.left=e.clientX+'px';
|
||||
menu.style.top=e.clientY+'px';
|
||||
|
||||
// Rename option
|
||||
const renameItem=document.createElement('div');
|
||||
renameItem.textContent='Rename';
|
||||
renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||||
renameItem.onmouseenter=()=>renameItem.style.background='var(--hover)';
|
||||
renameItem.onmouseleave=()=>renameItem.style.background='';
|
||||
renameItem.onclick=()=>{menu.remove();_startProjectRename(proj,chip);};
|
||||
menu.appendChild(renameItem);
|
||||
|
||||
// Color picker row
|
||||
const colorRow=document.createElement('div');
|
||||
colorRow.style.cssText='display:flex;gap:5px;padding:7px 14px;align-items:center;';
|
||||
PROJECT_COLORS.forEach(hex=>{
|
||||
const dot=document.createElement('span');
|
||||
dot.style.cssText=`width:16px;height:16px;border-radius:50%;background:${hex};cursor:pointer;display:inline-block;flex-shrink:0;`;
|
||||
if(hex===(proj.color||'')) dot.style.outline='2px solid var(--text)';
|
||||
dot.onclick=async()=>{
|
||||
menu.remove();
|
||||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})});
|
||||
await renderSessionList();
|
||||
showToast('Color updated');
|
||||
};
|
||||
colorRow.appendChild(dot);
|
||||
});
|
||||
menu.appendChild(colorRow);
|
||||
|
||||
// Divider + Delete
|
||||
const sep=document.createElement('hr');
|
||||
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
|
||||
menu.appendChild(sep);
|
||||
const delItem=document.createElement('div');
|
||||
delItem.textContent='Delete';
|
||||
delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);';
|
||||
delItem.onmouseenter=()=>delItem.style.background='var(--hover)';
|
||||
delItem.onmouseleave=()=>delItem.style.background='';
|
||||
delItem.onclick=()=>{menu.remove();_confirmDeleteProject(proj);};
|
||||
menu.appendChild(delItem);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);};
|
||||
setTimeout(()=>document.addEventListener('click',dismiss),0);
|
||||
}
|
||||
|
||||
async function _confirmDeleteProject(proj){
|
||||
const ok=await showConfirmDialog({
|
||||
message:'Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.',
|
||||
|
||||
+4
-1
@@ -568,7 +568,7 @@
|
||||
.role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
|
||||
.role-icon.user{background:var(--accent-bg);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
|
||||
.role-icon.assistant{background:var(--accent-bg-strong);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
|
||||
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;}
|
||||
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
|
||||
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
.msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;}
|
||||
@@ -583,6 +583,7 @@
|
||||
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
|
||||
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
|
||||
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
|
||||
.msg-body blockquote p{margin:0;}
|
||||
.msg-body a{color:var(--blue);text-decoration:underline;}
|
||||
.msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;}
|
||||
.msg-body table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
|
||||
@@ -761,6 +762,7 @@
|
||||
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
|
||||
.preview-md pre[class*="language-"],.preview-md pre code[class*="language-"]{background:var(--code-bg) !important;}
|
||||
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
|
||||
.preview-md blockquote p{margin:0;}
|
||||
.preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);}
|
||||
.preview-md a{color:var(--blue);text-decoration:underline;}
|
||||
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
|
||||
@@ -1969,6 +1971,7 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
padding-left: 14px;
|
||||
max-width: none;
|
||||
color: var(--user-bubble-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.msg-row[data-role="user"] .msg-body::selection,
|
||||
.msg-row[data-role="user"] .msg-body *::selection {
|
||||
|
||||
+69
-38
@@ -681,6 +681,68 @@ function _sanitizeThinkingDisplayText(text){
|
||||
|
||||
function renderMd(raw){
|
||||
let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n');
|
||||
// ── Entity decode: must run FIRST so > lines become > for the blockquote
|
||||
// pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this
|
||||
// a blockquote sent as "> text" would never be recognised as a blockquote.
|
||||
s=s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
|
||||
// ── Blockquote pre-pass (must run BEFORE every other markdown pass) ────────
|
||||
// Group consecutive >-prefixed lines, strip the > prefix from each line,
|
||||
// recursively render the stripped content with the full pipeline, and
|
||||
// replace the group with a stash token. This is the only way fenced code,
|
||||
// headings, hr, and ordered lists inside a blockquote can render correctly:
|
||||
// the per-line passes downstream don't know about > prefixes, and by the
|
||||
// time the blockquote handler used to run those passes had already mangled
|
||||
// the >-prefixed lines.
|
||||
//
|
||||
// Walks lines (instead of using a single regex) so >-prefixed lines that
|
||||
// sit inside a non-blockquote fenced block (e.g. a shell prompt in a
|
||||
// ```bash``` example) are not miscaptured as a blockquote.
|
||||
const _bq_stash=[];
|
||||
s=(function _applyBlockquotes(input){
|
||||
const lines=input.split('\n');
|
||||
const out=[];
|
||||
let inFence=false; // inside a non-blockquote ```...``` fence
|
||||
let bqStart=-1;
|
||||
const flush=(end)=>{
|
||||
if(bqStart<0) return;
|
||||
// Strip "> " prefix (and bare ">" → empty) from each line
|
||||
const stripped=lines.slice(bqStart,end).map(l=>l.replace(/^> ?/,'')).join('\n');
|
||||
// Recursive call: full pipeline on stripped content. Handles fenced
|
||||
// code, headings, hr, ordered/unordered lists, nested blockquotes
|
||||
// (>>) — anything that renderMd handles at the top level.
|
||||
const rendered=renderMd(stripped);
|
||||
_bq_stash.push('<blockquote>'+rendered+'</blockquote>');
|
||||
// Surround the token with blank lines so the paragraph splitter
|
||||
// isolates it as its own chunk (otherwise the token gets wrapped
|
||||
// in <p>...<br> with adjacent text, producing invalid HTML).
|
||||
out.push('');
|
||||
out.push('\x00Q'+(_bq_stash.length-1)+'\x00');
|
||||
out.push('');
|
||||
bqStart=-1;
|
||||
};
|
||||
for(let i=0;i<lines.length;i++){
|
||||
const line=lines[i];
|
||||
if(inFence){
|
||||
out.push(line);
|
||||
if(/^```/.test(line)) inFence=false;
|
||||
continue;
|
||||
}
|
||||
if(/^```/.test(line)){
|
||||
flush(i);
|
||||
out.push(line);
|
||||
inFence=true;
|
||||
continue;
|
||||
}
|
||||
if(/^>/.test(line)){
|
||||
if(bqStart<0) bqStart=i;
|
||||
} else {
|
||||
flush(i);
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
flush(lines.length);
|
||||
return out.join('\n');
|
||||
})(s);
|
||||
// ── MEDIA: token stash (must run first, before any other processing) ───────
|
||||
// Detect MEDIA:<path-or-url> tokens emitted by the agent (e.g. screenshots,
|
||||
// generated images) and replace them with inline <img> or download links.
|
||||
@@ -781,43 +843,8 @@ function renderMd(raw){
|
||||
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
|
||||
s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${inlineMd(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${inlineMd(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${inlineMd(t)}</h1>`);
|
||||
s=s.replace(/^---+$/gm,'<hr>');
|
||||
// Group consecutive > lines into one <blockquote>.
|
||||
// Handles: blank continuation lines (> alone), nested blockquotes (>>),
|
||||
// lists inside blockquotes (> - item), and inline markdown in quoted text.
|
||||
function _applyBlockquotes(src){
|
||||
return src.replace(/((?:^>[^\n]*(?:\n|$))+)/gm,block=>{
|
||||
const lines=block.split('\n');
|
||||
// Drop trailing bare '>' artifact
|
||||
while(lines.length&&(lines[lines.length-1].trim()==='>'||lines[lines.length-1]===''))
|
||||
{if(lines[lines.length-1].trim()==='>'){lines.pop();break;}lines.pop();}
|
||||
const stripped=lines.map(l=>l.replace(/^>[ \t]?/,''));
|
||||
const innerRaw=stripped.join('\n');
|
||||
let inner;
|
||||
if(/^>/m.test(innerRaw)){
|
||||
// Nested blockquote: recurse so >> → <blockquote><blockquote>
|
||||
inner=_applyBlockquotes(innerRaw);
|
||||
} else if(/(^(?: )?[-*+] .+)/m.test(innerRaw)){
|
||||
// List inside blockquote: run list pass on stripped inner content
|
||||
inner=innerRaw.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,lb=>{
|
||||
const ll=lb.trimEnd().split('\n');let h='<ul>';
|
||||
for(const li of ll){
|
||||
const txt=li.replace(/^ {0,4}[-*+] /,'');
|
||||
let ih;
|
||||
if(/^\[x\] /i.test(txt)) ih='<span class="task-done">✅</span> '+inlineMd(txt.slice(4));
|
||||
else if(/^\[ \] /.test(txt)) ih='<span class="task-todo">☐</span> '+inlineMd(txt.slice(4));
|
||||
else ih=inlineMd(txt);
|
||||
h+=`<li>${ih}</li>`;
|
||||
}
|
||||
return h+'</ul>';
|
||||
});
|
||||
} else {
|
||||
// Plain lines: blank line → <br>, text → inlineMd
|
||||
inner=stripped.map(l=>l.trim()===''?'<br>':inlineMd(l)).join('\n');
|
||||
}
|
||||
return `<blockquote>${inner}</blockquote>`;
|
||||
});
|
||||
}
|
||||
s=_applyBlockquotes(s);
|
||||
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
|
||||
// fence_stash. The per-line passes below never see > prefixes.)
|
||||
// B8: improved list handling supporting up to 2 levels of indentation
|
||||
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
|
||||
const lines=block.trimEnd().split('\n');
|
||||
@@ -911,7 +938,7 @@ function renderMd(raw){
|
||||
return '\x00E'+(_pre_stash.length-1)+'\x00';
|
||||
});
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00E/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
|
||||
// ── Restore MEDIA stash → inline images or download links ─────────────────
|
||||
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
|
||||
@@ -945,6 +972,10 @@ function renderMd(raw){
|
||||
return `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
|
||||
});
|
||||
// ── End MEDIA restore ──────────────────────────────────────────────────────
|
||||
// Restore blockquote stash. Done last so the inner HTML (already produced
|
||||
// by the recursive renderMd in the pre-pass) is dropped into the final
|
||||
// string verbatim — no further passes can mangle it.
|
||||
s=s.replace(/\x00Q(\d+)\x00/g,(_,i)=>_bq_stash[+i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,15 +47,19 @@ class TestCodeBlockNewlinePreservation:
|
||||
"_pre_stash must be restored after the paragraph split/join"
|
||||
|
||||
def test_paragraph_split_bypasses_stash_tokens(self):
|
||||
"""The paragraph map must bypass lines that start with \\x00E."""
|
||||
"""The paragraph map must bypass lines that start with \\x00E (pre stash).
|
||||
Also accepts a character class like \\x00[EQ] when other stash tokens
|
||||
share the same bypass (e.g. \\x00Q for blockquote stash)."""
|
||||
src = get_ui_js()
|
||||
# The map line must check for \x00E in its bypass condition
|
||||
map_line = next(
|
||||
l for l in src.splitlines()
|
||||
if 'parts.map' in l and '<br>' in l
|
||||
)
|
||||
assert r'\x00E' in map_line, \
|
||||
r"paragraph map must bypass \x00E stash tokens"
|
||||
assert r'\x00E' in map_line or r'\x00[E' in map_line, (
|
||||
r"paragraph map must bypass \x00E stash tokens (literally or as "
|
||||
r"part of a character class like \x00[EQ])"
|
||||
)
|
||||
|
||||
def test_pre_regex_covers_pre_header_div(self):
|
||||
"""The stash regex must match <div class=\"pre-header\"> before <pre>."""
|
||||
|
||||
@@ -73,16 +73,34 @@ class TestBlockquoteSourceStructure:
|
||||
" lines and creates one <blockquote> per line"
|
||||
)
|
||||
|
||||
def test_new_group_rule_present(self):
|
||||
"""The new grouping regex must be present."""
|
||||
assert "(?:^>[^\\n]*(?:\\n|$))+" in UI_JS, (
|
||||
"New group-based blockquote rule not found in ui.js"
|
||||
def test_blockquote_pre_pass_present(self):
|
||||
"""The blockquote pre-pass (line walker + recursive render + stash)
|
||||
must be present in ui.js."""
|
||||
assert "_bq_stash" in UI_JS, (
|
||||
"Blockquote stash array (_bq_stash) not found — pre-pass missing"
|
||||
)
|
||||
assert "_applyBlockquotes" in UI_JS, (
|
||||
"_applyBlockquotes line-walker function not found"
|
||||
)
|
||||
|
||||
def test_prefix_strip_present(self):
|
||||
"""The fix must strip the '> ' prefix from each line."""
|
||||
assert "replace(/^>[" in UI_JS or "replace(/^>[ " in UI_JS, (
|
||||
"Expected prefix-strip pattern not found in the blockquote block"
|
||||
assert "replace(/^> ?/" in UI_JS, (
|
||||
"Expected prefix-strip pattern `^> ?` not found in the blockquote block"
|
||||
)
|
||||
|
||||
def test_bq_stash_token_in_paragraph_bypass(self):
|
||||
"""\\x00Q must be in the paragraph-splitter bypass so blockquote
|
||||
stash tokens are not wrapped in <p>."""
|
||||
assert r"\x00[EQ]" in UI_JS, (
|
||||
"Paragraph-splitter bypass must accept \\x00Q (blockquote token) "
|
||||
"alongside \\x00E (pre stash token)"
|
||||
)
|
||||
|
||||
def test_bq_stash_restore_present(self):
|
||||
"""The stash restore must run at the end of renderMd."""
|
||||
assert r"\x00Q(\d+)\x00" in UI_JS, (
|
||||
"Blockquote stash restore regex not found in ui.js"
|
||||
)
|
||||
|
||||
|
||||
@@ -213,3 +231,25 @@ class TestBlockquoteFollowedByParagraph:
|
||||
# Normal paragraph must be outside the blockquote
|
||||
after_bq = out[out.index("</blockquote>"):]
|
||||
assert "Normal paragraph" in after_bq
|
||||
|
||||
|
||||
class TestBlockquotePrePassOrdering:
|
||||
"""Structural checks that lock the ordering of the blockquote pre-pass
|
||||
relative to the entity-decode and MEDIA-stash passes in renderMd()."""
|
||||
|
||||
def test_entity_decode_runs_before_blockquote_pre_pass(self):
|
||||
"""The entity decode must appear BEFORE the blockquote pre-pass in
|
||||
renderMd() so >-prefixed lines are recognised as blockquotes."""
|
||||
# The entity decode is represented by '>' replacement or the
|
||||
# inline decode line, whichever appears first.
|
||||
decode_idx = min(
|
||||
UI_JS.find("replace(/>/g"),
|
||||
UI_JS.find("replace(/</g"),
|
||||
)
|
||||
bq_stash_idx = UI_JS.find("_bq_stash")
|
||||
assert decode_idx != -1, "Entity decode (> or <) not found in renderMd"
|
||||
assert bq_stash_idx != -1, "_bq_stash not found"
|
||||
assert decode_idx < bq_stash_idx, (
|
||||
"Entity decode must appear before the blockquote pre-pass (_bq_stash). "
|
||||
f"decode at {decode_idx}, _bq_stash at {bq_stash_idx}"
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ Add a case here whenever the renderer fix targets a class of input the
|
||||
Python mirror cannot exercise faithfully.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -94,17 +95,18 @@ class TestBlockquotePrefixStrip:
|
||||
|
||||
def test_single_line_blockquote_no_leading_space(self, driver_path):
|
||||
out = _render(driver_path, "> Hello world").strip()
|
||||
assert "<blockquote>Hello world</blockquote>" in out, (
|
||||
f"`> Hello world` must render as <blockquote>Hello world</blockquote> "
|
||||
f"with no leading space. Got: {out!r}. Likely cause: prefix-strip "
|
||||
f"regex consumes only \\t, not space."
|
||||
# New shape: recursive renderMd wraps content in <p> (CommonMark-correct).
|
||||
assert "<blockquote><p>Hello world</p></blockquote>" in out, (
|
||||
f"`> Hello world` must render as <blockquote><p>Hello world</p></blockquote> "
|
||||
f"with no leading space. Got: {out!r}."
|
||||
)
|
||||
|
||||
def test_multiline_blockquote_no_leading_space(self, driver_path):
|
||||
out = _render(driver_path, "> Line one\n> Line two").strip()
|
||||
assert ">Line one\nLine two<" in out, (
|
||||
f"Multi-line blockquote must strip the space after each `>`. "
|
||||
f"Got: {out!r}"
|
||||
# New shape: single paragraph with <br> between soft-wrapped lines.
|
||||
assert "<blockquote><p>Line one<br>Line two</p></blockquote>" in out, (
|
||||
f"Multi-line blockquote must strip the space after each `>` and "
|
||||
f"render as a single paragraph. Got: {out!r}"
|
||||
)
|
||||
# Belt-and-braces: there must be no space-after-newline-in-content
|
||||
assert "\n " not in out.replace("</blockquote>", ""), (
|
||||
@@ -164,7 +166,7 @@ class TestCommonLLMShapes:
|
||||
|
||||
def test_quote_then_heading(self, driver_path):
|
||||
out = _render(driver_path, "> Note this.\n\n## Heading")
|
||||
assert "<blockquote>Note this.</blockquote>" in out
|
||||
assert "<blockquote><p>Note this.</p></blockquote>" in out
|
||||
assert "<h2>Heading</h2>" in out
|
||||
|
||||
def test_crlf_does_not_leak_carriage_return(self, driver_path):
|
||||
@@ -189,3 +191,273 @@ class TestCommonLLMShapes:
|
||||
assert "And a closing remark." in out
|
||||
# No leading-space artifacts in the quoted text
|
||||
assert "\n " not in out.replace("</blockquote>", "")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Block-level constructs INSIDE blockquotes — the six bugs documented in
|
||||
# blockquote-rendering-bugs.md. Each test feeds the exact input from the
|
||||
# bug report and asserts the rendered HTML structure.
|
||||
#
|
||||
# Root cause of all six: every block-level pass (fenced code, headings, hr,
|
||||
# ordered lists) used to run BEFORE the blockquote handler, on > -prefixed
|
||||
# lines those passes don't recognise. The fix moved blockquote handling to a
|
||||
# pre-pass that strips > prefixes and recursively renders the inner content.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBugFencedCodeInBlockquote:
|
||||
"""Bug 1: fenced code blocks inside blockquotes leaked > prefixes inside
|
||||
the rendered <pre>, broke the <blockquote> wrapper, and sometimes left
|
||||
raw <pre>/<div class="pre-header"> as visible text."""
|
||||
|
||||
def test_fenced_code_inside_blockquote_renders_pre(self, driver_path):
|
||||
src = (
|
||||
"> Here is some code:\n"
|
||||
">\n"
|
||||
"> ```python\n"
|
||||
"> x = 1\n"
|
||||
"> y = 2\n"
|
||||
"> ```\n"
|
||||
">\n"
|
||||
"> That was the code."
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert "<pre>" in out and "</pre>" in out, (
|
||||
f"Fenced code inside blockquote must render as <pre>: {out!r}"
|
||||
)
|
||||
# The > prefixes must be stripped from the code content, not preserved
|
||||
# inside the <pre>.
|
||||
assert "> x = 1" not in out, (
|
||||
f"Code content inside <pre> must not contain > prefixes: {out!r}"
|
||||
)
|
||||
# Raw <pre> or pre-header tags must NOT appear as visible text
|
||||
assert "<pre>" not in out
|
||||
assert "<div class="pre-header" not in out
|
||||
# Single <blockquote> wrapping everything (not split by the <pre>)
|
||||
assert out.count("<blockquote>") == 1, (
|
||||
f"Expected ONE <blockquote>, got {out.count('<blockquote>')}: {out!r}"
|
||||
)
|
||||
|
||||
def test_fenced_code_with_lang_class(self, driver_path):
|
||||
src = "> ```python\n> x = 1\n> ```"
|
||||
out = _render(driver_path, src)
|
||||
assert 'class="language-python"' in out
|
||||
assert "x = 1" in out
|
||||
|
||||
|
||||
class TestBugBlankContinuationInBlockquote:
|
||||
"""Bug 2: blank > lines between paragraphs fragmented the blockquote into
|
||||
separate elements with literal > characters between them."""
|
||||
|
||||
def test_three_paragraphs_one_blockquote(self, driver_path):
|
||||
src = (
|
||||
"> First paragraph of the quote.\n"
|
||||
">\n"
|
||||
"> Second paragraph of the quote.\n"
|
||||
">\n"
|
||||
"> Third paragraph of the quote."
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
# All three paragraphs in ONE <blockquote>
|
||||
assert out.count("<blockquote>") == 1, (
|
||||
f"Expected ONE <blockquote>, got {out.count('<blockquote>')}: {out!r}"
|
||||
)
|
||||
assert "First paragraph" in out
|
||||
assert "Second paragraph" in out
|
||||
assert "Third paragraph" in out
|
||||
# No literal > between paragraphs (would indicate fragmented blockquote)
|
||||
text_only = re.sub(r"<[^>]+>", "", out)
|
||||
assert ">" not in text_only, (
|
||||
f"Literal > in rendered text indicates fragmented blockquote: {text_only!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestBugHeadingsInsideBlockquote:
|
||||
"""Bug 3: # headings inside blockquotes rendered as literal '##' text
|
||||
because the heading pass ran before the blockquote pass."""
|
||||
|
||||
def test_h2_inside_blockquote(self, driver_path):
|
||||
src = (
|
||||
"> ## Bug description\n"
|
||||
">\n"
|
||||
"> The widget is broken.\n"
|
||||
">\n"
|
||||
"> ## Steps to reproduce\n"
|
||||
">\n"
|
||||
"> Click the button."
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert "<h2>Bug description</h2>" in out, (
|
||||
f"## inside blockquote must render as <h2>: {out!r}"
|
||||
)
|
||||
assert "<h2>Steps to reproduce</h2>" in out
|
||||
# No literal '##' as visible text
|
||||
text_only = re.sub(r"<[^>]+>", "", out)
|
||||
assert "##" not in text_only, (
|
||||
f"Literal ## in rendered text — heading pass missed it: {text_only!r}"
|
||||
)
|
||||
|
||||
def test_h1_h2_h3_all_render(self, driver_path):
|
||||
src = "> # H1\n> ## H2\n> ### H3"
|
||||
out = _render(driver_path, src)
|
||||
assert "<h1>H1</h1>" in out
|
||||
assert "<h2>H2</h2>" in out
|
||||
assert "<h3>H3</h3>" in out
|
||||
|
||||
|
||||
class TestBugOrderedListInsideBlockquote:
|
||||
"""Bug 4: ordered (numbered) lists inside blockquotes rendered as plain
|
||||
text — the OL pass had no equivalent of the UL branch in the old
|
||||
blockquote handler."""
|
||||
|
||||
def test_ordered_list_renders_as_ol(self, driver_path):
|
||||
src = (
|
||||
"> Steps to reproduce:\n"
|
||||
">\n"
|
||||
"> 1. Open the app\n"
|
||||
"> 2. Click the button\n"
|
||||
"> 3. Observe the crash"
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert "<ol>" in out and "</ol>" in out, (
|
||||
f"Numbered list inside blockquote must render as <ol>: {out!r}"
|
||||
)
|
||||
# All three list items present
|
||||
for item in ["Open the app", "Click the button", "Observe the crash"]:
|
||||
assert f">{item}</li>" in out, (
|
||||
f"Missing <li>{item}</li> in {out!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestBugHorizontalRuleInsideBlockquote:
|
||||
"""Bug 6: --- inside a blockquote rendered as literal text instead of <hr>."""
|
||||
|
||||
def test_hr_renders_inside_blockquote(self, driver_path):
|
||||
src = "> Above the rule\n>\n> ---\n>\n> Below the rule"
|
||||
out = _render(driver_path, src)
|
||||
assert "<hr>" in out, (
|
||||
f"--- inside blockquote must render as <hr>: {out!r}"
|
||||
)
|
||||
assert "Above the rule" in out
|
||||
assert "Below the rule" in out
|
||||
# No literal '---' as text
|
||||
text_only = re.sub(r"<[^>]+>", "", out)
|
||||
assert "---" not in text_only, (
|
||||
f"Literal --- in rendered text: {text_only!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestBugComplexBlockquoteAllFeatures:
|
||||
"""Bug 5 (worst-case): a blockquote with headings, paragraphs, inline code,
|
||||
fenced code, and an ordered list. Old behaviour collapsed the entire thing
|
||||
into a monospace blob with raw markdown syntax leaking everywhere."""
|
||||
|
||||
def test_complex_blockquote_renders_all_constructs(self, driver_path):
|
||||
src = (
|
||||
"> ## Description\n"
|
||||
">\n"
|
||||
"> The widget is broken when X happens.\n"
|
||||
">\n"
|
||||
"> ## Root cause\n"
|
||||
">\n"
|
||||
"> The `MIME_MAP` in `api/config.py` is missing entries.\n"
|
||||
">\n"
|
||||
"> ## Fix\n"
|
||||
">\n"
|
||||
"> Add two entries:\n"
|
||||
">\n"
|
||||
"> ```python\n"
|
||||
'> ".html": "text/html",\n'
|
||||
'> ".htm": "text/html",\n'
|
||||
"> ```\n"
|
||||
">\n"
|
||||
"> ## Workflow rules\n"
|
||||
">\n"
|
||||
"> 1. Never edit the file directly\n"
|
||||
"> 2. Create a worktree\n"
|
||||
"> 3. Run the tests\n"
|
||||
">\n"
|
||||
"> Target branch is `master`."
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
# Multiple <h2> headings
|
||||
assert out.count("<h2>") >= 4, (
|
||||
f"Expected at least 4 <h2> headings, got {out.count('<h2>')}: {out!r}"
|
||||
)
|
||||
# Fenced code block
|
||||
assert "<pre>" in out
|
||||
assert 'class="language-python"' in out
|
||||
# Ordered list
|
||||
assert "<ol>" in out
|
||||
# Inline code
|
||||
assert "<code>MIME_MAP</code>" in out
|
||||
assert "<code>api/config.py</code>" in out
|
||||
assert "<code>master</code>" in out
|
||||
# No literal markdown syntax leaking
|
||||
text_only = re.sub(r"<[^>]+>", "", out)
|
||||
assert "##" not in text_only, f"Literal ## in {text_only!r}"
|
||||
# Single <blockquote> wraps everything
|
||||
assert out.count("<blockquote>") == 1, (
|
||||
f"Expected ONE <blockquote>, got {out.count('<blockquote>')}: {out!r}"
|
||||
)
|
||||
# No raw <pre>/<div class="pre-header"> as escaped text
|
||||
assert "<pre>" not in out
|
||||
assert "<div class="pre-header" not in out
|
||||
|
||||
|
||||
class TestBlockquoteRegressionsDontTouchOutsideContent:
|
||||
"""Make sure the blockquote pre-pass doesn't grab > -prefixed lines that
|
||||
sit inside a non-blockquote fenced code block (e.g. shell prompts in
|
||||
```bash``` examples)."""
|
||||
|
||||
def test_shell_prompt_in_bash_fence_not_treated_as_blockquote(self, driver_path):
|
||||
src = "```bash\n> echo hello\n```"
|
||||
out = _render(driver_path, src)
|
||||
# The > line is part of the bash code, not a blockquote
|
||||
assert "<blockquote>" not in out, (
|
||||
f"> line inside ```bash``` must NOT become a blockquote: {out!r}"
|
||||
)
|
||||
assert "<pre>" in out
|
||||
# Escaped > preserved as code content
|
||||
assert "> echo hello" in out
|
||||
|
||||
def test_two_separate_blockquotes_stay_separate(self, driver_path):
|
||||
src = "> First quote\n\nSome plain text.\n\n> Second quote"
|
||||
out = _render(driver_path, src)
|
||||
assert out.count("<blockquote>") == 2, (
|
||||
f"Two separated blockquotes must stay separate: {out!r}"
|
||||
)
|
||||
assert "Some plain text." in out
|
||||
|
||||
def test_nested_double_blockquote(self, driver_path):
|
||||
src = "> outer line\n> > inner line"
|
||||
out = _render(driver_path, src)
|
||||
# Should produce nested <blockquote><blockquote>
|
||||
assert out.count("<blockquote>") == 2, (
|
||||
f"Expected 2 <blockquote>: {out!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestBlockquoteEntityEncodedInput:
|
||||
"""Blockquotes sent as HTML-entity-encoded text must still render correctly.
|
||||
LLMs sometimes emit > instead of > — the entity-decode pass must run
|
||||
BEFORE the blockquote pre-pass, not after it."""
|
||||
|
||||
def test_amp_gt_prefix_becomes_blockquote(self, driver_path):
|
||||
src = "> Hello quote"
|
||||
out = _render(driver_path, src)
|
||||
assert "<blockquote>" in out, (
|
||||
f">-prefixed line must render as <blockquote>: {out!r}"
|
||||
)
|
||||
text_only = re.sub(r"<[^>]+>", "", out)
|
||||
assert "Hello quote" in text_only
|
||||
# Should not see a literal > or > in the rendered text
|
||||
assert ">" not in out, f"> should have been decoded: {out!r}"
|
||||
|
||||
def test_amp_gt_fenced_code_in_blockquote(self, driver_path):
|
||||
src = "> ```python\n> x = 1\n> ```"
|
||||
out = _render(driver_path, src)
|
||||
assert "<blockquote>" in out, (
|
||||
f"Entity-encoded blockquote with fenced code must render: {out!r}"
|
||||
)
|
||||
assert "<pre>" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"
|
||||
|
||||
Reference in New Issue
Block a user