Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2359933f1b | |||
| 5fdcede6f8 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
_bin
|
_bin
|
||||||
.#*
|
.#*
|
||||||
|
vendor/
|
||||||
|
|||||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Release 0.3.1
|
||||||
|
- Metadata fix only
|
||||||
|
|
||||||
|
## 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
|
||||||
24
Makefile
24
Makefile
@ -1,18 +1,34 @@
|
|||||||
build:
|
build:
|
||||||
mkdir -p _bin
|
mkdir -p _bin
|
||||||
go build -o _bin/donniemarko cmd/main.go
|
GOFLAGS=-mod=vendor go build -o _bin/donniemarko cmd/main.go
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cp bin/donniemarko ~/.local/bin/
|
cp bin/donniemarko ~/.local/bin/
|
||||||
|
|
||||||
|
vendor:
|
||||||
|
go mod vendor
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -cover ./...
|
GOFLAGS=-mod=vendor go test -v -cover ./...
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run main.go
|
GOFLAGS=-mod=vendor go run main.go
|
||||||
|
|
||||||
freebsd:
|
freebsd:
|
||||||
mkdir -p _bin
|
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
|
all: build install
|
||||||
|
|||||||
16
README.md
16
README.md
@ -4,13 +4,21 @@ Version: 0.2.0
|
|||||||
|
|
||||||
Knowledge Management System over markdown notes.
|
Knowledge Management System over markdown notes.
|
||||||
|
|
||||||
## Release 0.1.0
|

|
||||||
- Core web UI for browsing notes
|
|
||||||
- Tagging system (add/remove, filter, search)
|
## About donniemarko
|
||||||
- SQLite-backed storage with tests
|
|
||||||
|
|
||||||
`donniemarko` works as a read-only (for now) interface over a set of markdown notes. Its goals are:
|
`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
|
- Ensuring notes intented to be published online are correctly formatted
|
||||||
- Rendering the notes in a printable-friendly format, taking advantage of HTML/CSS styling
|
- 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 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
|
- 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
|
||||||
|
|||||||
BIN
dm_screen.jpg
Normal file
BIN
dm_screen.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
103
internal/note/sections.go
Normal file
103
internal/note/sections.go
Normal 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
|
||||||
|
}
|
||||||
69
internal/note/sections_test.go
Normal file
69
internal/note/sections_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@ -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 := ¬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 */
|
/* PRINT MODE */
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
body {
|
||||||
margin: 2cm 1.5cm 2cm 1.5cm;
|
margin: 1cm;
|
||||||
background: white;
|
background: white;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@ -40,7 +40,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
break-after: always;
|
break-after: always;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@ -49,6 +50,28 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: black;
|
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 */
|
/* SCREEN MODE */
|
||||||
|
|||||||
12
packaging/freebsd/release.Makefile
Normal file
12
packaging/freebsd/release.Makefile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
|
BIN_DIR := $(PREFIX)/bin
|
||||||
|
ETC_DIR := $(PREFIX)/etc
|
||||||
|
|
||||||
|
install:
|
||||||
|
install -d $(DESTDIR)$(BIN_DIR)
|
||||||
|
install -m 755 usr/local/bin/donniemarko $(DESTDIR)$(BIN_DIR)/donniemarko
|
||||||
|
install -d $(DESTDIR)$(ETC_DIR)/rc.d
|
||||||
|
install -m 755 usr/local/etc/rc.d/donniemarko $(DESTDIR)$(ETC_DIR)/rc.d/donniemarko
|
||||||
|
install -d $(DESTDIR)$(ETC_DIR)/newsyslog.conf.d
|
||||||
|
install -m 644 usr/local/etc/newsyslog.conf.d/donniemarko $(DESTDIR)$(ETC_DIR)/newsyslog.conf.d/donniemarko
|
||||||
Reference in New Issue
Block a user