feat(release): v0.3.0
commit 533ac4e58256e6520a86af964fcf4c2f9a98d4ba Author: adminoo <git@kadath.corp> Date: Mon Feb 23 18:52:59 2026 +0100 feat: freebsd release tarball generator commit874fb63fd0Author: adminoo <git@kadath.corp> Date: Mon Feb 23 14:05:24 2026 +0100 feat: bump changelog commit46ab7e2911Author: adminoo <git@kadath.corp> Date: Mon Feb 23 13:58:14 2026 +0100 feat: margin and page breaks commit44751a808aAuthor: adminoo <git@kadath.corp> Date: Mon Feb 23 13:57:56 2026 +0100 feat: picture are worth thousand words commita5683428e0Author: adminoo <git@kadath.corp> Date: Mon Feb 23 13:39:00 2026 +0100 feat: navigate individual sections commit0d9b7c4e7bAuthor: adminoo <git@kadath.corp> Date: Mon Feb 23 13:38:19 2026 +0100 feat: make use of vendoring
This commit is contained in:
@ -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")
|
||||
}
|
||||
|
||||
@ -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, "<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 := ¬e.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 := ¬e.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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
/* PRINT MODE */
|
||||
@media print {
|
||||
body {
|
||||
margin: 2cm 1.5cm 2cm 1.5cm;
|
||||
margin: 1cm;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
@ -40,7 +40,8 @@
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
break-after: always;
|
||||
color: black;
|
||||
}
|
||||
@ -49,6 +50,28 @@
|
||||
margin-top: 0;
|
||||
color: black;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
break-after: avoid;
|
||||
page-break-after: avoid;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
li,
|
||||
pre,
|
||||
blockquote,
|
||||
table {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* SCREEN MODE */
|
||||
|
||||
Reference in New Issue
Block a user