Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 127 additions & 18 deletions static/js/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,125 @@ export function processWithThinking(text) {
return _useSvgEmoji() ? svgifyEmoji(html) : html;
}

// ---------------------------------------------------------------------------
// Nested markdown lists.
//
// Ordered (`1.`), unordered (`- ` / `* `) and GitHub task (`- [ ] `) items are
// grouped into properly nested <ul>/<ol> by walking the lines and driving an
// INDENTATION STACK: an item indented past its predecessor opens a child list
// INSIDE the open parent <li> (`<li>...<ul>...</ul></li>`), and a dedent closes
// levels until the indentation matches an open one. Indent is compared
// RELATIVELY to the parent on the stack (never bucketed by a fixed width), so
// the 2-/3-/4-space and tab indentations that models emit inconsistently all
// nest the same way, and mixed ordered/unordered nesting produces valid HTML.
// This replaces the old flatten-and-group passes, which anchored on the
// absolute start of the line and rendered any indented item as flat literal
// text (and emitted an <ol> inside a <p> for the ordered-nested case).
// ---------------------------------------------------------------------------

const _LIST_ORDERED_RE = /^([ \t]*)(\d+)\. (.*)$/;
const _LIST_TASK_RE = /^([ \t]*)(?:- |\* )\[([ xX])\] (.*)$/;
const _LIST_UNORDERED_RE = /^([ \t]*)(?:- |\* )(.*)$/;

// Indentation width in columns: a tab counts as four columns so a tab-indented
// child still reads as "deeper" than a space-indented parent (and vice versa).
function _listIndentWidth(ws) {
return ws.replace(/\t/g, ' ').length;
}

// Parse one line into a list item, or null when it is not one. Precedence
// mirrors the old passes: ordered, then task (before generic unordered so the
// "- " prefix is not consumed first), then unordered. `attr` is the attribute
// string for the emitted <li> (the task-item class); `html` is its inner HTML.
function _parseListItem(line) {
let m;
if ((m = _LIST_ORDERED_RE.exec(line))) {
return { indent: _listIndentWidth(m[1]), type: 'ol', html: m[3], attr: '' };
}
if ((m = _LIST_TASK_RE.exec(line))) {
const done = m[2].toLowerCase() === 'x';
const inner = `<span class="task-check" aria-hidden="true"></span><span class="task-text">${m[3]}</span>`;
return {
indent: _listIndentWidth(m[1]),
type: 'ul',
html: inner,
attr: ` class="task-item${done ? ' task-done' : ''}"`,
};
}
if ((m = _LIST_UNORDERED_RE.exec(line))) {
return { indent: _listIndentWidth(m[1]), type: 'ul', html: m[2], attr: '' };
}
return null;
}

// Build the HTML for one maximal run of consecutive list-item lines by driving
// the indentation stack. Emitted as a single line (no internal newlines) that
// starts with <ul>/<ol>, so the later paragraph pass leaves the block untouched.
function _renderListRun(items) {
let html = '';
const stack = []; // { type, indent }, outermost first
for (const it of items) {
let popped = false;
while (stack.length && it.indent < stack[stack.length - 1].indent) {
html += `</li></${stack.pop().type}>`;
popped = true;
}
const top = stack.length ? stack[stack.length - 1] : null;
if (top && it.indent > top.indent && !popped) {
// Deeper: open a child list INSIDE the still-open parent <li>.
html += `<${it.type}>`;
stack.push({ type: it.type, indent: it.indent });
html += `<li${it.attr}>`;
} else if (top) {
// Sibling at this level: equal indent, or a dedent that overshot between
// two open levels. A marker-type switch closes this list and opens the
// other kind at the same level.
if (top.type === it.type) {
html += `</li><li${it.attr}>`;
} else {
html += `</li></${stack.pop().type}>`;
html += `<${it.type}>`;
stack.push({ type: it.type, indent: it.indent });
html += `<li${it.attr}>`;
}
} else {
// Stack empty: open the root list. The first item sets the baseline, so a
// whole run that happens to be indented still renders as a top-level list.
html += `<${it.type}>`;
stack.push({ type: it.type, indent: it.indent });
html += `<li${it.attr}>`;
}
html += it.html;
}
while (stack.length) html += `</li></${stack.pop().type}>`;
return html;
}

// Group the source into maximal runs of consecutive list-item lines (a blank or
// non-list line breaks a run, matching the old "consecutive sentinels" grouping
// so loose lists keep their current one-block-per-run rendering) and convert
// each run to nested-list HTML.
function _buildLists(src) {
const lines = src.split('\n');
const out = [];
let i = 0;
while (i < lines.length) {
if (!_parseListItem(lines[i])) {
out.push(lines[i]);
i++;
continue;
}
const items = [];
let it;
while (i < lines.length && (it = _parseListItem(lines[i]))) {
items.push(it);
i++;
}
out.push(_renderListRun(items));
}
return out.join('\n');
}

/**
* Convert markdown to HTML
*/
Expand Down Expand Up @@ -721,24 +840,14 @@ export function mdToHtml(src, opts) {
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>');

// Ordered lists (1. 2. 3. etc.)
s = s.replace(/^(\d+)\. (.*)$/gm, '<oli>$2</oli>');
s = s.replace(/(?:^|\n)(<oli>[\s\S]*?)(?=\n(?!<oli>)|$)/g, m => `<ol>${m.trim().replace(/<\/?oli>/g, (t) => t === '<oli>' ? '<li>' : '</li>')}</ol>`);

// GitHub-style task lists (- [ ] / - [x]) → checkbox items. Must run before
// the generic unordered-list rule so the "- " prefix isn't consumed first.
// Emits <uli> (with a class) so the unordered-list wrapper below treats it
// as a list item. Used by plan mode: plan + progress render as a checklist.
s = s.replace(/^(?:- |\* )\[([ xX])\] (.*)$/gm, (_m, mark, text) => {
const done = mark.toLowerCase() === 'x';
return `<uli class="task-item${done ? ' task-done' : ''}"><span class="task-check" aria-hidden="true"></span><span class="task-text">${text}</span></uli>`;
});

// Unordered lists. <uli> may carry attributes (task-item class), so the
// wrapper preserves them when converting <uli ...> → <li ...>.
s = s.replace(/^(?:- |\* )(.*)$/gm, '<uli>$1</uli>');
s = s.replace(/(^|\n)((?:<uli\b[^>]*>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) =>
`${prefix}<ul>${block.trim().replace(/<uli\b([^>]*)>/g, '<li$1>').replace(/<\/uli>/g, '</li>')}</ul>`);
// Ordered (1.), unordered (- / *) and GitHub task (- [ ] / - [x]) lists,
// grouped into properly nested <ul>/<ol> by the indentation-stack builder
// (see _buildLists above). Task items carry the task-item class on their
// <li>; the "- " prefix of a checkbox is matched before the generic
// unordered rule so it is not consumed first. Nested items render as
// <li>...<ul>...</ul></li>, including mixed ordered/unordered nesting and
// nested checkboxes, as valid HTML.
s = _buildLists(s);

// Blockquotes
s = s.replace(/^&gt; (.*)$/gm, '<bq>$1</bq>');
Expand Down
15 changes: 15 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2504,6 +2504,21 @@ body.bg-pattern-sparkles {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
/* A sub-list nested under a task item is a third flex child; force it onto
its own full-width line (below the checkbox + label row) so it reads as a
nested list rather than sitting inline after the label. */
li.task-item > ul,
li.task-item > ol {
flex-basis: 100%;
}
/* The label grows from basis 0 (NOT auto/max-content) so a long label stays
on the checkbox's row instead of wrapping below it under flex-wrap, while a
nested sub-list still takes its own full-width line (rule above). */
li.task-item > .task-text {
flex: 1 1 0;
min-width: 0;
}
li.task-item .task-check {
flex: 0 0 auto;
Expand Down
1 change: 1 addition & 0 deletions tests/streaming/corpus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const CORPUS = [
['ordered list then text', 'Before\n\n1. first\n2. second\n3. third\n\nAfter'],
['loose list then paragraph', '- a\n\n- b\n\n- c\n\nClosing paragraph.'],
['nested list', '- top\n - nested one\n - nested two\n- back to top\n\nend'],
['mixed nested list', '1. first\n - sub a\n - sub b\n2. second\n\nend'],
['blockquote', '> quoted line one\n> quoted line two\n\nplain after'],
['thematic break', 'above the line\n\n---\n\nbelow the line'],
['python code fence', 'Run this:\n\n```python\nprint("hi")\nfor i in range(3):\n print(i)\n```\n\nThat prints numbers.'],
Expand Down
169 changes: 169 additions & 0 deletions tests/test_markdown_rendering_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,172 @@ def test_dotted_python_import_paths_are_not_autolinked(node_available):
assert 'href="https://imblearn.com' not in html
assert 'href="https://sklearn.me' not in html
assert 'href="https://example.com/docs"' in html


# --- Nested markdown lists (indentation-stack builder) ----------------------
# The old flatten-and-group passes anchored each list regex on the ABSOLUTE
# start of the line, so an indented item (e.g. " - Sub") never matched and fell
# through to the paragraph pass as literal "- Sub" text, while the parent list
# fragmented. The ordered-nested case additionally emitted an <ol> inside a <p>
# (invalid HTML). These pin the fix: items nest as <li>...<ul>...</ul></li>
# (valid HTML), for unordered, ordered, mixed and task lists at arbitrary depth.


def test_nested_unordered_list_nests_sublist_inside_parent_li(node_available):
html = _run_markdown_case(
"- Item one\n"
"- Item two\n"
" - Nested sub-item"
)

# The sublist lives INSIDE the parent <li>, not loose after it.
assert (
"<ul><li>Item one</li><li>Item two"
"<ul><li>Nested sub-item</li></ul></li></ul>"
) in html
# Exactly one level of nesting (two <ul>, two </ul>).
assert html.count("<ul>") == 2
assert html.count("</ul>") == 2
# The sub-item is NOT rendered as literal text or a stray paragraph.
assert "- Nested sub-item" not in html
assert "<p>- Nested sub-item" not in html
assert "<p><ul>" not in html
# No leftover sentinels.
assert "<uli" not in html
assert "<oli" not in html


def test_nested_ordered_list_is_valid_html_not_ol_in_p(node_available):
# THE invalid-HTML bug: an indented " 1. Sub" used to drop out of the list
# passes and get wrapped as <p><ol>...</ol></p>. It must nest inside the
# parent <li> as valid HTML instead.
html = _run_markdown_case(
"1. First\n"
"2. Second\n"
" 1. Sub"
)

assert (
"<ol><li>First</li><li>Second"
"<ol><li>Sub</li></ol></li></ol>"
) in html
assert html.count("<ol>") == 2
assert html.count("</ol>") == 2
# No <ol> is ever wrapped in / adjacent-inside a paragraph.
assert "<p><ol>" not in html
assert "<ol></p>" not in html
assert "<p>" not in html # no surrounding prose in this sample
assert "<ul>" not in html


def test_mixed_unordered_under_ordered_nests_ul_inside_ol_li(node_available):
html = _run_markdown_case(
"1. Parent\n"
" - bullet child\n"
"2. Second"
)

assert (
"<ol><li>Parent<ul><li>bullet child</li></ul></li>"
"<li>Second</li></ol>"
) in html
assert html.count("<ol>") == 1
assert html.count("<ul>") == 1
assert "<p>" not in html


def test_nested_ordered_under_unordered_nests_ol_inside_ul_li(node_available):
html = _run_markdown_case(
"- Parent bullet\n"
" 1. numbered child\n"
"- Sibling bullet"
)

assert (
"<ul><li>Parent bullet<ol><li>numbered child</li></ol></li>"
"<li>Sibling bullet</li></ul>"
) in html
assert html.count("<ul>") == 1
assert html.count("<ol>") == 1


def test_nested_task_checkbox_preserves_class_and_structure(node_available):
html = _run_markdown_case(
"- [ ] parent task\n"
" - [x] child done"
)

# Parent keeps its task-item <li>, child keeps task-done, and the child list
# is nested inside the parent <li>.
assert (
'<ul><li class="task-item">'
'<span class="task-check" aria-hidden="true"></span>'
'<span class="task-text">parent task</span>'
'<ul><li class="task-item task-done">'
'<span class="task-check" aria-hidden="true"></span>'
'<span class="task-text">child done</span>'
"</li></ul></li></ul>"
) in html
assert html.count("<ul>") == 2
assert "task-text\">child done</span>" in html


def test_three_level_deep_nesting(node_available):
html = _run_markdown_case(
"- A\n"
" - B\n"
" - C"
)

assert (
"<ul><li>A<ul><li>B<ul><li>C</li></ul></li></ul></li></ul>"
) in html
assert html.count("<ul>") == 3
assert html.count("</ul>") == 3


def test_inconsistent_indent_widths_still_nest_consistently(node_available):
# Models emit inconsistent indent (here 3 then 6 spaces). Depth must follow
# the RELATIVE increase, not a fixed bucket width.
html = _run_markdown_case(
"- top\n"
" - mid\n"
" - deep"
)

assert (
"<ul><li>top<ul><li>mid<ul><li>deep</li></ul></li></ul></li></ul>"
) in html
assert html.count("<ul>") == 3


def test_nested_list_after_prose_keeps_paragraph_and_indent(node_available):
# A lead-in paragraph, then a bullet list whose last item carries a nested
# sub-item, then a trailing paragraph.
html = _run_markdown_case(
"Bullet list:\n\n"
"- Item one\n"
"- Item two\n"
" - Nested sub-item\n\n"
"After."
)

assert "<p>Bullet list:</p>" in html
assert "<p>After.</p>" in html
assert (
"<li>Item two<ul><li>Nested sub-item</li></ul></li></ul>"
) in html
# The sub-item never leaks out as a literal-text paragraph (the old bug).
assert "<p>- Nested sub-item" not in html
assert "- Nested sub-item" not in html


def test_flat_unordered_list_still_renders_as_one_ul(node_available):
# Guard the flat path the builder must not regress.
html = _run_markdown_case("- one\n- two\n- three")

assert "<ul><li>one</li><li>two</li><li>three</li></ul>" in html
assert html.count("<ul>") == 1
assert html.count("<li>") == 3
assert "<oli>" not in html
assert "<uli>" not in html