From 9bc217bc2204f8893f3c32a2eed1f3aec38f7954 Mon Sep 17 00:00:00 2001 From: Alex Harris Date: Sat, 14 Feb 2026 19:56:19 +0000 Subject: [PATCH 1/2] feat: follow relative markdown links in TUI --- ui/keys.go | 7 +- ui/pager.go | 251 ++++++++++++++++++++++++++++++++---------- ui/pager_highlight.go | 108 ++++++++++++++++++ ui/pager_links.go | 198 +++++++++++++++++++++++++++++++++ ui/ui.go | 27 ++++- 5 files changed, 532 insertions(+), 59 deletions(-) create mode 100644 ui/pager_highlight.go create mode 100644 ui/pager_links.go diff --git a/ui/keys.go b/ui/keys.go index 8e13f95..e874465 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -1,6 +1,9 @@ package ui const ( - keyEnter = "enter" - keyEsc = "esc" + keyTab = "tab" + keyShiftTab = "shift+tab" + keyEnter = "enter" + keyBackspace = "backspace" + keyEsc = "esc" ) diff --git a/ui/pager.go b/ui/pager.go index 5c522e7..3cb9380 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -89,6 +89,11 @@ const ( pagerStateStatusMessage ) +type navEntry struct { + Path string + YOffset int +} + type pagerModel struct { common *commonModel viewport viewport.Model @@ -102,19 +107,29 @@ type pagerModel struct { // it here so we can re-render it on resize. currentDocument markdown - watcher *fsnotify.Watcher + rendered string + + links []followableLink + focusedLink int + history []navEntry + + pendingRestoreYOffset *int + + watcher *fsnotify.Watcher + watchedDir string + watchCancel chan struct{} } func newPagerModel(common *commonModel) pagerModel { - // Init viewport vp := viewport.New(0, 0) vp.YPosition = 0 - vp.HighPerformanceRendering = config.HighPerformancePager + vp.HighPerformanceRendering = common.cfg.HighPerformancePager m := pagerModel{ - common: common, - state: pagerStateBrowse, - viewport: vp, + common: common, + state: pagerStateBrowse, + viewport: vp, + focusedLink: -1, } m.initWatcher() return m @@ -136,6 +151,14 @@ func (m *pagerModel) setContent(s string) { m.viewport.SetContent(s) } +func (m *pagerModel) applyRenderedContent() { + content := m.rendered + if m.focusedLink >= 0 { + content = highlightFocusedLink(content, m.links, m.focusedLink) + } + m.setContent(content) +} + func (m *pagerModel) toggleHelp() { m.showHelp = !m.showHelp m.setSize(m.common.width, m.common.height) @@ -175,7 +198,12 @@ func (m *pagerModel) unload() { m.state = pagerStateBrowse m.viewport.SetContent("") m.viewport.YOffset = 0 - m.unwatchFile() + m.rendered = "" + m.links = nil + m.focusedLink = -1 + m.history = nil + m.pendingRestoreYOffset = nil + m.stopWatching() } func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { @@ -192,26 +220,56 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { m.state = pagerStateBrowse return m, nil } + case keyTab: + if len(m.links) == 0 { + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable links", false})) + break + } + if m.focusedLink < 0 { + m.focusedLink = 0 + } else { + m.focusedLink = (m.focusedLink + 1) % len(m.links) + } + m.applyRenderedContent() + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false})) + case keyShiftTab, "backtab": + if len(m.links) == 0 { + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable links", false})) + break + } + if m.focusedLink < 0 { + m.focusedLink = len(m.links) - 1 + } else { + m.focusedLink-- + if m.focusedLink < 0 { + m.focusedLink = len(m.links) - 1 + } + } + m.applyRenderedContent() + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false})) + + case keyEnter: + if m.focusedLink >= 0 && m.focusedLink < len(m.links) { + cmd := m.followFocusedLink() + return m, cmd + } + if len(m.links) > 0 { + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Tab to select a link", false})) + } + + case keyBackspace: + if len(m.history) > 0 { + cmd := m.goBack() + return m, cmd + } case "home", "g": m.viewport.GotoTop() - if m.viewport.HighPerformanceRendering { + if m.common != nil && m.common.cfg.HighPerformancePager { cmds = append(cmds, viewport.Sync(m.viewport)) } case "end", "G": m.viewport.GotoBottom() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } - - case "d": - m.viewport.HalfViewDown() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } - - case "u": - m.viewport.HalfViewUp() - if m.viewport.HighPerformanceRendering { + if m.common != nil && m.common.cfg.HighPerformancePager { cmds = append(cmds, viewport.Sync(m.viewport)) } @@ -239,20 +297,32 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { case "?": m.toggleHelp() - if m.viewport.HighPerformanceRendering { + if m.common != nil && m.common.cfg.HighPerformancePager { cmds = append(cmds, viewport.Sync(m.viewport)) } } + case errMsg: + m.pendingRestoreYOffset = nil + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{msg.Error(), true})) + // Glow has rendered the content case contentRenderedMsg: log.Info("content rendered", "state", m.state) - m.setContent(string(msg)) - if m.viewport.HighPerformanceRendering { + m.rendered = string(msg) + m.applyRenderedContent() + if m.pendingRestoreYOffset != nil { + m.viewport.YOffset = *m.pendingRestoreYOffset + if m.viewport.PastBottom() { + m.viewport.GotoBottom() + } + m.pendingRestoreYOffset = nil + } + if m.common != nil && m.common.cfg.HighPerformancePager { cmds = append(cmds, viewport.Sync(m.viewport)) } - cmds = append(cmds, m.watchFile) + cmds = append(cmds, m.startWatching()) // The file was changed on disk and we're reloading it case reloadMsg: @@ -366,26 +436,30 @@ func (m pagerModel) statusBarView(b *strings.Builder) { } func (m pagerModel) helpView() (s string) { - col1 := []string{ - "g/home go to top", - "G/end go to bottom", - "c copy contents", - "e edit this document", - "r reload this document", - "esc back to files", - "q quit", + rows := [][2]string{ + {"k/↑ up", "g/home go to top"}, + {"j/↓ down", "G/end go to bottom"}, + {"b/pgup page up", "tab next link"}, + {"f/pgdn page down", "⇧tab prev link"}, + {"u ½ page up", "enter follow link"}, + {"d ½ page down", "⌫ go back"}, + {"", "c copy contents"}, + {"", "e edit this document"}, + {"", "r reload this document"}, + {"", "esc back to files"}, + {"", "q quit"}, } s += "\n" - s += "k/↑ up " + col1[0] + "\n" - s += "j/↓ down " + col1[1] + "\n" - s += "b/pgup page up " + col1[2] + "\n" - s += "f/pgdn page down " + col1[3] + "\n" - s += "u ½ page up " + col1[4] + "\n" - s += "d ½ page down " - - if len(col1) > 5 { - s += col1[5] + for _, row := range rows { + left := row[0] + right := row[1] + if left != "" { + left = fmt.Sprintf("%-24s", left) + } else { + left = strings.Repeat(" ", 24) + } + s += left + right + "\n" } s = indent(s, 2) @@ -487,20 +561,37 @@ func (m *pagerModel) initWatcher() { } } -func (m *pagerModel) watchFile() tea.Msg { - dir := m.localDir() +func (m *pagerModel) startWatching() tea.Cmd { + if m.watcher == nil || m.currentDocument.localPath == "" { + return nil + } + m.stopWatching() + + dir := m.localDir() if err := m.watcher.Add(dir); err != nil { log.Error("error adding dir to fsnotify watcher", "error", err) return nil } + m.watchedDir = dir + m.watchCancel = make(chan struct{}) - log.Info("fsnotify watching dir", "dir", dir) + cancel := m.watchCancel + return func() tea.Msg { return m.watchFile(cancel) } +} + +func (m *pagerModel) watchFile(cancel <-chan struct{}) tea.Msg { + log.Info("fsnotify watching dir", "dir", m.watchedDir) for { select { + case <-cancel: + return nil case event, ok := <-m.watcher.Events: - if !ok || event.Name != m.currentDocument.localPath { + if !ok { + return nil + } + if event.Name != m.currentDocument.localPath { continue } @@ -512,24 +603,72 @@ func (m *pagerModel) watchFile() tea.Msg { return reloadMsg{} case err, ok := <-m.watcher.Errors: if !ok { - continue + return nil } - log.Debug("fsnotify error", "dir", dir, "error", err) + log.Debug("fsnotify error", "dir", m.watchedDir, "error", err) } } } -func (m *pagerModel) unwatchFile() { - dir := m.localDir() - - err := m.watcher.Remove(dir) - if err == nil { - log.Debug("fsnotify dir unwatched", "dir", dir) - } else { - log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err) +func (m *pagerModel) stopWatching() { + if m.watchCancel != nil { + close(m.watchCancel) + m.watchCancel = nil } + + if m.watcher == nil || m.watchedDir == "" { + return + } + + err := m.watcher.Remove(m.watchedDir) + if err == nil { + log.Debug("fsnotify dir unwatched", "dir", m.watchedDir) + } else { + log.Error("fsnotify fail to unwatch dir", "dir", m.watchedDir, "error", err) + } + m.watchedDir = "" } func (m *pagerModel) localDir() string { return filepath.Dir(m.currentDocument.localPath) } + +func (m *pagerModel) followFocusedLink() tea.Cmd { + l := m.links[m.focusedLink] + if l.ResolvedPath == "" { + return nil + } + if m.currentDocument.localPath != "" { + m.history = append(m.history, navEntry{Path: m.currentDocument.localPath, YOffset: m.viewport.YOffset}) + } + + m.focusedLink = -1 + m.viewport.GotoTop() + m.pendingRestoreYOffset = nil + + md := &markdown{ + localPath: l.ResolvedPath, + Note: l.ResolvedNote, + } + return loadLocalMarkdown(md) +} + +func (m *pagerModel) goBack() tea.Cmd { + if len(m.history) == 0 { + return nil + } + + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + + m.focusedLink = -1 + y := last.YOffset + m.pendingRestoreYOffset = &y + m.viewport.GotoTop() + + md := &markdown{ + localPath: last.Path, + Note: stripAbsolutePath(last.Path, m.common.cwd), + } + return loadLocalMarkdown(md) +} diff --git a/ui/pager_highlight.go b/ui/pager_highlight.go new file mode 100644 index 0000000..578755c --- /dev/null +++ b/ui/pager_highlight.go @@ -0,0 +1,108 @@ +package ui + +import ( + "strings" + "unicode/utf8" +) + +func highlightFocusedLink(rendered string, links []followableLink, focused int) string { + if focused < 0 || focused >= len(links) { + return rendered + } + + printable, offsets := printableRunesAndOffsets(rendered) + if len(printable) == 0 { + return rendered + } + printableStr := string(printable) + + type span struct { + start int + end int + ok bool + } + + spans := make([]span, len(links)) + searchFrom := 0 + for i, l := range links { + label := strings.TrimSpace(l.Label) + if label == "" || searchFrom >= len(printableStr) { + continue + } + + relIdx := strings.Index(printableStr[searchFrom:], label) + if relIdx < 0 { + continue + } + byteIdx := searchFrom + relIdx + searchFrom = byteIdx + len(label) + + startRune := utf8.RuneCountInString(printableStr[:byteIdx]) + endRune := startRune + utf8.RuneCountInString(label) + if startRune < 0 || endRune > len(offsets)-1 { + continue + } + + startByte := offsets[startRune] + endByte := offsets[endRune] + if startByte < 0 || endByte < startByte || endByte > len(rendered) { + continue + } + + spans[i] = span{start: startByte, end: endByte, ok: true} + } + + s := spans[focused] + if !s.ok { + return rendered + } + + const ( + reverseOn = "\x1b[7m" + reverseOff = "\x1b[27m" + ) + + var b strings.Builder + b.Grow(len(rendered) + len(reverseOn) + len(reverseOff)) + b.WriteString(rendered[:s.start]) + b.WriteString(reverseOn) + b.WriteString(rendered[s.start:s.end]) + b.WriteString(reverseOff) + b.WriteString(rendered[s.end:]) + return b.String() +} + +func printableRunesAndOffsets(s string) ([]rune, []int) { + var ( + runes []rune + offsets []int + ) + + for i := 0; i < len(s); { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + i += 2 + for i < len(s) { + c := s[i] + i++ + if c >= 0x40 && c <= 0x7E { + break + } + } + continue + } + + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + r = rune(s[i]) + size = 1 + } + + runes = append(runes, r) + offsets = append(offsets, i) + i += size + } + + offsets = append(offsets, len(s)) + + return runes, offsets +} diff --git a/ui/pager_links.go b/ui/pager_links.go new file mode 100644 index 0000000..adcbb2e --- /dev/null +++ b/ui/pager_links.go @@ -0,0 +1,198 @@ +package ui + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +type followableLink struct { + Href string + Path string + Fragment string + Label string + + ResolvedPath string + ResolvedNote string +} + +type rawLink struct { + href string + label string +} + +func followableLinksForDocument(rootDir, currentFilePath, markdown string) ([]followableLink, error) { + raw := extractRawLinks(markdown) + + out := make([]followableLink, 0, len(raw)) + for _, l := range raw { + link, ok, err := resolveFollowableLink(rootDir, currentFilePath, l.href) + if err != nil { + return nil, err + } + if !ok { + continue + } + if strings.TrimSpace(l.label) == "" { + continue + } + link.Label = l.label + out = append(out, link) + } + return out, nil +} + +func splitFragment(href string) (path, frag string) { + path, frag, ok := strings.Cut(href, "#") + if ok { + return path, frag + } + return href, "" +} + +func isAbsoluteOrUNCPath(path string) bool { + if strings.HasPrefix(path, "/") { + return true + } + if strings.HasPrefix(path, `\\`) { + return true + } + if len(path) >= 2 { + c0 := path[0] + if ((c0 >= 'a' && c0 <= 'z') || (c0 >= 'A' && c0 <= 'Z')) && path[1] == ':' { + return true + } + } + return filepath.IsAbs(path) +} + +func isFollowableHref(href string) bool { + href = strings.TrimSpace(href) + href = strings.Trim(href, "<>") + hrefLower := strings.ToLower(href) + + if strings.Contains(href, "://") || strings.HasPrefix(hrefLower, "mailto:") { + return false + } + + path, _ := splitFragment(href) + if isAbsoluteOrUNCPath(path) { + return false + } + pathLower := strings.ToLower(path) + + return strings.HasSuffix(pathLower, ".md") || strings.HasSuffix(pathLower, ".markdown") +} + +func extractRawLinks(markdown string) []rawLink { + source := []byte(markdown) + parser := goldmark.New().Parser() + doc := parser.Parse(text.NewReader(source)) + + var out []rawLink + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + link, ok := n.(*ast.Link) + if !ok { + return ast.WalkContinue, nil + } + + href := strings.TrimSpace(string(link.Destination)) + if href == "" { + return ast.WalkContinue, nil + } + + var b strings.Builder + _ = ast.Walk(link, func(child ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if t, ok := child.(*ast.Text); ok { + b.Write(t.Segment.Value(source)) + } + return ast.WalkContinue, nil + }) + + out = append(out, rawLink{ + href: href, + label: strings.TrimSpace(b.String()), + }) + + return ast.WalkContinue, nil + }) + + return out +} + +func resolveFollowableLink(rootDir, currentFilePath, href string) (followableLink, bool, error) { + href = strings.TrimSpace(href) + href = strings.Trim(href, "<>") + + if !isFollowableHref(href) { + return followableLink{}, false, nil + } + + path, frag := splitFragment(href) + path = strings.TrimSpace(path) + if path == "" { + return followableLink{}, false, nil + } + + if strings.Contains(path, "%") { + if decoded, err := url.PathUnescape(path); err == nil { + path = decoded + } + } + + base := filepath.Dir(currentFilePath) + resolved := filepath.Clean(filepath.Join(base, path)) + + rootAbs, err := filepath.Abs(rootDir) + if err != nil { + return followableLink{}, false, fmt.Errorf("abs root dir: %w", err) + } + resAbs, err := filepath.Abs(resolved) + if err != nil { + return followableLink{}, false, fmt.Errorf("abs resolved path: %w", err) + } + + if rootEval, err := filepath.EvalSymlinks(rootAbs); err == nil { + rootAbs = rootEval + } + if resEval, err := filepath.EvalSymlinks(resAbs); err == nil { + resAbs = resEval + } + + rel, err := filepath.Rel(rootAbs, resAbs) + if err != nil { + return followableLink{}, false, nil + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return followableLink{}, false, nil + } + + info, statErr := os.Stat(resAbs) + if statErr != nil { + return followableLink{}, false, nil + } + if !info.Mode().IsRegular() { + return followableLink{}, false, nil + } + + return followableLink{ + Href: href, + Path: path, + Fragment: frag, + ResolvedPath: resAbs, + ResolvedNote: stripAbsolutePath(resAbs, rootAbs), + }, true, nil +} diff --git a/ui/ui.go b/ui/ui.go index 3537d3f..6da0196 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -172,10 +172,14 @@ func newModel(cfg Config, content string) tea.Model { m.state = stateShowStash } else { cwd, _ := os.Getwd() + m.common.cwd = cwd + if rel, err := filepath.Rel(m.common.cwd, path); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + m.common.cwd = filepath.Dir(path) + } m.state = stateShowDocument m.pager.currentDocument = markdown{ localPath: path, - Note: stripAbsolutePath(path, cwd), + Note: stripAbsolutePath(path, m.common.cwd), Modtime: info.ModTime(), } } @@ -196,6 +200,15 @@ func (m model) Init() tea.Cmd { return func() tea.Msg { return errMsg{err} } } body := string(utils.RemoveFrontmatter(content)) + m.pager.currentDocument.Body = body + if m.pager.currentDocument.localPath != "" && m.common.cwd != "" { + links, err := followableLinksForDocument(m.common.cwd, m.pager.currentDocument.localPath, body) + if err != nil { + log.Debug("error extracting followable links", "error", err) + } + m.pager.links = links + m.pager.focusedLink = -1 + } cmds = append(cmds, renderWithGlamour(m.pager, body)) } @@ -276,6 +289,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // We've loaded a markdown file's contents for rendering m.pager.currentDocument = *msg body := string(utils.RemoveFrontmatter([]byte(msg.Body))) + m.pager.currentDocument.Body = body + if m.pager.currentDocument.localPath != "" && m.common.cwd != "" { + links, err := followableLinksForDocument(m.common.cwd, m.pager.currentDocument.localPath, body) + if err != nil { + log.Debug("error extracting followable links", "error", err) + } + m.pager.links = links + m.pager.focusedLink = -1 + } else { + m.pager.links = nil + m.pager.focusedLink = -1 + } cmds = append(cmds, renderWithGlamour(m.pager, body)) case contentRenderedMsg: From 166393f633fbb95547f93d4dd4a57ad665420002 Mon Sep 17 00:00:00 2001 From: Alex Harris Date: Sun, 15 Feb 2026 22:32:17 +0000 Subject: [PATCH 2/2] test: link extraction formats and root-escape protection --- ui/pager_links_test.go | 254 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 ui/pager_links_test.go diff --git a/ui/pager_links_test.go b/ui/pager_links_test.go new file mode 100644 index 0000000..4407ee3 --- /dev/null +++ b/ui/pager_links_test.go @@ -0,0 +1,254 @@ +package ui + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFollowableLinksForDocument_CommonFormatsAndSafety(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "root") + outside := filepath.Join(base, "outside") + + mustMkdirAll(t, filepath.Join(root, "docs")) + mustMkdirAll(t, outside) + + currentFilePath := filepath.Join(root, "current.md") + mustWriteFile(t, currentFilePath, "# Current\n") + + targetMD := filepath.Join(root, "docs", "target.md") + targetMarkdown := filepath.Join(root, "docs", "target.markdown") + spaceNameMD := filepath.Join(root, "docs", "SPACE NAME.md") + mustWriteFile(t, targetMD, "# Target\n") + mustWriteFile(t, targetMarkdown, "# Target Markdown\n") + mustWriteFile(t, spaceNameMD, "# Space Name\n") + + outsideMD := filepath.Join(outside, "outside.md") + mustWriteFile(t, outsideMD, "# Outside\n") + + // A directory with a markdown-looking name should not be followable. + dirLooksLikeMD := filepath.Join(root, "docs", "dir.md") + mustMkdirAll(t, dirLooksLikeMD) + + rootAbs := absEvalSymlinks(t, root) + + type wantLink struct { + Label string + ResolvedPath string + ResolvedNote string + Fragment string + } + + targetAbs := absEvalSymlinks(t, targetMD) + targetMarkdownAbs := absEvalSymlinks(t, targetMarkdown) + spaceNameAbs := absEvalSymlinks(t, spaceNameMD) + + cases := []struct { + name string + md string + want []wantLink + setup func(t *testing.T) + }{ + { + name: "inline_relative_md", + md: "See [Target](docs/target.md).\n", + want: []wantLink{{ + Label: "Target", + ResolvedPath: targetAbs, + ResolvedNote: stripAbsolutePath(targetAbs, rootAbs), + }}, + }, + { + name: "reference_relative_md", + md: "See [Target][id].\n\n[id]: docs/target.md\n", + want: []wantLink{{ + Label: "Target", + ResolvedPath: targetAbs, + ResolvedNote: stripAbsolutePath(targetAbs, rootAbs), + }}, + }, + { + name: "collapsed_reference_relative_md", + md: "[Target][]\n\n[Target]: docs/target.md\n", + want: []wantLink{{ + Label: "Target", + ResolvedPath: targetAbs, + ResolvedNote: stripAbsolutePath(targetAbs, rootAbs), + }}, + }, + { + name: "relative_md_with_fragment", + md: "See [Target](docs/target.md#section).\n", + want: []wantLink{{ + Label: "Target", + ResolvedPath: targetAbs, + ResolvedNote: stripAbsolutePath(targetAbs, rootAbs), + Fragment: "section", + }}, + }, + { + name: "destination_in_angle_brackets", + md: "See [Target]().\n", + want: []wantLink{{ + Label: "Target", + ResolvedPath: targetAbs, + ResolvedNote: stripAbsolutePath(targetAbs, rootAbs), + }}, + }, + { + name: "url_escaped_path_is_unescaped", + md: "See [Space](docs/SPACE%20NAME.md).\n", + want: []wantLink{{ + Label: "Space", + ResolvedPath: spaceNameAbs, + ResolvedNote: stripAbsolutePath(spaceNameAbs, rootAbs), + }}, + }, + { + name: "relative_markdown_extension", + md: "See [Target](docs/target.markdown).\n", + want: []wantLink{{ + Label: "Target", + ResolvedPath: targetMarkdownAbs, + ResolvedNote: stripAbsolutePath(targetMarkdownAbs, rootAbs), + }}, + }, + { + name: "empty_label_is_ignored", + md: "See [](docs/target.md).\n", + want: nil, + }, + { + name: "image_is_ignored", + md: "![Alt](docs/target.md)\n", + want: nil, + }, + { + name: "autolink_relative_is_ignored", + md: "See .\n", + want: nil, + }, + { + name: "bare_path_is_ignored", + md: "docs/target.md\n", + want: nil, + }, + { + name: "external_http_is_ignored", + md: "See [Ext](https://example.com/docs/target.md).\n", + want: nil, + }, + { + name: "mailto_is_ignored", + md: "See [Mail](mailto:test@example.com).\n", + want: nil, + }, + { + name: "non_markdown_extension_is_ignored", + md: "See [Txt](docs/target.txt).\n", + want: nil, + }, + { + name: "missing_file_is_ignored", + md: "See [Missing](docs/missing.md).\n", + want: nil, + }, + { + name: "directory_is_ignored_even_if_md_suffix", + md: "See [Dir](docs/dir.md).\n", + want: nil, + }, + { + name: "absolute_unix_path_is_ignored", + md: "See [Abs](/etc/passwd).\n", + want: nil, + }, + { + name: "windows_drive_absolute_is_ignored", + md: `See [WinAbs](C:\\Windows\\system32\\a.md).`, + want: nil, + }, + { + name: "windows_unc_absolute_is_ignored", + md: `See [UNC](\\\\server\\share\\a.md).`, + want: nil, + }, + { + name: "root_escape_via_dotdot_is_ignored", + md: "See [Escape](../outside/outside.md).\n", + want: nil, + }, + { + name: "root_escape_via_symlink_is_ignored", + md: "See [Escape](escape/outside.md).\n", + want: nil, + setup: func(t *testing.T) { + t.Helper() + escape := filepath.Join(root, "escape") + if err := os.Symlink(outside, escape); err != nil { + t.Skipf("symlink not supported: %v", err) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(t) + } + + got, err := followableLinksForDocument(root, currentFilePath, tc.md) + if err != nil { + t.Fatalf("followableLinksForDocument returned error: %v", err) + } + + if len(got) != len(tc.want) { + t.Fatalf("expected %d links, got %d: %+v", len(tc.want), len(got), got) + } + + for i := range tc.want { + if got[i].Label != tc.want[i].Label { + t.Fatalf("link[%d] label: expected %q, got %q", i, tc.want[i].Label, got[i].Label) + } + if got[i].ResolvedPath != tc.want[i].ResolvedPath { + t.Fatalf("link[%d] resolved path: expected %q, got %q", i, tc.want[i].ResolvedPath, got[i].ResolvedPath) + } + if got[i].ResolvedNote != tc.want[i].ResolvedNote { + t.Fatalf("link[%d] resolved note: expected %q, got %q", i, tc.want[i].ResolvedNote, got[i].ResolvedNote) + } + if got[i].Fragment != tc.want[i].Fragment { + t.Fatalf("link[%d] fragment: expected %q, got %q", i, tc.want[i].Fragment, got[i].Fragment) + } + } + }) + } +} + +func absEvalSymlinks(t *testing.T, path string) string { + t.Helper() + abs, err := filepath.Abs(path) + if err != nil { + t.Fatalf("abs %q: %v", path, err) + } + if eval, err := filepath.EvalSymlinks(abs); err == nil { + return eval + } + return abs +} + +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("mkdirall %q: %v", path, err) + } +} + +func mustWriteFile(t *testing.T, path, contents string) { + t.Helper() + mustMkdirAll(t, filepath.Dir(path)) + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("writefile %q: %v", path, err) + } +}