Files
bbs-md/.planning/phases/02-vault-core-and-rendering/02-02-PLAN.md
T
2026-02-28 22:02:14 +01:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-vault-core-and-rendering 02 execute 2
02-01
src/renderer.rs
true
REND-01
REND-02
REND-03
REND-04
REND-05
REND-06
REND-07
REND-08
REND-09
REND-10
truths artifacts key_links
Headings H1-H6 render with distinct CGA colors and decorators (H1=LightCyan+══, H2=LightYellow+──, H3-H6 color-only)
Bold text uses Modifier::BOLD, italic uses Modifier::ITALIC, inline code uses LightCyan styling
Fenced code blocks render with rounded box-drawing borders (╭─╮│╰─╯) and syntax-highlighted content via highlighter.rs
Ordered and unordered lists render with bullets/numbers and proper indentation per nesting depth
Blockquotes render with a yellow │ left border and gray content
Horizontal rules render as full-width ─── lines in DarkGray
Images render as [IMAGE: alt text] placeholders in DarkGray+DIM
GFM tables render with full box-drawing grid (┌┬┐├┼┤└┴┘) and bold LightCyan header row
path provides exports min_lines
src/renderer.rs Complete markdown-to-styled-lines conversion pipeline
render_markdown
200
from to via pattern
src/renderer.rs pulldown-cmark Parser::new_ext with TextMergeStream consuming all Event variants TextMergeStream
from to via pattern
src/renderer.rs src/highlighter.rs highlight_code() called for fenced code blocks highlight_code
from to via pattern
src/renderer.rs ratatui::text Produces Vec<Line<'static>> with Span styling Vec<Line<'static>>
Build the complete markdown-to-styled-lines renderer that converts a markdown string into `Vec>` using pulldown-cmark event processing, CGA-themed styling, syntax-highlighted code blocks, and box-drawing table grids.

Purpose: This is the core content pipeline — every markdown construct the user sees flows through this module. Output: src/renderer.rs with a public render_markdown(input: &str, width: u16) -> Vec<Line<'static>> function.

<execution_context> @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md @.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md @.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md @src/highlighter.rs Task 1: Build core markdown renderer with inline and block elements src/renderer.rs Create `src/renderer.rs` with the public function `render_markdown(input: &str, width: u16) -> Vec>`.

The width parameter is needed for horizontal rules (full-width repeats) and could be used for code block border sizing.

Internal state struct (private):

struct RenderState {
    lines: Vec<Line<'static>>,
    current_spans: Vec<Span<'static>>,
    style_stack: Vec<Style>,          // push on Start, pop on End
    in_code_block: bool,
    code_lang: String,
    code_buf: String,
    list_counters: Vec<Option<u64>>,  // None=unordered, Some(n)=ordered from n
    in_blockquote: bool,
    blockquote_depth: u32,
    in_image: bool,
    image_alt: String,
    // Table state
    in_table: bool,
    table_alignments: Vec<pulldown_cmark::Alignment>,
    table_rows: Vec<Vec<String>>,
    current_cell: String,
    in_table_head: bool,
    // Width for rules
    width: u16,
}

Parser setup:

  • Use Options::empty() then insert ENABLE_TABLES, ENABLE_STRIKETHROUGH, ENABLE_TASKLISTS
  • Wrap parser in TextMergeStream::new(Parser::new_ext(input, opts))
  • Iterate events, dispatch to handler methods on RenderState

Element handling (implement ALL of these):

Headings (REND-01, REND-10):

  • On Start(Tag::Heading { level, .. }): push heading style onto style_stack based on level:
    • H1: Color::LightCyan + Modifier::BOLD
    • H2: Color::LightYellow + Modifier::BOLD
    • H3: Color::LightGreen + Modifier::BOLD
    • H4: Color::Green
    • H5: Color::Cyan
    • H6: Color::DarkGray
  • On End(TagEnd::Heading(level)): flush current_spans as a Line, then emit decorator:
    • H1: Line of repeated to width, in Color::LightCyan
    • H2: Line of repeated to width, in Color::LightYellow
    • H3-H6: no decorator line
    • Always add an empty Line after for spacing

Paragraphs:

  • On Start(Tag::Paragraph): nothing (spans accumulate)
  • On End(TagEnd::Paragraph): flush current_spans to a Line, push empty Line for spacing
  • Special handling: if inside blockquote, prepend blockquote decorators to each flushed line

Inline formatting (REND-02):

  • Start(Tag::Strong): push current style with Modifier::BOLD added onto style_stack
  • End(TagEnd::Strong): pop style_stack
  • Start(Tag::Emphasis): push current style with Modifier::ITALIC added
  • End(TagEnd::Emphasis): pop style_stack
  • Event::Code(text): push Span with Style::default().fg(Color::LightCyan) — inline code

Text:

  • Event::Text(text): if in_code_block, append to code_buf; if in_image, append to image_alt; if in_table and inside a cell, append to current_cell; otherwise push Span::styled(text.to_string(), current_style()) to current_spans
  • current_style() returns the top of style_stack, or Style::default() if empty

Soft/Hard breaks:

  • Event::SoftBreak: push Span::raw(" ")
  • Event::HardBreak: flush current_spans as a Line

Lists (REND-04):

  • Start(Tag::List(start)): push start onto list_counters
  • End(TagEnd::List(_)): pop list_counters, push empty Line for spacing after top-level list
  • Start(Tag::Item): compute indent = " ".repeat(list_counters.len()). Compute bullet:
    • If list_counters.last() == Some(None): use "• " (depth 1), "◦ " (depth 2), "▪ " (depth 3+)
    • If list_counters.last() == Some(Some(n)): use format!("{}. ", n), then increment n in the counter
    • Push indent + bullet as a Span in Color::Cyan to current_spans
  • End(TagEnd::Item): flush current_spans as a Line

Blockquotes (REND-05):

  • Start(Tag::BlockQuote(_)): increment blockquote_depth, set in_blockquote = true, push empty line before
  • End(TagEnd::BlockQuote(_)): decrement blockquote_depth, set in_blockquote = false if depth == 0, push empty line after
  • When flushing lines inside blockquote: prepend Span::styled("│ ".repeat(depth), Style::default().fg(Color::Yellow)) to each Line, and apply Color::Gray to the content spans

Horizontal rules (REND-06):

  • Event::Rule: push Line::from(Span::styled("─".repeat(width as usize), Style::default().fg(Color::DarkGray))), then push empty Line

Images (REND-07):

  • Start(Tag::Image { .. }): set in_image = true, clear image_alt
  • Collect text inside image into image_alt
  • End(TagEnd::Image): push Span::styled(format!("[IMAGE: {}]", image_alt), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)) to current_spans, set in_image = false

Links:

  • Start(Tag::Link { .. }): for Phase 2, just pass through — link text renders as normal styled text. Links become interactive in Phase 3.
  • End(TagEnd::Link): nothing special

All other events: ignore (_ => {})

Do NOT add table or code block rendering yet — Task 2 handles those. For now, stub them:

  • Start(Tag::CodeBlock(_)): set in_code_block = true, clear code_buf, capture lang
  • End(TagEnd::CodeBlock): call a placeholder emit_code_block() that pushes code_buf lines as plain styled text
  • Table events: set in_table = true, accumulate cells, call placeholder emit_table() on End

Helper methods on RenderState:

  • flush_line(): take current_spans, make a Line, apply blockquote prefix if needed, push to lines, clear current_spans
  • current_style(): return top of style_stack or Style::default()
  • finish() -> Vec<Line<'static>>: return the accumulated lines

Ensure ALL Span content uses .to_string() for owned strings — every output must be Line<'static>. cargo check passes after temporarily adding mod renderer; to main.rs. Test with a simple markdown string mentally: # Hello\n\nSome **bold** text. should produce heading line + decorator + empty + paragraph line + empty. renderer.rs exists with render_markdown() function that handles headings (H1-H6 with colors + decorators), paragraphs, bold/italic/inline code, lists (ordered/unordered/nested), blockquotes with │ prefix, horizontal rules, images as placeholders, soft/hard breaks. Code blocks and tables have working stubs that render content without fancy borders.

Task 2: Add code block borders with syntax highlighting and GFM table grid rendering src/renderer.rs Replace the placeholder `emit_code_block()` and `emit_table()` with full implementations.

Code blocks (REND-03, REND-09):

Replace the code block stub with emit_code_block(code_buf: &str, lang: &str, width: u16, lines: &mut Vec<Line<'static>>):

  1. Get syntax-highlighted lines by calling crate::highlighter::highlight_code(code_buf, lang)
  2. Determine the box width: max(highlighted lines' max display width + 4, lang.len() + 6), capped at width
  3. Build the top border line:
    • ╭─ {lang} + repeated to fill +
    • Style: Color::DarkGray for border chars, Color::Yellow for lang label
    • If lang is empty, just + fill +
  4. For each highlighted line: + highlighted spans + padding to box width +
    • Border chars () in Color::DarkGray
    • Content spans keep their syntax highlighting colors from highlight_code()
  5. Build the bottom border: + fill + in Color::DarkGray
  6. Push empty Line before and after the code block for spacing

If highlight_code() returns empty (no syntax found), fall back to plain Color::Gray text for the code content.

Tables (REND-08, REND-09):

Replace the table stub with emit_table(alignments: &[Alignment], rows: &[Vec<String>], lines: &mut Vec<Line<'static>>):

  1. Compute column widths: for each column index, find max(cell.len() + 2) across all rows (header + data), minimum 5 chars
  2. Build helper build_border_line(left: char, mid: char, right: char, fill: char, widths: &[usize]) -> String:
    • e.g., build_border_line('┌', '┬', '┐', '─', &widths)┌──────┬──────┐
  3. Emit top border: build_border_line('┌', '┬', '┐', '─', ...) styled Color::Cyan
  4. Emit header row (rows[0]):
    • For each cell: + padded cell text (aligned per alignments[i]) + space
    • Cell text styled Color::LightCyan + Modifier::BOLD
    • border chars styled Color::Cyan
    • Trailing
  5. Emit header separator: build_border_line('├', '┼', '┤', '─', ...) styled Color::Cyan
  6. Emit data rows (rows[1..]):
    • Same format as header but with Style::default() for cell text
  7. Emit bottom border: build_border_line('└', '┴', '┘', '─', ...) styled Color::Cyan
  8. Push empty Line after table for spacing

Alignment handling in table cells:

  • Alignment::Left or Alignment::None: left-pad with 1 space, right-pad to width
  • Alignment::Right: left-pad to width, right-pad with 1 space
  • Alignment::Center: center the text within width

Ensure the table state machine in the event handler correctly:

  • Captures alignments from Start(Tag::Table(alignments))
  • Pushes new Vec<String> on Start(Tag::TableRow) AND Start(Tag::TableHead)
  • Accumulates cell text on Event::Text when in_table is true
  • Pushes cell to current row on End(TagEnd::TableCell)
  • Calls emit_table() on End(TagEnd::Table) cargo check passes. Mentally verify: a markdown code block rust\nlet x = 1;\n should produce 4 lines (top border, content, bottom border) plus spacing. A 2-column 3-row table should produce 6 lines (top border, header, separator, 2 data rows, bottom border) plus spacing. Code blocks render with ╭─╮│╰─╯ rounded borders, language label in yellow, syntax-highlighted content from highlighter.rs. GFM tables render with ┌┬┐├┼┤└┴┘ full box-drawing grid, bold LightCyan header row, aligned cell content. Both emit proper spacing.
1. `cargo check` passes with renderer.rs compiling 2. `render_markdown("# Test\n\nHello **world**\n", 80)` would produce: heading line (LightCyan bold), ══ decorator, empty, paragraph with bold span, empty 3. All pulldown-cmark Event variants for Phase 2 requirements are handled (Heading, Paragraph, Strong, Emphasis, Code, CodeBlock, List, Item, BlockQuote, Rule, Image, Table, TableHead, TableRow, TableCell, Text, SoftBreak, HardBreak) 4. Code block output includes ╭╮│╰╯ border characters with syntax-highlighted inner content 5. Table output includes ┌┬┐├┼┤└┴┘ border characters with bold header row 6. Every Span uses owned String content (.to_string()) — no borrowed lifetimes

<success_criteria>

  • renderer.rs contains a single public function render_markdown() that processes all markdown constructs
  • Headings H1-H6 have distinct CGA colors and H1/H2 have decorators
  • Bold, italic, inline code have correct terminal modifiers/colors
  • Lists support ordered/unordered with nesting
  • Code blocks have syntax highlighting with CGA colors and rounded box-drawing borders
  • Tables have full box-drawing grid with aligned columns
  • All output is Vec<Line<'static>> with no lifetime dependencies </success_criteria>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md`