Files
2026-02-28 22:02:14 +01:00

279 lines
14 KiB
Markdown

---
phase: 02-vault-core-and-rendering
plan: 02
type: execute
wave: 2
depends_on:
- "02-01"
files_modified:
- src/renderer.rs
autonomous: true
requirements:
- REND-01
- REND-02
- REND-03
- REND-04
- REND-05
- REND-06
- REND-07
- REND-08
- REND-09
- REND-10
must_haves:
truths:
- "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"
artifacts:
- path: "src/renderer.rs"
provides: "Complete markdown-to-styled-lines conversion pipeline"
exports: ["render_markdown"]
min_lines: 200
key_links:
- from: "src/renderer.rs"
to: "pulldown-cmark"
via: "Parser::new_ext with TextMergeStream consuming all Event variants"
pattern: "TextMergeStream"
- from: "src/renderer.rs"
to: "src/highlighter.rs"
via: "highlight_code() called for fenced code blocks"
pattern: "highlight_code"
- from: "src/renderer.rs"
to: "ratatui::text"
via: "Produces Vec<Line<'static>> with Span styling"
pattern: "Vec<Line<'static>>"
---
<objective>
Build the complete markdown-to-styled-lines renderer that converts a markdown string into `Vec<Line<'static>>` 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.
</objective>
<execution_context>
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: Build core markdown renderer with inline and block elements</name>
<files>src/renderer.rs</files>
<action>
Create `src/renderer.rs` with the public function `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>`.
The `width` parameter is needed for horizontal rules (full-width `─` repeats) and could be used for code block border sizing.
**Internal state struct (private):**
```rust
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>`.
</action>
<verify>`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.</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Add code block borders with syntax highlighting and GFM table grid rendering</name>
<files>src/renderer.rs</files>
<action>
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)`
</action>
<verify>`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.</verify>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md`
</output>