docs(03): create phase plan
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
---
|
||||
phase: 03-navigation-and-links
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/renderer.rs
|
||||
- src/vault.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- NAV-01
|
||||
- NAV-02
|
||||
- NAV-10
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "render_markdown returns both styled lines and a parallel Vec<LinkRecord> for every link in the document"
|
||||
- "Wiki-links ([[Page Name]]) are parsed via pulldown-cmark ENABLE_WIKILINKS and rendered as bracket-wrapped LightCyan text"
|
||||
- "Standard markdown links ([text](path.md)) are rendered as bracket-wrapped LightCyan text"
|
||||
- "Broken wiki-links (dest does not resolve in vault) are rendered as red text with CROSSED_OUT modifier"
|
||||
- "resolve_wiki_link() maps raw wiki targets to vault-relative file paths with case-insensitive matching"
|
||||
- "Path traversal attacks via ../.. are blocked by canonicalize + starts_with guard"
|
||||
artifacts:
|
||||
- path: "src/renderer.rs"
|
||||
provides: "LinkRecord struct, updated render_markdown returning (Vec<Line>, Vec<LinkRecord>)"
|
||||
contains: "pub struct LinkRecord"
|
||||
- path: "src/vault.rs"
|
||||
provides: "Wiki-link resolution and path traversal guard"
|
||||
contains: "pub fn resolve_wiki_link"
|
||||
key_links:
|
||||
- from: "src/renderer.rs"
|
||||
to: "pulldown-cmark ENABLE_WIKILINKS"
|
||||
via: "Options::ENABLE_WIKILINKS inserted in render_markdown"
|
||||
pattern: "ENABLE_WIKILINKS"
|
||||
- from: "src/renderer.rs"
|
||||
to: "src/vault.rs"
|
||||
via: "LinkRecord.is_wiki flag consumed by app.rs to decide whether to call resolve_wiki_link"
|
||||
pattern: "is_wiki"
|
||||
- from: "src/vault.rs"
|
||||
to: "std::fs::read_dir"
|
||||
via: "resolve_wiki_link scans vault directory for case-insensitive matches"
|
||||
pattern: "read_dir"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Extend the renderer to extract link metadata and render links with BBS-style bracket-wrapped styling, and add wiki-link resolution to the vault module.
|
||||
|
||||
Purpose: The renderer currently ignores links (Phase 2 pass-through). This plan makes links visible and extractable so Plan 02 can wire navigation. Wiki-link resolution is the vault-side counterpart that maps raw targets to actual files.
|
||||
|
||||
Output:
|
||||
- `render_markdown()` returns `(Vec<Line<'static>>, Vec<LinkRecord>)` instead of `Vec<Line<'static>>`
|
||||
- `LinkRecord` struct with line_index, col_offset, span_len, dest, is_wiki
|
||||
- All links rendered as `[Link Text]` in LightCyan; broken wiki-links in Red+CROSSED_OUT
|
||||
- `resolve_wiki_link()` in vault.rs with path traversal guard
|
||||
</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/03-navigation-and-links/03-RESEARCH.md
|
||||
@src/renderer.rs
|
||||
@src/vault.rs
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend renderer with LinkRecord extraction and link styling</name>
|
||||
<files>src/renderer.rs</files>
|
||||
<action>
|
||||
Modify `src/renderer.rs` to produce link metadata alongside styled lines:
|
||||
|
||||
1. **Add LinkRecord struct** (public, at top of file):
|
||||
```rust
|
||||
pub struct LinkRecord {
|
||||
pub line_index: usize, // Index into Vec<Line> where this link appears
|
||||
pub col_offset: usize, // Character column offset within that line
|
||||
pub span_len: usize, // Display length of the link text (including brackets)
|
||||
pub dest: String, // Raw destination (path for inline, wiki target for wiki-links)
|
||||
pub is_wiki: bool, // True if WikiLink type (needs resolution at nav time)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add PendingLink to RenderState**:
|
||||
- Add fields: `pending_link: Option<PendingLink>` and `pending_link_records: Vec<PendingLinkRecord>` and `link_records: Vec<LinkRecord>`
|
||||
- `PendingLink` holds `dest: String`, `is_wiki: bool`, `col_offset: usize` (computed from sum of current_spans character lengths at Tag::Link Start)
|
||||
- `PendingLinkRecord` holds `dest`, `is_wiki`, `col_offset` (finalized at TagEnd::Link, awaiting line_index from flush)
|
||||
|
||||
3. **Enable ENABLE_WIKILINKS** in `render_markdown()`:
|
||||
- Add `opts.insert(Options::ENABLE_WIKILINKS);` to the options block
|
||||
- **IMPORTANT**: Test TextMergeStream interaction — if wiki-link text is garbled, wrap only the parser without TextMergeStream for wiki-link detection and apply TextMergeStream separately for code blocks. Research indicates this is LOW risk but must be verified.
|
||||
|
||||
4. **Handle Tag::Link Start event** (replace the current pass-through):
|
||||
```rust
|
||||
Event::Start(Tag::Link { link_type, dest_url, .. }) => {
|
||||
let is_wiki = matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
|
||||
let col_offset: usize = state.current_spans.iter()
|
||||
.map(|s| s.content.chars().count())
|
||||
.sum();
|
||||
state.pending_link = Some(PendingLink {
|
||||
dest: dest_url.to_string(),
|
||||
is_wiki,
|
||||
col_offset,
|
||||
});
|
||||
// Determine link color: broken wiki-links get Red+CROSSED_OUT
|
||||
// For wiki-links, check at render time whether the link resolves.
|
||||
// Pass vault_path into render_markdown for this check.
|
||||
// Push opening bracket "[" in appropriate color
|
||||
}
|
||||
```
|
||||
|
||||
For broken wiki-link detection at render time: `render_markdown` needs access to `vault_path` to call `resolve_wiki_link` for wiki-links. Add `vault_path: Option<&Path>` parameter. When `is_wiki` and vault_path is Some, call `crate::vault::resolve_wiki_link(vault_path, &dest_url)` — if None, use red/strikethrough style; if Some, use LightCyan style. For non-wiki links, always use LightCyan (broken standard links are revealed only at navigation time per research recommendation).
|
||||
|
||||
The link style determines the color of brackets and text:
|
||||
- Normal link: `Style::default().fg(Color::LightCyan)`
|
||||
- Broken wiki-link: `Style::default().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT)`
|
||||
|
||||
Push opening bracket span: `state.current_spans.push(Span::styled("[", link_style))`
|
||||
Set a flag so Text events inside the link use the link style instead of current_style().
|
||||
|
||||
5. **Handle TagEnd::Link**:
|
||||
- Push closing bracket span `"]"` with same link style
|
||||
- Compute `span_len` = chars count of all spans pushed between Start and End (including brackets)
|
||||
- Create a `PendingLinkRecord { dest, is_wiki, col_offset, span_len }` and push to `state.pending_link_records`
|
||||
- Clear `state.pending_link`
|
||||
|
||||
6. **Update flush_line()** to finalize link records:
|
||||
After pushing the line to `self.lines`, iterate `self.pending_link_records`, drain them, and for each create a `LinkRecord` with `line_index = self.lines.len() - 1` (the just-pushed line). Push to `self.link_records`.
|
||||
|
||||
7. **Update finish()** to return the pair:
|
||||
Change return type to `(Vec<Line<'static>>, Vec<LinkRecord>)`. Return `(self.lines, self.link_records)`.
|
||||
|
||||
8. **Update render_markdown() signature**:
|
||||
```rust
|
||||
pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>)
|
||||
```
|
||||
|
||||
Note: Using `chars().count()` for col_offset instead of `len()` — handles multi-byte Unicode correctly per research Pitfall 4.
|
||||
</action>
|
||||
<verify>
|
||||
`cargo build` compiles with zero new errors. The function signature change will cause errors in app.rs and main.rs which are expected — Plan 02 will fix those callers. To verify in isolation, temporarily adjust the call sites in app.rs `handle_resize` and main.rs to destructure the tuple (e.g., `let (lines, _links) = renderer::render_markdown(...)`) so `cargo build` passes. If this is done, add a `// TODO(03-02): use link_records` comment.
|
||||
</verify>
|
||||
<done>
|
||||
render_markdown returns (Vec<Line>, Vec<LinkRecord>). Wiki-links parsed via ENABLE_WIKILINKS. Links display as bracket-wrapped colored text. Broken wiki-links show red/strikethrough. LinkRecord contains line_index, col_offset, span_len, dest, is_wiki for every link in the document.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add wiki-link resolution and path traversal guard to vault.rs</name>
|
||||
<files>src/vault.rs</files>
|
||||
<action>
|
||||
Add two public functions to `src/vault.rs`:
|
||||
|
||||
1. **`is_within_vault(vault_path: &Path, candidate: &Path) -> bool`**:
|
||||
```rust
|
||||
pub fn is_within_vault(vault_path: &Path, candidate: &Path) -> bool {
|
||||
match (candidate.canonicalize(), vault_path.canonicalize()) {
|
||||
(Ok(canon_candidate), Ok(canon_vault)) => canon_candidate.starts_with(&canon_vault),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **`resolve_wiki_link(vault_path: &Path, raw_target: &str) -> Option<PathBuf>`**:
|
||||
|
||||
Algorithm (per locked decisions — case-insensitive, hyphens first, then underscores, then literal):
|
||||
- Split raw_target on last `/` to get (subdir, name). If no `/`, subdir is empty.
|
||||
- Generate candidates: `name.replace(' ', "-")`, `name.replace(' ', "_")`, `name.to_string()` (literal spaces)
|
||||
- Compute search_dir: `vault_path.join(subdir)` if subdir is non-empty, otherwise vault_path
|
||||
- Call `std::fs::read_dir(&search_dir)` — return None on error
|
||||
- For each entry, get filename, strip `.md` suffix, lowercase compare against each candidate (lowercased)
|
||||
- On first match: construct full path, call `is_within_vault()` to guard against path traversal
|
||||
- If within vault: return `Some(canonical.strip_prefix(vault_canonical).to_path_buf())` — vault-relative path
|
||||
- If no match found: return None
|
||||
|
||||
Per locked decision: first match wins (filesystem order). This is documented as intentional in the research.
|
||||
|
||||
3. **`resolve_standard_link(vault_path: &Path, current_doc: &str, dest: &str) -> Option<PathBuf>`**:
|
||||
|
||||
For standard markdown links `[text](path.md)`:
|
||||
- Compute base_dir from current_doc: `vault_path.join(current_doc).parent()` (directory of current document)
|
||||
- Join: `base_dir.join(dest)`
|
||||
- Apply is_within_vault() guard
|
||||
- If file exists and within vault: return vault-relative path
|
||||
- Otherwise: return None
|
||||
|
||||
This function is needed for Plan 02 when following standard links. Adding it here keeps all vault path resolution in one place.
|
||||
</action>
|
||||
<verify>
|
||||
`cargo build` compiles. Add a small test at the bottom of vault.rs (or verify mentally): `resolve_wiki_link` with a known vault directory returns the expected path; `is_within_vault` rejects `../../etc/passwd`.
|
||||
</verify>
|
||||
<done>
|
||||
resolve_wiki_link() resolves raw wiki targets to vault-relative paths with case-insensitive multi-strategy matching. resolve_standard_link() resolves relative markdown link paths. Both are guarded by is_within_vault() which uses canonicalize + starts_with to prevent path traversal.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` succeeds (caller sites temporarily updated to handle new return type)
|
||||
2. render_markdown with a document containing `[[Test Link]]` and `[Click](page.md)` produces LinkRecords with correct is_wiki, dest, line_index, col_offset, span_len
|
||||
3. Links render as bracket-wrapped colored text: `[Test Link]` in LightCyan, broken `[[Missing]]` in Red+CROSSED_OUT
|
||||
4. resolve_wiki_link with "Getting Started" matches "getting-started.md" (case-insensitive, hyphen strategy)
|
||||
5. resolve_wiki_link with "guides/Getting Started" resolves subpath correctly
|
||||
6. is_within_vault rejects candidates outside the vault root
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The renderer produces a parallel link metadata structure alongside styled lines. Wiki-links and standard links are visually distinct from plain text. The vault module can resolve wiki-link targets to file paths safely. Plan 02 can consume these outputs to wire navigation.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-navigation-and-links/03-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user