Init. "Go documentation server for cs-midi"
This commit is contained in:
commit
7b2e25395d
|
|
@ -0,0 +1 @@
|
||||||
|
docserve
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
title: cs-midi
|
||||||
|
author: jess
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<article>
|
||||||
|
{{.Body}}
|
||||||
|
</article>
|
||||||
|
<nav class="page-nav">
|
||||||
|
{{if .PrevPath}}<a href="{{.PrevPath}}" class="prev">← {{.PrevTitle}}</a>{{end}}
|
||||||
|
{{if .NextPath}}<a href="{{.NextPath}}" class="next">{{.NextTitle}} →</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
|
@ -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}}
|
||||||
Loading…
Reference in New Issue