fix(renderer): preserve nested mixed ul/ol list hierarchy (#6700)

This commit is contained in:
webtecnica committed 2026-09-08 01:16:00 -03:00
1 parent 641878bd88
commit 13dd97d60d
4 files changed
+178 -72

No files matched your search

+66 -45
View File
@@ -7801,48 +7801,72 @@ function renderMd(raw){
s=s.replace(/^---+$/gm,'<hr>');
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
// fence_stash. The per-line passes below never see > prefixes.)
function _renderListBlock(lines, ordered){
const marker=ordered?'\\d+\\. ':'[-*+] ';
let html=ordered?'<ol>':'<ul>';
let item=null;
const flush=()=>{
if(!item) return;
const body=item.parts.join('\n').trim();
const text=body;
let inner;
if(!ordered && /^\[x\] /i.test(text)) inner='<span class="task-done">✅</span> '+inlineMd(text.slice(4));
else if(!ordered && /^\[ \] /.test(text)) inner='<span class="task-todo">☐</span> '+inlineMd(text.slice(4));
else inner=inlineMd(text);
const valueAttr=item.value!==null?` value="${item.value}"`:'';
const styleAttr=item.indent?` style="margin-left:16px"`:'';
html+=`<li${valueAttr}${styleAttr}>${inner}</li>`;
item=null;
function _renderListBlock(lines){
// Single-pass mixed-marker list renderer (#6700). Builds a real nested
// <ul>/<ol> tree with a stack keyed by indentation depth + marker kind
// instead of rendering one marker family and re-parsing the generated
// HTML with the other. Only item text goes through inlineMd(); tags
// emitted here are never parsed as Markdown again.
const root={ordered:false,items:[]}; // container for top-level lists
const listStack=[]; // listStack[k]: list open at depth k
const itemStack=[]; // itemStack[k]: last item at depth k
const openList=(depth,ordered)=>{
const ln={ordered,items:[]};
// Attach to the nearest open ancestor item when indentation jumps by
// more than one level (e.g. 4-space indent straight from the top), so
// the new list nests under the right parent instead of detaching.
let parentItem=null;
for(let k=depth-1;k>=0;k--){
if(itemStack[k]){ parentItem=itemStack[k]; break; }
}
(parentItem?parentItem.sublists:root.items).push(ln);
listStack[depth]=ln;
return ln;
};
const openItem=(depth,ordered,value,content)=>{
for(let k=listStack.length-1;k>depth;k--) listStack.pop();
if(!listStack[depth]||listStack[depth].ordered!==ordered){
if(listStack[depth]) listStack.pop();
listStack[depth]=openList(depth,ordered);
}
const item={value,content:[content],sublists:[]};
listStack[depth].items.push(item);
itemStack[depth]=item;
for(let k=itemStack.length-1;k>depth;k--) itemStack.pop();
return item;
};
for(const raw of lines){
const line=String(raw||'');
const nested=line.match(new RegExp(`^ {2,}(${marker})(.*)$`));
if(nested){
flush();
item={indent:true,value:ordered?parseInt(nested[1],10):null,parts:[nested[2]]};
continue;
const om=line.match(/^(\s*)(\d+)\.\s+(.*)$/);
const um=line.match(/^(\s*)([-*+])\s+(.*)$/);
const m=om||um;
if(m){
const depth=m[1].length<2?0:Math.floor(m[1].length/2);
openItem(depth,!!om,om?parseInt(om[2],10):null,m[3]);
} else if(itemStack.length){
itemStack[itemStack.length-1].content.push(line.replace(/^ {2,}/,'').trim());
}
const top=line.match(new RegExp(`^(?: )?(${marker})(.*)$`));
if(top){
flush();
item={indent:false,value:ordered?parseInt(top[1],10):null,parts:[top[2]]};
continue;
}
if(!item) continue;
item.parts.push(line.replace(/^ {2,}/,'').trim());
}
flush();
return html+(ordered?'</ol>':'</ul>');
const renderList=ln=>'<'+(ln.ordered?'ol':'ul')+'>'+ln.items.map(renderItem).join('')+'</'+(ln.ordered?'ol':'ul')+'>';
const renderItem=it=>{
const text=it.content.join('\n').trim();
let inner;
if(!it.ordered && /^\[x\] /i.test(text)) inner='<span class="task-done">✅</span> '+inlineMd(text.slice(4));
else if(!it.ordered && /^\[ \] /.test(text)) inner='<span class="task-todo">☐</span> '+inlineMd(text.slice(4));
else inner=inlineMd(text);
const valueAttr=it.value!==null?` value="${it.value}"`:'';
return `<li${valueAttr}>${inner}${it.sublists.map(renderList).join('')}</li>`;
};
return root.items.map(renderList).join('');
}
function _renderLists(src, ordered){
function _renderLists(src){
// Single pass over source lines: collect a contiguous list region (any
// marker family, top-level or nested, plus continuation lines) and hand
// it to _renderListBlock, which builds the structural <ul>/<ol> tree.
const lines=src.split('\n');
const out=[];
const topRe=ordered?/^(?: )?\d+\. /:/^(?: )?[-*+] /;
const nestedRe=ordered?/^ {2,}\d+\. /:/^ {2,}[-*+] /;
const topRe=/^(?: )?(?:\d+\.|[-*+]) /;
const nestedRe=/^ {2,}(?:\d+\.|[-*+]) /;
const contRe=/^ {2,}\S/;
let i=0;
while(i<lines.length){
@@ -7869,18 +7893,16 @@ function renderMd(raw){
}
break;
}
out.push(_renderListBlock(block,ordered));
out.push(_renderListBlock(block));
}
return out.join('\n');
}
// Preserve continuation lines, nested indentation, and LaTeX placeholder lines
// inside list items without changing the wider markdown pipeline.
s=_renderLists(s,false);
// Ordered-list parsing intentionally runs on the post-unordered string; the
// unordered pass emits <ul> HTML that cannot satisfy the ordered-item regex.
// Keep continuation lines attached to their item and preserve explicit
// numbering via value= even when blank lines split the markdown.
s=_renderLists(s,true);
// Single-pass list stage (#6700): one parser handles both marker families
// with a stack keyed by indentation + marker kind, so mixed nested lists
// (ul→ol→ul) build valid <ul>/<ol> structure instead of re-parsing
// renderer-generated HTML as Markdown. value="N" is still emitted on every
// ordered <li> so explicit numbering survives blank-line splits (#886).
s=_renderLists(s);
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
// NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells
// is handled by inlineMd() only — prevents double-linking.
@@ -8043,8 +8065,7 @@ function renderMd(raw){
const a=_attrs(rawAttrs);
if(name==='li'){
const value=/^\d+$/.test(a.value||'')?` value="${esc(a.value)}"`:'';
const style=(a.style||'').replace(/\s+/g,'').toLowerCase()==='margin-left:16px'?` style="margin-left:16px"`:'';
return `<li${value}${style}>`;
return `<li${value}>`;
}
if(name==='span'){
return `<span${_cls(a.class,['task-done','task-todo','katex-inline'])}${a['data-katex']==='inline'?' data-katex="inline"':''}>`;
+20 -15
View File
@@ -8,6 +8,11 @@ at 1, producing "1. 1. 1." instead of "1. 2. 3.".
Fix: emit value="N" on every <li> so the correct ordinal is preserved even when
items end up in separate <ol> containers after the paragraph split.
The list stage was rewritten for #6700 into a single-pass, mixed-marker parser
(a stack keyed by indentation + marker kind). These static assertions pin the
parts of that implementation that keep #886 working: the ordered-marker digit
capture and the value="N" emission on <li>.
"""
import os
import re
@@ -21,15 +26,15 @@ def get_ui_js():
class TestOrderedListNumbering:
def _ordered_list_block(self, src: str) -> str:
start = src.find("function _renderListBlock(lines, ordered){")
start = src.find("function _renderListBlock(lines){")
assert start != -1, "_renderListBlock helper not found in ui.js"
end = src.find("function _renderLists(src, ordered){", start)
end = src.find("function _renderLists(src){", start)
assert end != -1, "_renderLists helper not found after _renderListBlock"
return src[start:end]
def _ordered_list_dispatch_block(self, src: str) -> str:
start = src.find("s=_renderLists(s,true);")
assert start != -1, "ordered-list dispatch not found in ui.js"
start = src.find("s=_renderLists(s);")
assert start != -1, "list dispatch not found in ui.js"
return src[max(0, start - 260):start + 80]
def test_li_value_attr_present_in_ordered_list_block(self):
@@ -42,18 +47,18 @@ class TestOrderedListNumbering:
)
def test_li_value_uses_parsed_number(self):
"""The value= must be derived from parseInt of the captured digit, not hardcoded."""
"""The value= must be derived from parseInt of the captured digits, not hardcoded."""
src = get_ui_js()
ol_block = self._ordered_list_block(src)
assert 'parseInt' in ol_block, (
"Ordered-list block should use parseInt() to parse the list number (#886)"
)
def test_numMatch_variable_present(self):
"""The ordered-list branch must still capture digits from the markdown marker."""
def test_marker_digit_capture_present(self):
"""The ordered branch must still capture digits from the markdown marker."""
src = get_ui_js()
ol_block = self._ordered_list_block(src)
assert "const marker=ordered?'\\\\d+\\\\. ':'[-*+] ';" in ol_block, (
assert "\\d+\\." in ol_block or re.search(r"\(\\d\+\)\\\.", ol_block), (
"Ordered-list block should keep a digit marker pattern for numbered items (#886)"
)
@@ -68,18 +73,18 @@ class TestOrderedListNumbering:
)
def test_ordered_list_comment_references_issue(self):
"""A comment near the OL fix should reference the issue (#886) or the symptom."""
"""A comment near the list dispatch should reference #886 or the symptom."""
src = get_ui_js()
context = self._ordered_list_dispatch_block(src)
has_comment = '#886' in context or '1. 1. 1.' in context or 'blank lines' in context.lower()
assert has_comment, (
"Expected a comment near the OL fix explaining the blank-line issue (#886)"
"Expected a comment near the list dispatch explaining the blank-line issue (#886)"
)
def test_list_without_blank_lines_unaffected(self):
"""A compact list should still flow through the ordered-list helper."""
def test_lists_route_through_single_shared_helper(self):
"""All list rendering must flow through the single-pass shared helper."""
src = get_ui_js()
ol_block = self._ordered_list_dispatch_block(src)
assert "s=_renderLists(s,true);" in ol_block, (
"Ordered-list rendering should still route through the shared helper"
dispatch = self._ordered_list_dispatch_block(src)
assert "s=_renderLists(s);" in dispatch, (
"List rendering should route through the single-pass _renderLists helper"
)
+62 -2
View File
@@ -338,10 +338,13 @@ class TestMarkdownListsWithLatex:
assert "<li>next item</li>" in out
def test_nested_indentation_stays_in_list(self, driver_path):
"""Same-marker nesting builds a structural nested <ul> instead of a
styled sibling <li> — the margin-left convention was removed by the
single-pass mixed-marker parser (#6700)."""
out = _render(driver_path, "- parent\n - child")
assert "<ul>" in out
assert "<li>parent</li>" in out
assert '<li style="margin-left:16px">child</li>' in out
assert "<li>parent<ul><li>child</li></ul></li>" in out
assert "margin-left:16px" not in out
def test_display_math_line_stays_inside_list_item(self, driver_path):
src = "- intro\n\n $$x^2$$\n\n continuation"
@@ -361,6 +364,63 @@ class TestMarkdownListsWithLatex:
assert '<li value="3">tail</li>' in out
class TestMixedNestedLists:
"""#6700: mixed ul/ol nesting must build a valid structural hierarchy.
Regression for the old two-pass list renderer, where the ordered pass
re-parsed the <ul> HTML emitted by the unordered pass as Markdown. That
leaked escaped fragments like `&lt;/li&gt;&lt;li style=&quot;margin-left:
16px&quot;&gt;` into the chat and flattened the inverse (ol→ul) shape.
"""
def test_ul_ol_ul_nested_then_top_level_return(self, driver_path):
src = (
"- normal item\n"
" 1. numbered child\n"
" 2. second numbered child\n"
" - unordered child again\n"
"- next normal item"
)
out = _render(driver_path, src)
# No escaped renderer-generated fragments may leak into the output
assert "&lt;/li&gt;" not in out, out
assert "&lt;ul" not in out, out
assert "&lt;ol" not in out, out
assert "margin-left:16px" not in out, out
# Balanced containers
assert out.count("<ul>") == out.count("</ul>"), out
assert out.count("<ol>") == out.count("</ol>"), out
# Exact structural hierarchy: ul > li > (ol, ul) > li
assert (
'<ul><li>normal item'
'<ol><li value="1">numbered child</li>'
'<li value="2">second numbered child</li></ol>'
'<ul><li>unordered child again</li></ul></li>'
'<li>next normal item</li></ul>'
) in out, out
def test_ol_ul_nested_keeps_bullets(self, driver_path):
src = "1. first step\n - detail A\n - detail B\n2. second step"
out = _render(driver_path, src)
assert "&lt;/li&gt;" not in out, out
assert "&lt;ul" not in out, out
assert "&lt;ol" not in out, out
assert out.count("<ol>") == out.count("</ol>"), out
assert out.count("<ul>") == out.count("</ul>"), out
assert (
'<ol><li value="1">first step'
'<ul><li>detail A</li><li>detail B</li></ul></li>'
'<li value="2">second step</li></ol>'
) in out, out
def test_top_level_marker_switch_starts_sibling_list(self, driver_path):
out = _render(driver_path, "- bullet\n1. numbered")
assert (
'<ul><li>bullet</li></ul><ol><li value="1">numbered</li></ol>'
) in out, out
assert "&lt;/li&gt;" not in out, out
# ─────────────────────────────────────────────────────────────────────────────
# Block-level constructs INSIDE blockquotes — the six bugs documented in
# blockquote-rendering-bugs.md. Each test feeds the exact input from the
+30 -10
View File
@@ -100,22 +100,40 @@ def render_md(raw):
def handle_ul(block):
lines = block.strip().split("\n")
out = "<ul>"
prev = 0
for l in lines:
indent = bool(re.match(r"^ {2,}", l))
text = re.sub(r"^ {0,4}[-*+] ", "", l)
style = ' style="margin-left:16px"' if indent else ""
out += f"<li{style}>{inline_md(text)}</li>"
return out + "</ul>"
m = re.match(r"^( *)([-*+]) (.*)$", l)
if not m:
continue
depth = 0 if len(m.group(1)) < 2 else len(m.group(1)) // 2
if depth > prev:
out += "<ul>"
elif depth < prev:
out += "</ul>" * (prev - depth)
prev = depth
out += f"<li>{inline_md(m.group(3))}</li>"
out += "</ul>" * (prev + 1)
return out
s = re.sub(r"((?:^(?: )?[-*+] .+\n?)+)", lambda m: handle_ul(m.group()), s, flags=re.M)
def handle_ol(block):
lines = block.strip().split("\n")
out = "<ol>"
prev = 0
for l in lines:
text = re.sub(r"^ {0,4}\d+\. ", "", l)
out += f"<li>{inline_md(text)}</li>"
return out + "</ol>"
m = re.match(r"^( *)(\d+)\. (.*)$", l)
if not m:
continue
depth = 0 if len(m.group(1)) < 2 else len(m.group(1)) // 2
if depth > prev:
out += "<ol>"
elif depth < prev:
out += "</ol>" * (prev - depth)
prev = depth
out += f"<li>{inline_md(m.group(3))}</li>"
out += "</ol>" * (prev + 1)
return out
s = re.sub(r"((?:^(?: )?\d+\. .+\n?)+)", lambda m: handle_ol(m.group()), s, flags=re.M)
@@ -559,10 +577,12 @@ def test_ordered_list_code_spans(cleanup_test_sessions):
assert "<code>npm start</code>" in out
def test_indented_list_item_bold(cleanup_test_sessions):
"""Bold inside indented (nested) list item."""
"""Bold inside indented (nested) list item — nested items now build a
structural nested <ul> instead of a margin-left sibling (#6700)."""
out = render_md("- top level\n - **nested bold**")
assert "<strong>nested bold</strong>" in out
assert "margin-left:16px" in out
assert "<ul><li><strong>nested bold</strong></li></ul>" in out
assert "margin-left:16px" not in out
# --- Blockquote variants ---