commit 7b2e25395d18139a822910b85119a819e6724444 Author: pszsh Date: Tue Mar 3 18:13:05 2026 -0800 Init. "Go documentation server for cs-midi" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20244d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +docserve diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2afdce7 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build run clean + +build: + go build -o docserve ./cmd/docserve + +run: build + ./docserve --content ./docs --title "cs-midi" + +clean: + rm -f docserve diff --git a/docs/01-getting-started/_index.md b/docs/01-getting-started/_index.md new file mode 100644 index 0000000..1970a97 --- /dev/null +++ b/docs/01-getting-started/_index.md @@ -0,0 +1,12 @@ +# Getting Started + +cs-midi is a standalone MIDI library for pico-sdk, extracted from [Control Surface](https://github.com/tttapa/Control-Surface). It provides BLE MIDI, Classic BT SPP MIDI, rotary encoders, buttons, potentiometers, LEDs, banks, and a flexible pipe-based routing system. + +## What's included + +- **BLE MIDI** via BTstack (pico-native, no Arduino) +- **Output elements**: NoteButton, CCRotaryEncoder, CCPotentiometer, and more +- **Input elements**: NoteValue, CCValue, PBValue, LEDs +- **Pipe routing**: bidirectional MIDI pipes with filter/transform support +- **Banks**: switch between multiple CC/note mappings +- **Custom hooks**: extend the library without modifying it diff --git a/docs/01-getting-started/installation.md b/docs/01-getting-started/installation.md new file mode 100644 index 0000000..addb68a --- /dev/null +++ b/docs/01-getting-started/installation.md @@ -0,0 +1,33 @@ +# Installation + +## Prerequisites + +- [pico-sdk](https://github.com/raspberrypi/pico-sdk) (v2.0+) +- CMake 3.13+ +- ARM GCC toolchain + +## Adding to your project + +Add cs-midi as a git submodule: + +```bash +git submodule add https://git.else-if.org/jess/cs-midi.git lib/cs-midi +``` + +In your `CMakeLists.txt`: + +```cmake +add_subdirectory(lib/cs-midi) +target_link_libraries(your_target cs_midi) +target_include_directories(cs_midi PRIVATE ${CMAKE_CURRENT_LIST_DIR}) +``` + +The last line is required so cs-midi can find your project's `btstack_config.h` and `lwipopts.h`. + +## Include + +```cpp +#include +``` + +This single header pulls in everything: output elements, input elements, pipes, banks, BLE interface, and the Control Surface singleton. diff --git a/docs/book.yaml b/docs/book.yaml new file mode 100644 index 0000000..f2e7758 --- /dev/null +++ b/docs/book.yaml @@ -0,0 +1,2 @@ +title: cs-midi +author: jess diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..34533f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.else-if.org/jess/cs-midi-docs + +go 1.25.7 + +require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/yuin/goldmark v1.7.16 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/alecthomas/chroma/v2 v2.2.0 // indirect + github.com/dlclark/regexp2 v1.7.0 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1bc5769 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..12d3fbf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,63 @@ +package config + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ContentDir string + Listen string + Title string + Author string + BaseURL string +} + +type bookYAML struct { + Title string `yaml:"title"` + Author string `yaml:"author"` +} + +func Load() (*Config, error) { + c := &Config{} + flag.StringVar(&c.ContentDir, "content", ".", "path to markdown content directory") + flag.StringVar(&c.Listen, "listen", ":8080", "listen address") + flag.StringVar(&c.Title, "title", "", "site title (overrides book.yaml)") + flag.StringVar(&c.BaseURL, "base-url", "", "base URL prefix (e.g. /docs)") + flag.Parse() + + abs, err := filepath.Abs(c.ContentDir) + if err != nil { + return nil, fmt.Errorf("content dir: %w", err) + } + c.ContentDir = abs + + info, err := os.Stat(c.ContentDir) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("content dir %q is not a directory", c.ContentDir) + } + + c.loadBookYAML() + return c, nil +} + +func (c *Config) loadBookYAML() { + data, err := os.ReadFile(filepath.Join(c.ContentDir, "book.yaml")) + if err != nil { + return + } + var b bookYAML + if err := yaml.Unmarshal(data, &b); err != nil { + return + } + if c.Title == "" && b.Title != "" { + c.Title = b.Title + } + if c.Author == "" && b.Author != "" { + c.Author = b.Author + } +} diff --git a/internal/content/tree.go b/internal/content/tree.go new file mode 100644 index 0000000..7ab11ee --- /dev/null +++ b/internal/content/tree.go @@ -0,0 +1,146 @@ +package content + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +type Node struct { + Title string + Path string // URL path relative to content root + FilePath string // absolute filesystem path + Children []*Node + IsDir bool +} + +func BuildTree(root string) (*Node, error) { + tree := &Node{Title: "root", IsDir: true} + if err := buildDir(root, root, tree); err != nil { + return nil, err + } + return tree, nil +} + +func buildDir(base, dir string, parent *Node) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, e := range entries { + name := e.Name() + full := filepath.Join(dir, name) + + if strings.HasPrefix(name, ".") || name == "book.yaml" { + continue + } + + rel, _ := filepath.Rel(base, full) + urlPath := filepath.ToSlash(rel) + + if e.IsDir() { + node := &Node{ + Title: displayName(name), + Path: urlPath, + FilePath: full, + IsDir: true, + } + if err := buildDir(base, full, node); err != nil { + return err + } + if len(node.Children) > 0 || hasIndex(full) { + parent.Children = append(parent.Children, node) + } + } else if strings.HasSuffix(name, ".md") { + urlPath = strings.TrimSuffix(urlPath, ".md") + node := &Node{ + Title: displayName(strings.TrimSuffix(name, ".md")), + Path: urlPath, + FilePath: full, + } + parent.Children = append(parent.Children, node) + } + } + return nil +} + +func hasIndex(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "_index.md")) + return err == nil +} + +func displayName(name string) string { + if name == "_index" { + return "Overview" + } + if len(name) > 3 && name[2] == '-' && + name[0] >= '0' && name[0] <= '9' && + name[1] >= '0' && name[1] <= '9' { + name = name[3:] + } + name = strings.ReplaceAll(name, "-", " ") + if len(name) > 0 { + return strings.ToUpper(name[:1]) + name[1:] + } + return name +} + +func (n *Node) Flatten() []*Node { + var out []*Node + n.flatten(&out) + return out +} + +func (n *Node) flatten(out *[]*Node) { + if !n.IsDir { + *out = append(*out, n) + } + for _, c := range n.Children { + c.flatten(out) + } +} + +func (n *Node) FindByPath(path string) *Node { + if !n.IsDir && n.Path == path { + return n + } + for _, c := range n.Children { + if found := c.FindByPath(path); found != nil { + return found + } + } + return nil +} + +func (n *Node) FindDirByPath(path string) *Node { + if n.IsDir && n.Path == path { + return n + } + for _, c := range n.Children { + if found := c.FindDirByPath(path); found != nil { + return found + } + } + return nil +} + +func (n *Node) IndexPath() string { + if n.IsDir { + return filepath.Join(n.FilePath, "_index.md") + } + return n.FilePath +} + +func (n *Node) FirstPage() *Node { + pages := n.Flatten() + if len(pages) > 0 { + return pages[0] + } + return nil +} diff --git a/internal/content/watcher.go b/internal/content/watcher.go new file mode 100644 index 0000000..65e7fe7 --- /dev/null +++ b/internal/content/watcher.go @@ -0,0 +1,93 @@ +package content + +import ( + "log" + "os" + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" +) + +type Watcher struct { + root string + mu sync.RWMutex + tree *Node + onChange []func() +} + +func NewWatcher(root string) (*Watcher, error) { + tree, err := BuildTree(root) + if err != nil { + return nil, err + } + return &Watcher{root: root, tree: tree}, nil +} + +func (w *Watcher) Tree() *Node { + w.mu.RLock() + defer w.mu.RUnlock() + return w.tree +} + +func (w *Watcher) Rebuild() error { + tree, err := BuildTree(w.root) + if err != nil { + return err + } + w.mu.Lock() + w.tree = tree + w.mu.Unlock() + for _, fn := range w.onChange { + fn() + } + return nil +} + +func (w *Watcher) OnChange(fn func()) { + w.onChange = append(w.onChange, fn) +} + +func (w *Watcher) Start() { + fw, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("fsnotify: %v", err) + return + } + walkAndWatch(fw, w.root) + + go func() { + for { + select { + case ev, ok := <-fw.Events: + if !ok { + return + } + if ev.Has(fsnotify.Create) || ev.Has(fsnotify.Remove) || + ev.Has(fsnotify.Write) || ev.Has(fsnotify.Rename) { + if err := w.Rebuild(); err != nil { + log.Printf("rebuild: %v", err) + } + } + case err, ok := <-fw.Errors: + if !ok { + return + } + log.Printf("fsnotify error: %v", err) + } + } + }() +} + +func walkAndWatch(fw *fsnotify.Watcher, root string) { + filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil + } + if d.Name()[0] == '.' && path != root { + return filepath.SkipDir + } + fw.Add(path) + return nil + }) +} diff --git a/internal/render/book.go b/internal/render/book.go new file mode 100644 index 0000000..e6a357e --- /dev/null +++ b/internal/render/book.go @@ -0,0 +1,33 @@ +package render + +import ( + "bytes" + "fmt" + "os" + + "git.else-if.org/jess/cs-midi-docs/internal/content" +) + +func BookMarkdown(tree *content.Node) ([]byte, error) { + var buf bytes.Buffer + pages := tree.Flatten() + for i, page := range pages { + src, err := os.ReadFile(page.FilePath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", page.FilePath, err) + } + if i > 0 { + buf.WriteString("\n\n---\n\n") + } + buf.Write(src) + } + return buf.Bytes(), nil +} + +func BookHTML(tree *content.Node) ([]byte, error) { + mdBytes, err := BookMarkdown(tree) + if err != nil { + return nil, err + } + return Markdown(mdBytes) +} diff --git a/internal/render/cache.go b/internal/render/cache.go new file mode 100644 index 0000000..433e6a4 --- /dev/null +++ b/internal/render/cache.go @@ -0,0 +1,64 @@ +package render + +import ( + "os" + "sync" + "time" +) + +type cacheEntry struct { + html []byte + modTime time.Time +} + +type Cache struct { + mu sync.RWMutex + items map[string]*cacheEntry +} + +func NewCache() *Cache { + return &Cache{items: make(map[string]*cacheEntry)} +} + +func (c *Cache) Get(filePath string) ([]byte, error) { + info, err := os.Stat(filePath) + if err != nil { + return nil, err + } + + c.mu.RLock() + entry, ok := c.items[filePath] + c.mu.RUnlock() + + if ok && entry.modTime.Equal(info.ModTime()) { + return entry.html, nil + } + + src, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + rendered, err := Markdown(src) + if err != nil { + return nil, err + } + + c.mu.Lock() + c.items[filePath] = &cacheEntry{html: rendered, modTime: info.ModTime()} + c.mu.Unlock() + + return rendered, nil +} + +func (c *Cache) Invalidate(filePath string) { + c.mu.Lock() + delete(c.items, filePath) + c.mu.Unlock() +} + +func (c *Cache) Clear() { + c.mu.Lock() + c.items = make(map[string]*cacheEntry) + c.mu.Unlock() +} diff --git a/internal/render/markdown.go b/internal/render/markdown.go new file mode 100644 index 0000000..76052e6 --- /dev/null +++ b/internal/render/markdown.go @@ -0,0 +1,38 @@ +package render + +import ( + "bytes" + + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +var md goldmark.Markdown + +func init() { + md = goldmark.New( + goldmark.WithExtensions( + extension.GFM, + highlighting.NewHighlighting( + highlighting.WithStyle("monokai"), + ), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + html.WithUnsafe(), + ), + ) +} + +func Markdown(src []byte) ([]byte, error) { + var buf bytes.Buffer + if err := md.Convert(src, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/render/pdf.go b/internal/render/pdf.go new file mode 100644 index 0000000..7cdc30a --- /dev/null +++ b/internal/render/pdf.go @@ -0,0 +1,70 @@ +package render + +import ( + "bytes" + "fmt" + "html/template" + "os/exec" + + "git.else-if.org/jess/cs-midi-docs/internal/content" +) + +var printTmpl = template.Must(template.New("print").Parse(` + + + +{{.Title}} + + +{{.Body}} +`)) + +func GeneratePDF(tree *content.Node, title string) ([]byte, error) { + if _, err := exec.LookPath("weasyprint"); err != nil { + return nil, fmt.Errorf("weasyprint not found: install it for PDF generation") + } + + htmlContent, err := BookHTML(tree) + if err != nil { + return nil, err + } + + var page bytes.Buffer + printTmpl.Execute(&page, struct { + Title string + Body template.HTML + }{title, template.HTML(htmlContent)}) + + cmd := exec.Command("weasyprint", "-", "-") + cmd.Stdin = &page + var out bytes.Buffer + cmd.Stdout = &out + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("weasyprint: %v: %s", err, stderr.String()) + } + return out.Bytes(), nil +} + +func PrintHTML(tree *content.Node, title string) ([]byte, error) { + htmlContent, err := BookHTML(tree) + if err != nil { + return nil, err + } + var page bytes.Buffer + printTmpl.Execute(&page, struct { + Title string + Body template.HTML + }{title, template.HTML(htmlContent)}) + return page.Bytes(), nil +} diff --git a/internal/search/index.go b/internal/search/index.go new file mode 100644 index 0000000..fd30d90 --- /dev/null +++ b/internal/search/index.go @@ -0,0 +1,141 @@ +package search + +import ( + "os" + "strings" + "sync" + "unicode" + + "git.else-if.org/jess/cs-midi-docs/internal/content" +) + +type Result struct { + Title string + Path string + Snippet string +} + +type Index struct { + mu sync.RWMutex + entries []entry +} + +type entry struct { + title string + path string + words []string + raw string +} + +func NewIndex() *Index { + return &Index{} +} + +func (idx *Index) Build(tree *content.Node) { + pages := tree.Flatten() + entries := make([]entry, 0, len(pages)) + for _, p := range pages { + data, err := os.ReadFile(p.FilePath) + if err != nil { + continue + } + text := string(data) + entries = append(entries, entry{ + title: p.Title, + path: p.Path, + words: tokenize(text), + raw: text, + }) + } + idx.mu.Lock() + idx.entries = entries + idx.mu.Unlock() +} + +func (idx *Index) Search(query string, limit int) []Result { + terms := tokenize(query) + if len(terms) == 0 { + return nil + } + + idx.mu.RLock() + defer idx.mu.RUnlock() + + var results []Result + for _, e := range idx.entries { + if !matchAll(e.words, terms) { + continue + } + results = append(results, Result{ + Title: e.title, + Path: e.path, + Snippet: extractSnippet(e.raw, terms[0], 150), + }) + if limit > 0 && len(results) >= limit { + break + } + } + return results +} + +func tokenize(s string) []string { + s = strings.ToLower(s) + var tokens []string + var cur strings.Builder + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { + cur.WriteRune(r) + } else if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + } + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + } + return tokens +} + +func matchAll(words, terms []string) bool { + for _, t := range terms { + found := false + for _, w := range words { + if strings.Contains(w, t) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func extractSnippet(text, term string, maxLen int) string { + lower := strings.ToLower(text) + idx := strings.Index(lower, term) + if idx < 0 { + if len(text) > maxLen { + return text[:maxLen] + "..." + } + return text + } + start := idx - maxLen/2 + if start < 0 { + start = 0 + } + end := start + maxLen + if end > len(text) { + end = len(text) + } + snippet := text[start:end] + snippet = strings.ReplaceAll(snippet, "\n", " ") + if start > 0 { + snippet = "..." + snippet + } + if end < len(text) { + snippet = snippet + "..." + } + return snippet +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..67e19f6 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,232 @@ +package server + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "os" + "strings" + + "git.else-if.org/jess/cs-midi-docs/internal/content" + "git.else-if.org/jess/cs-midi-docs/internal/render" +) + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + tree := s.watcher.Tree() + first := tree.FirstPage() + if first == nil { + http.Error(w, "no pages found", http.StatusNotFound) + return + } + http.Redirect(w, r, "/p/"+first.Path, http.StatusFound) +} + +func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) { + path := r.PathValue("path") + tree := s.watcher.Tree() + + // Check if it's a directory path with _index.md + if dir := tree.FindDirByPath(path); dir != nil { + idxPath := dir.IndexPath() + if _, err := os.Stat(idxPath); err == nil { + html, err := s.cache.Get(idxPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.renderPage(w, dir.Title, template.HTML(html), tree, path) + return + } + // No _index.md, redirect to first child + if first := dir.FirstPage(); first != nil { + http.Redirect(w, r, "/p/"+first.Path, http.StatusFound) + return + } + } + + node := tree.FindByPath(path) + if node == nil { + http.NotFound(w, r) + return + } + + html, err := s.cache.Get(node.FilePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.renderPage(w, node.Title, template.HTML(html), tree, path) +} + +func (s *Server) renderPage(w http.ResponseWriter, title string, body template.HTML, tree *content.Node, currentPath string) { + pages := tree.Flatten() + var prev, next *content.Node + for i, p := range pages { + if p.Path == currentPath { + if i > 0 { + prev = pages[i-1] + } + if i < len(pages)-1 { + next = pages[i+1] + } + break + } + } + + data := PageData{ + Title: title, + SiteTitle: s.cfg.Title, + Body: body, + Nav: s.buildNav(tree, currentPath), + } + if prev != nil { + data.PrevTitle = prev.Title + data.PrevPath = "/p/" + prev.Path + } + if next != nil { + data.NextTitle = next.Title + data.NextPath = "/p/" + next.Path + } + + var buf bytes.Buffer + if err := s.tmpl.page.ExecuteTemplate(&buf, "base.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(buf.Bytes()) +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + results := s.index.Search(query, 50) + + var sr []SearchResult + for _, res := range results { + sr = append(sr, SearchResult{ + Title: res.Title, + Path: "/p/" + res.Path, + Snippet: res.Snippet, + }) + } + + data := SearchData{ + SiteTitle: s.cfg.Title, + Query: query, + Results: sr, + Nav: s.buildNav(s.watcher.Tree(), ""), + } + + var buf bytes.Buffer + if err := s.tmpl.search.ExecuteTemplate(&buf, "base.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(buf.Bytes()) +} + +func (s *Server) handleDownloadPage(w http.ResponseWriter, r *http.Request) { + path := r.PathValue("path") + path = strings.TrimSuffix(path, ".md") + tree := s.watcher.Tree() + node := tree.FindByPath(path) + if node == nil { + http.NotFound(w, r) + return + } + + data, err := os.ReadFile(node.FilePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + filename := node.Title + ".md" + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Write(data) +} + +func (s *Server) handleDownloadBook(w http.ResponseWriter, r *http.Request) { + tree := s.watcher.Tree() + data, err := render.BookMarkdown(tree) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + title := s.cfg.Title + if title == "" { + title = "book" + } + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.md"`, title)) + w.Write(data) +} + +func (s *Server) handleDownloadPDF(w http.ResponseWriter, r *http.Request) { + tree := s.watcher.Tree() + data, err := render.GeneratePDF(tree, s.cfg.Title) + if err != nil { + // Fallback to print HTML + s.handleDownloadPrintHTML(w, r) + return + } + + title := s.cfg.Title + if title == "" { + title = "book" + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.pdf"`, title)) + w.Write(data) +} + +func (s *Server) handleDownloadPrintHTML(w http.ResponseWriter, r *http.Request) { + tree := s.watcher.Tree() + data, err := render.PrintHTML(tree, s.cfg.Title) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +func (s *Server) buildNav(tree *content.Node, currentPath string) template.HTML { + var buf strings.Builder + buf.WriteString(``) + return template.HTML(buf.String()) +} + +func (s *Server) renderNavNode(buf *strings.Builder, node *content.Node, currentPath string) { + if node.IsDir { + buf.WriteString(``) + } else { + active := "" + if node.Path == currentPath { + active = ` class="active"` + } + buf.WriteString(fmt.Sprintf(`%s`, + active, + template.HTMLEscapeString(node.Path), + template.HTMLEscapeString(node.Title))) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..7e03d70 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,20 @@ +package server + +import ( + "embed" + "net/http" +) + +//go:embed static +var staticFS embed.FS + +func (s *Server) routes(mux *http.ServeMux) { + mux.HandleFunc("GET /", s.handleRoot) + mux.HandleFunc("GET /p/{path...}", s.handlePage) + mux.HandleFunc("GET /search", s.handleSearch) + mux.HandleFunc("GET /dl/page/{path...}", s.handleDownloadPage) + mux.HandleFunc("GET /dl/book.md", s.handleDownloadBook) + mux.HandleFunc("GET /dl/book.pdf", s.handleDownloadPDF) + mux.HandleFunc("GET /dl/book.html", s.handleDownloadPrintHTML) + mux.Handle("GET /static/", http.FileServerFS(staticFS)) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e82c1fe --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,58 @@ +package server + +import ( + "log" + "net/http" + + "git.else-if.org/jess/cs-midi-docs/internal/config" + "git.else-if.org/jess/cs-midi-docs/internal/content" + "git.else-if.org/jess/cs-midi-docs/internal/render" + "git.else-if.org/jess/cs-midi-docs/internal/search" +) + +type Server struct { + cfg *config.Config + watcher *content.Watcher + cache *render.Cache + index *search.Index + tmpl *Templates +} + +func New(cfg *config.Config) (*Server, error) { + w, err := content.NewWatcher(cfg.ContentDir) + if err != nil { + return nil, err + } + + idx := search.NewIndex() + idx.Build(w.Tree()) + + cache := render.NewCache() + + w.OnChange(func() { + cache.Clear() + idx.Build(w.Tree()) + log.Println("content reloaded") + }) + w.Start() + + tmpl, err := LoadTemplates() + if err != nil { + return nil, err + } + + return &Server{ + cfg: cfg, + watcher: w, + cache: cache, + index: idx, + tmpl: tmpl, + }, nil +} + +func (s *Server) ListenAndServe() error { + mux := http.NewServeMux() + s.routes(mux) + log.Printf("listening on %s (content: %s)", s.cfg.Listen, s.cfg.ContentDir) + return http.ListenAndServe(s.cfg.Listen, mux) +} diff --git a/internal/server/static/search.js b/internal/server/static/search.js new file mode 100644 index 0000000..cbb563f --- /dev/null +++ b/internal/server/static/search.js @@ -0,0 +1,10 @@ +document.addEventListener('DOMContentLoaded', function() { + var input = document.querySelector('.search-form input'); + if (!input) return; + input.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + input.value = ''; + input.blur(); + } + }); +}); diff --git a/internal/server/static/style.css b/internal/server/static/style.css new file mode 100644 index 0000000..a433b0e --- /dev/null +++ b/internal/server/static/style.css @@ -0,0 +1,207 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #fafafa; + --fg: #1a1a1a; + --sidebar-bg: #f0f0f0; + --sidebar-w: 280px; + --accent: #2563eb; + --border: #ddd; + --code-bg: #f5f5f5; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a2e; + --fg: #e0e0e0; + --sidebar-bg: #16213e; + --accent: #60a5fa; + --border: #333; + --code-bg: #0f3460; + } +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.6; +} + +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-w); + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + bottom: 0; + overflow-y: auto; +} + +.sidebar-header { + padding: 1.5em 1em 1em; + border-bottom: 1px solid var(--border); +} + +.site-title { + display: block; + font-size: 1.2em; + font-weight: 700; + color: var(--fg); + text-decoration: none; + margin-bottom: 0.8em; +} + +.search-form input { + width: 100%; + padding: 0.4em 0.6em; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--fg); + font-size: 0.9em; +} + +.sidebar-nav { + flex: 1; + padding: 1em 0; + overflow-y: auto; +} + +.nav-section { + margin-bottom: 1em; +} + +.nav-heading { + display: block; + padding: 0.3em 1em; + font-size: 0.75em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg); + opacity: 0.6; +} + +.sidebar-nav ul { + list-style: none; +} + +.sidebar-nav li a { + display: block; + padding: 0.3em 1em 0.3em 1.5em; + color: var(--fg); + text-decoration: none; + font-size: 0.9em; + border-left: 3px solid transparent; +} + +.sidebar-nav li a:hover { + background: var(--bg); +} + +.sidebar-nav li.active a { + border-left-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.sidebar-footer { + padding: 1em; + border-top: 1px solid var(--border); + display: flex; + gap: 1em; + font-size: 0.85em; +} + +.sidebar-footer a { + color: var(--accent); + text-decoration: none; +} + +.content { + flex: 1; + margin-left: var(--sidebar-w); + padding: 2em 3em; + max-width: 900px; +} + +.content h1 { font-size: 2em; margin: 0.5em 0; border-bottom: 2px solid var(--border); padding-bottom: 0.3em; } +.content h2 { font-size: 1.5em; margin: 1.5em 0 0.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.2em; } +.content h3 { font-size: 1.2em; margin: 1.2em 0 0.4em; } +.content p { margin: 0.8em 0; } +.content ul, .content ol { margin: 0.8em 0; padding-left: 2em; } +.content li { margin: 0.3em 0; } + +.content pre { + background: var(--code-bg); + padding: 1em; + border-radius: 6px; + overflow-x: auto; + margin: 1em 0; + font-size: 0.9em; +} + +.content code { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.9em; +} + +.content p code, .content li code { + background: var(--code-bg); + padding: 0.15em 0.4em; + border-radius: 3px; +} + +.content table { + border-collapse: collapse; + margin: 1em 0; + width: 100%; +} + +.content th, .content td { + border: 1px solid var(--border); + padding: 0.5em 0.8em; + text-align: left; +} + +.content th { + background: var(--sidebar-bg); + font-weight: 600; +} + +.content a { color: var(--accent); } + +.page-nav { + display: flex; + justify-content: space-between; + margin-top: 3em; + padding-top: 1.5em; + border-top: 1px solid var(--border); +} + +.page-nav a { + color: var(--accent); + text-decoration: none; + font-size: 0.95em; +} + +.page-nav .next { margin-left: auto; } + +.search-results { list-style: none; padding: 0; } +.search-results li { margin: 1.5em 0; } +.search-results a { font-size: 1.1em; font-weight: 600; } +.search-results .snippet { margin-top: 0.3em; color: var(--fg); opacity: 0.7; font-size: 0.9em; } + +@media (max-width: 768px) { + .sidebar { position: static; width: 100%; border-right: none; border-bottom: 1px solid var(--border); } + .content { margin-left: 0; padding: 1.5em; } + .layout { flex-direction: column; } +} diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..75f25e2 --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,55 @@ +package server + +import ( + "embed" + "html/template" +) + +//go:embed templates +var templateFS embed.FS + +type Templates struct { + page *template.Template + search *template.Template +} + +type PageData struct { + Title string + SiteTitle string + Body template.HTML + Nav template.HTML + PrevTitle string + PrevPath string + NextTitle string + NextPath string +} + +type SearchData struct { + Title string + SiteTitle string + Query string + Results []SearchResult + Nav template.HTML +} + +type SearchResult struct { + Title string + Path string + Snippet string +} + +func LoadTemplates() (*Templates, error) { + funcs := template.FuncMap{} + + page, err := template.New("page.html").Funcs(funcs).ParseFS(templateFS, "templates/base.html", "templates/page.html") + if err != nil { + return nil, err + } + + search, err := template.New("search.html").Funcs(funcs).ParseFS(templateFS, "templates/base.html", "templates/search.html") + if err != nil { + return nil, err + } + + return &Templates{page: page, search: search}, nil +} diff --git a/internal/server/templates/base.html b/internal/server/templates/base.html new file mode 100644 index 0000000..175e9bc --- /dev/null +++ b/internal/server/templates/base.html @@ -0,0 +1,30 @@ + + + + + +{{if .Title}}{{.Title}} - {{end}}{{.SiteTitle}} + + + +
+ +
+ {{template "content" .}} +
+
+ + + diff --git a/internal/server/templates/page.html b/internal/server/templates/page.html new file mode 100644 index 0000000..4924724 --- /dev/null +++ b/internal/server/templates/page.html @@ -0,0 +1,9 @@ +{{define "content"}} +
+ {{.Body}} +
+ +{{end}} diff --git a/internal/server/templates/search.html b/internal/server/templates/search.html new file mode 100644 index 0000000..c457ed8 --- /dev/null +++ b/internal/server/templates/search.html @@ -0,0 +1,15 @@ +{{define "content"}} +

Search results for "{{.Query}}"

+{{if .Results}} +
    + {{range .Results}} +
  • + {{.Title}} +

    {{.Snippet}}

    +
  • + {{end}} +
+{{else}} +

No results found.

+{{end}} +{{end}}