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