Init. "Go documentation server for cs-midi"

This commit is contained in:
pszsh 2026-03-03 18:13:05 -08:00
commit 7b2e25395d
24 changed files with 1387 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
docserve

10
Makefile Normal file
View File

@ -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

View File

@ -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

View File

@ -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 <cs_midi.h>
```
This single header pulls in everything: output elements, input elements, pipes, banks, BLE interface, and the Control Surface singleton.

2
docs/book.yaml Normal file
View File

@ -0,0 +1,2 @@
title: cs-midi
author: jess

16
go.mod Normal file
View File

@ -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
)

29
go.sum Normal file
View File

@ -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=

63
internal/config/config.go Normal file
View File

@ -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
}
}

146
internal/content/tree.go Normal file
View File

@ -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
}

View File

@ -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
})
}

33
internal/render/book.go Normal file
View File

@ -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)
}

64
internal/render/cache.go Normal file
View File

@ -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()
}

View File

@ -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
}

70
internal/render/pdf.go Normal file
View File

@ -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(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2em; color: #1a1a1a; line-height: 1.6; }
h1 { border-bottom: 2px solid #333; padding-bottom: 0.3em; }
h2 { border-bottom: 1px solid #ccc; padding-bottom: 0.2em; }
pre { background: #f5f5f5; padding: 1em; overflow-x: auto; border-radius: 4px; }
code { font-size: 0.9em; }
hr { margin: 3em 0; border: none; border-top: 1px solid #ccc; }
@media print { body { max-width: none; } pre { white-space: pre-wrap; } }
</style>
</head>
<body>{{.Body}}</body>
</html>`))
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
}

141
internal/search/index.go Normal file
View File

@ -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
}

232
internal/server/handlers.go Normal file
View File

@ -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(`<nav class="sidebar-nav">`)
for _, child := range tree.Children {
s.renderNavNode(&buf, child, currentPath)
}
buf.WriteString(`</nav>`)
return template.HTML(buf.String())
}
func (s *Server) renderNavNode(buf *strings.Builder, node *content.Node, currentPath string) {
if node.IsDir {
buf.WriteString(`<div class="nav-section">`)
buf.WriteString(fmt.Sprintf(`<span class="nav-heading">%s</span>`, template.HTMLEscapeString(node.Title)))
buf.WriteString(`<ul>`)
for _, child := range node.Children {
s.renderNavNode(buf, child, currentPath)
}
buf.WriteString(`</ul></div>`)
} else {
active := ""
if node.Path == currentPath {
active = ` class="active"`
}
buf.WriteString(fmt.Sprintf(`<li%s><a href="/p/%s">%s</a></li>`,
active,
template.HTMLEscapeString(node.Path),
template.HTMLEscapeString(node.Title)))
}
}

20
internal/server/routes.go Normal file
View File

@ -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))
}

58
internal/server/server.go Normal file
View File

@ -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)
}

View File

@ -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();
}
});
});

View File

@ -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; }
}

View File

@ -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
}

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} - {{end}}{{.SiteTitle}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<a href="/" class="site-title">{{.SiteTitle}}</a>
<form action="/search" method="get" class="search-form">
<input type="search" name="q" placeholder="Search...">
</form>
</div>
{{.Nav}}
<div class="sidebar-footer">
<a href="/dl/book.md">Download .md</a>
<a href="/dl/book.pdf">Download PDF</a>
</div>
</aside>
<main class="content">
{{template "content" .}}
</main>
</div>
<script src="/static/search.js"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
{{define "content"}}
<article>
{{.Body}}
</article>
<nav class="page-nav">
{{if .PrevPath}}<a href="{{.PrevPath}}" class="prev">&larr; {{.PrevTitle}}</a>{{end}}
{{if .NextPath}}<a href="{{.NextPath}}" class="next">{{.NextTitle}} &rarr;</a>{{end}}
</nav>
{{end}}

View File

@ -0,0 +1,15 @@
{{define "content"}}
<h1>Search results for "{{.Query}}"</h1>
{{if .Results}}
<ul class="search-results">
{{range .Results}}
<li>
<a href="{{.Path}}">{{.Title}}</a>
<p class="snippet">{{.Snippet}}</p>
</li>
{{end}}
</ul>
{{else}}
<p>No results found.</p>
{{end}}
{{end}}