feat: navigate individual sections

This commit is contained in:
2026-02-23 13:39:00 +01:00
parent 0d9b7c4e7b
commit a5683428e0
4 changed files with 406 additions and 4 deletions

103
internal/note/sections.go Normal file
View File

@ -0,0 +1,103 @@
package note
import (
"strconv"
"strings"
"unicode"
)
type Section struct {
ID string
Heading string
Content string
}
// ParseH2Heading returns the heading text for a level-two markdown heading.
func ParseH2Heading(line string) (string, bool) {
trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(trimmed, "## ") {
return "", false
}
return strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")), true
}
// ParseSections splits markdown into level-two heading sections.
func ParseSections(content string) []Section {
if content == "" {
return nil
}
lines := strings.Split(content, "\n")
var sections []Section
var current *Section
var builder strings.Builder
counts := make(map[string]int)
flush := func() {
if current == nil {
return
}
current.Content = builder.String()
sections = append(sections, *current)
current = nil
builder.Reset()
}
for i, line := range lines {
heading, ok := ParseH2Heading(line)
if ok {
flush()
base := slugifyHeading(heading)
if base == "" {
base = "section"
}
counts[base]++
id := base
if counts[base] > 1 {
id = base + "-" + strconv.Itoa(counts[base])
}
current = &Section{
ID: id,
Heading: heading,
}
}
if current != nil {
builder.WriteString(line)
if i < len(lines)-1 {
builder.WriteString("\n")
}
}
}
flush()
return sections
}
func slugifyHeading(input string) string {
in := strings.TrimSpace(strings.ToLower(input))
if in == "" {
return ""
}
var b strings.Builder
prevDash := false
for _, r := range in {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
prevDash = false
continue
}
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
out := b.String()
out = strings.Trim(out, "-")
return out
}

View File

@ -0,0 +1,69 @@
package note
import "testing"
func TestSlugifyHeading(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{name: "simple", input: "Glossary", want: "glossary"},
{name: "date and title", input: "2026-01-01 - First", want: "2026-01-01-first"},
{name: "punctuation", input: "Hello, World!", want: "hello-world"},
{name: "trim", input: " spaced out ", want: "spaced-out"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := slugifyHeading(tc.input); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestParseSections(t *testing.T) {
content := "# Title\nIntro\n## Alpha\nA1\nA2\n## Beta\nB1\n"
sections := ParseSections(content)
if len(sections) != 2 {
t.Fatalf("expected 2 sections, got %d", len(sections))
}
if sections[0].Heading != "Alpha" {
t.Fatalf("expected first heading Alpha, got %q", sections[0].Heading)
}
if sections[0].ID != "alpha" {
t.Fatalf("expected first id alpha, got %q", sections[0].ID)
}
if want := "## Alpha\nA1\nA2\n"; sections[0].Content != want {
t.Fatalf("expected first content %q, got %q", want, sections[0].Content)
}
if sections[1].Heading != "Beta" {
t.Fatalf("expected second heading Beta, got %q", sections[1].Heading)
}
if sections[1].ID != "beta" {
t.Fatalf("expected second id beta, got %q", sections[1].ID)
}
if want := "## Beta\nB1\n"; sections[1].Content != want {
t.Fatalf("expected second content %q, got %q", want, sections[1].Content)
}
}
func TestParseSections_DuplicateHeadings(t *testing.T) {
content := "## Glossary\nTerm A\n## Glossary\nTerm B\n"
sections := ParseSections(content)
if len(sections) != 2 {
t.Fatalf("expected 2 sections, got %d", len(sections))
}
if sections[0].ID != "glossary" {
t.Fatalf("expected first id glossary, got %q", sections[0].ID)
}
if sections[1].ID != "glossary-2" {
t.Fatalf("expected second id glossary-2, got %q", sections[1].ID)
}
}

View File

@ -43,6 +43,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleTags(w, r) h.handleTags(w, r)
return return
} }
if strings.Contains(path, "/sections/") {
h.handleSections(w, r)
return
}
h.handleNotes(w, r) h.handleNotes(w, r)
return return
} }
@ -185,8 +189,9 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
return return
} }
// Convert markdown to HTML // Convert markdown to HTML, linking section headings
htmlContent, err := render.RenderMarkdown([]byte(note.Content)) basePath := basePathFromRequest(r)
htmlContent, err := renderNoteMarkdown(note.Content, note.ID, basePath)
if err != nil { if err != nil {
http.Error(w, "Failed to render markdown", http.StatusInternalServerError) http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
return return
@ -197,7 +202,7 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
state.RenderedNote = htmlContent state.RenderedNote = htmlContent
state.LastActive = hash state.LastActive = hash
// Ensure note view carries proxy prefix for links/forms. // Ensure note view carries proxy prefix for links/forms.
state.BasePath = basePathFromRequest(r) state.BasePath = basePath
if err := h.templates.Render(w, "index", state); err != nil { if err := h.templates.Render(w, "index", state); err != nil {
log.Printf("render error: %v", err) log.Printf("render error: %v", err)
@ -211,7 +216,7 @@ func (h *Handler) setActiveNote(state *ViewState, noteID string) error {
return err return err
} }
htmlContent, err := render.RenderMarkdown([]byte(note.Content)) htmlContent, err := renderNoteMarkdown(note.Content, note.ID, state.BasePath)
if err != nil { if err != nil {
return err return err
} }
@ -357,3 +362,92 @@ func parseTagRoute(path string) (noteID string, tag string, isRemove bool) {
return noteID, "", false return noteID, "", false
} }
func parseSectionRoute(path string) (noteID string, sectionID string) {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 4 || parts[0] != "notes" || parts[2] != "sections" {
return "", ""
}
return parts[1], parts[3]
}
func (h *Handler) handleSections(w http.ResponseWriter, r *http.Request) {
// Build base state
state, err := h.buildViewState(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
noteID, sectionID := parseSectionRoute(r.URL.Path)
if noteID == "" || sectionID == "" {
http.NotFound(w, r)
return
}
n, err := h.notesService.GetNoteByHash(noteID)
if err != nil {
http.Error(w, "Note not found", http.StatusNotFound)
return
}
var sectionContent string
for _, section := range note.ParseSections(n.Content) {
if section.ID == sectionID {
sectionContent = section.Content
break
}
}
if sectionContent == "" {
http.Error(w, "Section not found", http.StatusNotFound)
return
}
basePath := basePathFromRequest(r)
htmlContent, err := render.RenderMarkdown([]byte(sectionContent))
if err != nil {
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
return
}
state.Note = n
state.RenderedNote = htmlContent
state.LastActive = noteID
state.BasePath = basePath
if err := h.templates.Render(w, "index", state); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Render error", http.StatusInternalServerError)
}
}
func renderNoteMarkdown(content, noteID, basePath string) (template.HTML, error) {
linked := linkifySectionsMarkdown(content, noteID, basePath)
return render.RenderMarkdown([]byte(linked))
}
func linkifySectionsMarkdown(content, noteID, basePath string) string {
sections := note.ParseSections(content)
if len(sections) == 0 {
return content
}
lines := strings.Split(content, "\n")
sectionIdx := 0
for i, line := range lines {
heading, ok := note.ParseH2Heading(line)
if !ok {
continue
}
if sectionIdx >= len(sections) {
break
}
link := basePath + "/notes/" + noteID + "/sections/" + sections[sectionIdx].ID
lines[i] = "## [" + heading + "](" + link + ")"
sectionIdx++
}
return strings.Join(lines, "\n")
}

View File

@ -443,3 +443,139 @@ func TestGroupNotesByFolder(t *testing.T) {
t.Fatalf("unexpected notes group: %+v", groups[1]) t.Fatalf("unexpected notes group: %+v", groups[1])
} }
} }
func TestHandlerNotes_SectionLinks(t *testing.T) {
env := newTestEnv(t)
sectionNote := &note.Note{
ID: "s1",
Title: "Sections",
Content: "# Sections\nIntro\n## 2026-01-01 - First\nFirst body\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
Path: "notes/sections.md",
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
}
if err := env.storage.Create(sectionNote); err != nil {
t.Fatalf("create note: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/notes/s1", nil)
rec := httptest.NewRecorder()
env.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `href="/notes/s1/sections/2026-01-01-first"`) {
t.Fatalf("expected link for first section")
}
if !strings.Contains(body, `href="/notes/s1/sections/glossary"`) {
t.Fatalf("expected link for glossary section")
}
if !strings.Contains(body, `href="/notes/s1/sections/glossary-2"`) {
t.Fatalf("expected link for duplicate glossary section")
}
}
func TestHandlerNotes_SectionRoute(t *testing.T) {
env := newTestEnv(t)
sectionNote := &note.Note{
ID: "s1",
Title: "Sections",
Content: "# Sections\nIntro\n## 2026-01-01 - First\nFirst body\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
Path: "notes/sections.md",
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
}
if err := env.storage.Create(sectionNote); err != nil {
t.Fatalf("create note: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/glossary", nil)
rec := httptest.NewRecorder()
env.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "<h2>Glossary</h2>") {
t.Fatalf("expected glossary heading to be rendered")
}
if !strings.Contains(body, "Term A") {
t.Fatalf("expected first glossary content")
}
if strings.Contains(body, "First body") {
t.Fatalf("expected other section content to be excluded")
}
if strings.Contains(body, "Term B") {
t.Fatalf("expected second glossary content to be excluded")
}
}
func TestHandlerNotes_SectionRoute_Duplicate(t *testing.T) {
env := newTestEnv(t)
sectionNote := &note.Note{
ID: "s1",
Title: "Sections",
Content: "# Sections\nIntro\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
Path: "notes/sections.md",
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
}
if err := env.storage.Create(sectionNote); err != nil {
t.Fatalf("create note: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/glossary-2", nil)
rec := httptest.NewRecorder()
env.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "<h2>Glossary</h2>") {
t.Fatalf("expected glossary heading to be rendered")
}
if !strings.Contains(body, "Term B") {
t.Fatalf("expected second glossary content")
}
if strings.Contains(body, "Term A") {
t.Fatalf("expected first glossary content to be excluded")
}
}
func TestHandlerNotes_SectionRoute_NotFound(t *testing.T) {
env := newTestEnv(t)
sectionNote := &note.Note{
ID: "s1",
Title: "Sections",
Content: "# Sections\n## Alpha\nA\n",
Path: "notes/sections.md",
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
}
if err := env.storage.Create(sectionNote); err != nil {
t.Fatalf("create note: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/missing", nil)
rec := httptest.NewRecorder()
env.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
}