diff --git a/.gitignore b/.gitignore index 6a4f2bd..8feed97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ _bin -.#* \ No newline at end of file +.#* +vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..113540b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 0.2.0 +- New section endpoints: `GET /notes/{id}/sections/{sectionID}` +- Level-two headings are now linkable and point to their section pages +- Print CSS improvements (page breaks before headings, avoid splitting blocks, reduced margins) +- Vendoring support for offline builds (`make vendor`) +- Makefile now defaults to `-mod=vendor` for build/test/run (breaking change) +- README now includes a UI screenshot + +## 0.1.0 +- Core web UI for browsing notes +- Tagging system (add/remove, filter, search) +- SQLite-backed storage with tests diff --git a/Makefile b/Makefile index a2c2a86..087cec0 100755 --- a/Makefile +++ b/Makefile @@ -1,18 +1,34 @@ build: mkdir -p _bin - go build -o _bin/donniemarko cmd/main.go + GOFLAGS=-mod=vendor go build -o _bin/donniemarko cmd/main.go install: cp bin/donniemarko ~/.local/bin/ +vendor: + go mod vendor + test: - go test -v -cover ./... + GOFLAGS=-mod=vendor go test -v -cover ./... run: - go run main.go + GOFLAGS=-mod=vendor go run main.go freebsd: mkdir -p _bin - GOOS=freebsd GOARCH=amd64 go build -o _bin/donniemarko-freebsd cmd/main.go + GOOS=freebsd GOARCH=amd64 GOFLAGS=-mod=vendor go build -o _bin/donniemarko-freebsd cmd/main.go + @stage_dir="_bin/freebsd-release"; \ + rm -rf "$$stage_dir"; \ + ver="$$(cat VERSION 2>/dev/null || echo 0.0.0)"; \ + archive_dir="donniemarko-freebsd-$${ver}"; \ + archive_root="$$stage_dir/$$archive_dir"; \ + mkdir -p "$$archive_root/usr/local/bin" \ + "$$archive_root/usr/local/etc/rc.d" \ + "$$archive_root/usr/local/etc/newsyslog.conf.d"; \ + cp _bin/donniemarko-freebsd "$$archive_root/usr/local/bin/donniemarko"; \ + cp packaging/freebsd/donniemarko "$$archive_root/usr/local/etc/rc.d/donniemarko"; \ + cp packaging/freebsd/newsyslog.conf.d/donniemarko "$$archive_root/usr/local/etc/newsyslog.conf.d/donniemarko"; \ + cp packaging/freebsd/release.Makefile "$$archive_root/Makefile"; \ + tar -C "$$stage_dir" -czf "_bin/$${archive_dir}.tar.gz" "$$archive_dir" all: build install diff --git a/README.md b/README.md index 739f8ef..7fafdcc 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,41 @@ Version: 0.2.0 Knowledge Management System over markdown notes. + + +## Release 0.3.0 +- New section endpoints: `GET /notes/{id}/sections/{sectionID}` +- Level-two headings are now linkable and point to their section pages +- Print CSS improvements (page breaks before headings, avoid splitting blocks, reduced margins) +- Vendoring support for offline builds (`make vendor`) +- Makefile now defaults to `-mod=vendor` for build/test/run (breaking change) +- README now includes a UI screenshot + +## Release 0.2.0 +- Group notes by root folders +- Metadatas +- Mobile-friendly CSS +- FreeBSD-specific compile target and scripts +- Logging http requests and errors + ## Release 0.1.0 - Core web UI for browsing notes - Tagging system (add/remove, filter, search) - SQLite-backed storage with tests +## About donniemarko + `donniemarko` works as a read-only (for now) interface over a set of markdown notes. Its goals are: - Ensuring notes intented to be published online are correctly formatted - Rendering the notes in a printable-friendly format, taking advantage of HTML/CSS styling - Providing an interface to aggregate the content of those notes for quickly retrieving bits of information through searching and filtering - Providing an interface to cross-reference those notes through a tagging system, in the same fashion as a blog or a wiki + +## Development + +Vendoring is supported for offline builds. + +Common commands: +- `make vendor` to populate `vendor/` +- `make build` to build using vendored deps +- `make test` to run tests using vendored deps diff --git a/dm_screen.jpg b/dm_screen.jpg new file mode 100644 index 0000000..68e25ed Binary files /dev/null and b/dm_screen.jpg differ diff --git a/internal/note/sections.go b/internal/note/sections.go new file mode 100644 index 0000000..58c36b0 --- /dev/null +++ b/internal/note/sections.go @@ -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 +} diff --git a/internal/note/sections_test.go b/internal/note/sections_test.go new file mode 100644 index 0000000..cf4bf8b --- /dev/null +++ b/internal/note/sections_test.go @@ -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) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index ef3174f..e870ae6 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -43,6 +43,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleTags(w, r) return } + if strings.Contains(path, "/sections/") { + h.handleSections(w, r) + return + } h.handleNotes(w, r) return } @@ -185,8 +189,9 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) { return } - // Convert markdown to HTML - htmlContent, err := render.RenderMarkdown([]byte(note.Content)) + // Convert markdown to HTML, linking section headings + basePath := basePathFromRequest(r) + htmlContent, err := renderNoteMarkdown(note.Content, note.ID, basePath) if err != nil { http.Error(w, "Failed to render markdown", http.StatusInternalServerError) return @@ -197,7 +202,7 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) { state.RenderedNote = htmlContent state.LastActive = hash // 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 { log.Printf("render error: %v", err) @@ -211,7 +216,7 @@ func (h *Handler) setActiveNote(state *ViewState, noteID string) error { return err } - htmlContent, err := render.RenderMarkdown([]byte(note.Content)) + htmlContent, err := renderNoteMarkdown(note.Content, note.ID, state.BasePath) if err != nil { return err } @@ -357,3 +362,92 @@ func parseTagRoute(path string) (noteID string, tag string, isRemove bool) { 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") +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index af424d3..b8218f5 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -443,3 +443,139 @@ func TestGroupNotesByFolder(t *testing.T) { t.Fatalf("unexpected notes group: %+v", groups[1]) } } + +func TestHandlerNotes_SectionLinks(t *testing.T) { + env := newTestEnv(t) + + sectionNote := ¬e.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 := ¬e.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, "