diff --git a/static/ui.js b/static/ui.js
index abe4634d..d455065f 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -7801,48 +7801,97 @@ 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={ordered,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?'
':'
');
+ // Iterative depth-first emit (#6700 re-gate). The recursive
+ // renderList/renderItem pair recursed once per nesting level, so a
+ // pathologically deep list (an agent dumping deeply nested structured
+ // data) threw RangeError at ~2,000 levels; renderMd() runs after the
+ // transcript container is cleared, so the uncaught throw blanked the
+ // whole session. Emit order is identical — list open, items, nested
+ // sublists, item close, list close — but sublists are pushed onto an
+ // explicit work stack instead of the call stack.
+ const html=[];
+ const work=[]; // {open:list} | {item} | {close:list} | {closeItem:true}
+ for(let i=root.items.length-1;i>=0;i--) work.push({open:root.items[i]});
+ while(work.length){
+ const f=work.pop();
+ if(f.item){
+ const it=f.item;
+ 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}"`:'';
+ html.push(`- `,inner);
+ work.push({closeItem:true}); //
after any nested sublists
+ for(let i=it.sublists.length-1;i>=0;i--) work.push({open:it.sublists[i]});
+ } else if(f.closeItem){
+ html.push('');
+ } else if(f.open){
+ const ln=f.open;
+ html.push('<',ln.ordered?'ol':'ul','>');
+ work.push({close:ln}); //
/
after all items
+ for(let i=ln.items.length-1;i>=0;i--) work.push({item:ln.items[i]});
+ } else {
+ html.push('',f.close.ordered?'ol':'ul','>');
+ }
+ }
+ return html.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 +8090,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..576da35a 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,98 @@ 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
+
+ def test_ordered_item_with_task_marker_keeps_literal_prefix(self, driver_path):
+ """Task-list rendering applies only to unordered items: an ordered
+ item whose text starts with [x]/[ ] must keep its literal prefix
+ (e.g. '1. [x] shipped') instead of being silently converted to a
+ ✅/☐ task icon (review fix for #6700: openItem() dropped `ordered`
+ from the item constructor, so every item looked unordered)."""
+ out = _render(driver_path, "1. [x] shipped\n2. [ ] pending")
+ assert 'class="task-done"' not in out, out
+ assert 'class="task-todo"' not in out, out
+ assert "✅" not in out, out
+ assert "☐" not in out, out
+ assert '- [x] shipped
' in out, out
+ assert '- [ ] pending
' in out, out
+
+ def test_deeply_nested_list_does_not_overflow_the_stack(self, driver_path):
+ """The single-pass tree serializer must not recurse per nesting level.
+
+ The first serializer for the #6700 tree was mutually recursive
+ (renderList -> renderItem -> renderList ...), so a pathologically
+ deep list threw ``RangeError: Maximum call stack size exceeded`` at
+ ~2,000 nested items. renderMd() runs after the transcript container
+ is cleared and the throw is uncaught, so one hostile/degenerate
+ message blanked the whole session. The emit step is iterative, so a
+ deep chain must render balanced HTML without throwing.
+ """
+ depth = 2000
+ src = "".join(" " * (2 * k) + "- item %d\n" % k for k in range(depth))
+ out = _render(driver_path, src)
+ assert out.count("") == depth, out[-400:]
+ assert out.count("
") == depth, out[-400:]
+ assert out.count("- ") == depth, out[-400:]
+ assert out.count("
") == depth, out[-400:]
+ # First and last items survive at the extremes of the chain.
+ assert "item 0" in out and "item %d" % (depth - 1) in out, out[-400:]
+
+
# ─────────────────────────────────────────────────────────────────────────────
# 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..bd94862a 100644
--- a/tests/test_sprint16.py
+++ b/tests/test_sprint16.py
@@ -100,22 +100,58 @@ def render_md(raw):
def handle_ul(block):
lines = block.strip().split("\n")
out = ""
+ prev = 0
+ first = True
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:
+ # Deeper: the previous - stays OPEN so the nested
+ # renders INSIDE it (matches renderMd(): parent- child
).
+ out += "" * (depth - prev)
+ elif depth < prev:
+ # Shallower: close the deepest - , each nested
and the
+ # - it hung from, then the current item's parent
- .
+ out += "
" * (prev - depth) + " "
+ elif not first:
+ # Same depth: close the previous sibling - only.
+ out += "
"
+ prev = depth
+ first = False
+ 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
+ first = True
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:
+ # Deeper: the previous - stays OPEN so the nested
+ # renders INSIDE it (matches renderMd(): parent- child
).
+ out += "" * (depth - prev)
+ elif depth < prev:
+ # Shallower: close the deepest - , each nested
and the
+ # - it hung from, then the current item's parent
- .
+ out += "
" * (prev - depth) + " "
+ elif not first:
+ # Same depth: close the previous sibling - only.
+ out += "
"
+ prev = depth
+ first = False
+ 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 +595,25 @@ 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
+
+def test_mirror_nests_same_marker_child_list_inside_parent_li(cleanup_test_sessions):
+ """greptile P2 (#6700): the mirror's handle_ul/handle_ol must nest a
+ same-marker child list INSIDE the parent - , exactly like renderMd()
+ in ui.js — not close the parent
- before opening the child
/.
+ The old shape (- parent
…) let structural assertions pass
+ without validating the production renderer's parent-child hierarchy."""
+ assert render_md("- parent\n - child") == (
+ ""
+ )
+ assert render_md("1. parent\n 1. child") == (
+ "- parent
- child
"
+ )
# --- Blockquote variants ---