diff --git a/static/ui.js b/static/ui.js
index abe4634d..cd25232e 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -7801,48 +7801,72 @@ function renderMd(raw){
s=s.replace(/^---+$/gm,'
');
// (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?'':'';
- 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='✅ '+inlineMd(text.slice(4));
- else if(!ordered && /^\[ \] /.test(text)) inner='☐ '+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+=`- ${inner}
`;
- item=null;
+ function _renderListBlock(lines){
+ // Single-pass mixed-marker list renderer (#6700). Builds a real nested
+ // / 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?'
':'
');
+ 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='✅ '+inlineMd(text.slice(4));
+ else if(!it.ordered && /^\[ \] /.test(text)) inner='☐ '+inlineMd(text.slice(4));
+ else inner=inlineMd(text);
+ const valueAttr=it.value!==null?` value="${it.value}"`:'';
+ return `- ${inner}${it.sublists.map(renderList).join('')}
`;
+ };
+ 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 / 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 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 / structure instead of re-parsing
+ // renderer-generated HTML as Markdown. value="N" is still emitted on every
+ // ordered - 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 `
- `;
+ return `
- `;
}
if(name==='span'){
return ``;
diff --git a/tests/test_886_ordered_list_numbering.py b/tests/test_886_ordered_list_numbering.py
index d81adf7b..c8e11962 100644
--- a/tests/test_886_ordered_list_numbering.py
+++ b/tests/test_886_ordered_list_numbering.py
@@ -8,6 +8,11 @@ at 1, producing "1. 1. 1." instead of "1. 2. 3.".
Fix: emit value="N" on every
- so the correct ordinal is preserved even when
items end up in separate
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 - .
"""
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"
)
diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py
index 3056a8d6..9c3c587a 100644
--- a/tests/test_renderer_js_behaviour.py
+++ b/tests/test_renderer_js_behaviour.py
@@ -338,10 +338,13 @@ class TestMarkdownListsWithLatex:
assert "
- next item
" in out
def test_nested_indentation_stays_in_list(self, driver_path):
+ """Same-marker nesting builds a structural nested instead of a
+ styled sibling - — the margin-left convention was removed by the
+ single-pass mixed-marker parser (#6700)."""
out = _render(driver_path, "- parent\n - child")
assert "
" in out
- assert "- parent
" in out
- assert '- child
' in out
+ assert "- parent
" 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 '- tail
' 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 HTML emitted by the unordered pass as Markdown. That
+ leaked escaped fragments like `</li><li style="margin-left:
+ 16px">` 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 "</li>" not in out, out
+ assert "<ul" not in out, out
+ assert "<ol" not in out, out
+ assert "margin-left:16px" not in out, out
+ # Balanced containers
+ assert out.count(""), out
+ assert out.count("") == out.count("
"), out
+ # Exact structural hierarchy: ul > li > (ol, ul) > li
+ assert (
+ '- normal item'
+ '
- numbered child
'
+ '- second numbered child
'
+ ' '
+ '- next normal item
'
+ ) 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 "</li>" not in out, out
+ assert "<ul" not in out, out
+ assert "<ol" not in out, out
+ assert out.count("") == out.count("
"), out
+ assert out.count(""), out
+ assert (
+ '- first step'
+ '
'
+ '- second step
'
+ ) in out, out
+
+ def test_top_level_marker_switch_starts_sibling_list(self, driver_path):
+ out = _render(driver_path, "- bullet\n1. numbered")
+ assert (
+ '- numbered
'
+ ) in out, out
+ assert "</li>" 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
diff --git a/tests/test_sprint16.py b/tests/test_sprint16.py
index fd9882bc..446796f1 100644
--- a/tests/test_sprint16.py
+++ b/tests/test_sprint16.py
@@ -100,22 +100,40 @@ def render_md(raw):
def handle_ul(block):
lines = block.strip().split("\n")
out = ""
+ 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"- {inline_md(text)}
"
- return out + "
"
+ 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 += ""
+ elif depth < prev:
+ out += "
" * (prev - depth)
+ prev = depth
+ out += f"- {inline_md(m.group(3))}
"
+ out += "
" * (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 = ""
+ prev = 0
for l in lines:
- text = re.sub(r"^ {0,4}\d+\. ", "", l)
- out += f"- {inline_md(text)}
"
- return out + "
"
+ 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 += ""
+ elif depth < prev:
+ out += "
" * (prev - depth)
+ prev = depth
+ out += f"- {inline_md(m.group(3))}
"
+ out += "
" * (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 "npm start" 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 instead of a margin-left sibling (#6700)."""
out = render_md("- top level\n - **nested bold**")
assert "nested bold" in out
- assert "margin-left:16px" in out
+ assert "" in out
+ assert "margin-left:16px" not in out
# --- Blockquote variants ---