diff --git a/website/readme_parser.py b/website/readme_parser.py
index 71a36742..2f4ac5c8 100644
--- a/website/readme_parser.py
+++ b/website/readme_parser.py
@@ -269,11 +269,67 @@ def _parse_section_entries(content_nodes: list[SyntaxTreeNode]) -> list[ParsedEn
return entries
-# --- Content HTML rendering (stub for Task 4) --------------------------------
+# --- Content HTML rendering --------------------------------------------------
+
+
+def _render_bullet_list_html(
+ bullet_list: SyntaxTreeNode,
+ *,
+ is_sub: bool = False,
+) -> str:
+ """Render a bullet_list node to HTML with entry/entry-sub/subcat classes."""
+ out: list[str] = []
+
+ for list_item in bullet_list.children:
+ if list_item.type != "list_item":
+ continue
+
+ inline = _find_inline(list_item)
+ if inline is None:
+ continue
+
+ first_link = _find_first_link(inline)
+
+ if first_link is None:
+ # Subcategory label
+ label = str(escape(render_inline_text(inline.children)))
+ out.append(f'
{label}
')
+ nested = _find_child(list_item, "bullet_list")
+ if nested:
+ out.append(_render_bullet_list_html(nested, is_sub=False))
+ continue
+
+ # Entry with a link
+ name = str(escape(render_inline_text(first_link.children)))
+ url = str(escape(first_link.attrGet("href") or ""))
+
+ if is_sub:
+ out.append(f'')
+ else:
+ desc = _extract_description_html(inline, first_link)
+ if desc:
+ out.append(
+ f''
+ )
+ else:
+ out.append(f'')
+
+ # Nested items under an entry with a link are sub-entries
+ nested = _find_child(list_item, "bullet_list")
+ if nested:
+ out.append(_render_bullet_list_html(nested, is_sub=True))
+
+ return "\n".join(out)
def _render_section_html(content_nodes: list[SyntaxTreeNode]) -> str:
- return ""
+ """Render a section's content nodes to HTML."""
+ parts: list[str] = []
+ for node in content_nodes:
+ if node.type == "bullet_list":
+ parts.append(_render_bullet_list_html(node))
+ return "\n".join(parts)
# --- Section splitting -------------------------------------------------------
diff --git a/website/tests/test_readme_parser.py b/website/tests/test_readme_parser.py
index f0f53e92..06a29900 100644
--- a/website/tests/test_readme_parser.py
+++ b/website/tests/test_readme_parser.py
@@ -5,7 +5,13 @@ import sys
import textwrap
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
-from readme_parser import _parse_section_entries, parse_readme, render_inline_html, render_inline_text
+from readme_parser import (
+ _parse_section_entries,
+ _render_section_html,
+ parse_readme,
+ render_inline_html,
+ render_inline_text,
+)
from markdown_it import MarkdownIt
from markdown_it.tree import SyntaxTreeNode
@@ -303,3 +309,48 @@ class TestParseSectionEntries:
entries = _parse_section_entries(nodes)
assert "\n")
+ html = _render_section_html(nodes)
+ assert "