From dd11bdb0c7443b69cb1df3df4574fa6a8024cdb8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 21:57:55 +0200 Subject: [PATCH] feat(11.1-02): add the documentation theme, chroma highlighting and docs:serve - WinterCMS-style shell: header with search and theme toggle, grouped sidebar, on-page TOC, pager, page actions, callouts, heading permalinks, footer and a 404 page - fenced code highlighted at build time by chroma/v2 into tok-* classes, with a copy button; no inline script, style or handler - vendored DM Sans/DM Mono fonts and Lucide icons with their licences - client-side search over search-index.json built with textContent only - summer docs:serve builds into a temp dir, serves on loopback by default, returns 404.html with status 404 and rebuilds on change --- README.md | 2 + cmd/summer/docs.go | 41 + cmd/summer/docs_test.go | 9 + cmd/summer/main.go | 1 + cmd/summer/main_test.go | 3 +- go.mod | 2 + go.sum | 10 + internal/docsite/check_commands.go | 3 +- internal/docsite/docsite_test.go | 2 +- internal/docsite/emit.go | 106 +- internal/docsite/highlight.go | 200 +++ internal/docsite/render.go | 154 ++- internal/docsite/serve.go | 287 +++++ .../docsite/theme/assets/LICENSE-lucide.txt | 43 + .../theme/assets/fonts/LICENSE-dm-mono.txt | 93 ++ .../theme/assets/fonts/LICENSE-dm-sans.txt | 93 ++ .../fonts/dm-mono-latin-400-normal.woff2 | Bin 0 -> 14820 bytes .../fonts/dm-mono-latin-ext-400-normal.woff2 | Bin 0 -> 9552 bytes .../fonts/dm-sans-latin-400-italic.woff2 | Bin 0 -> 15140 bytes .../fonts/dm-sans-latin-400-normal.woff2 | Bin 0 -> 14200 bytes .../fonts/dm-sans-latin-600-normal.woff2 | Bin 0 -> 14144 bytes .../fonts/dm-sans-latin-ext-400-italic.woff2 | Bin 0 -> 8128 bytes .../fonts/dm-sans-latin-ext-400-normal.woff2 | Bin 0 -> 7364 bytes .../fonts/dm-sans-latin-ext-600-normal.woff2 | Bin 0 -> 7420 bytes internal/docsite/theme/assets/search.js | 393 ++++++ internal/docsite/theme/assets/site.css | 1076 +++++++++++++++-- internal/docsite/theme/assets/site.js | 228 ++++ internal/docsite/theme/assets/theme-init.js | 22 + internal/docsite/theme/templates/404.html | 22 + internal/docsite/theme/templates/footer.html | 6 + internal/docsite/theme/templates/header.html | 9 + internal/docsite/theme/templates/icons.html | 16 + internal/docsite/theme/templates/page.html | 72 +- internal/docsite/theme/templates/pager.html | 14 + internal/docsite/theme/templates/search.html | 8 + internal/docsite/theme/templates/sidebar.html | 16 + internal/docsite/theme/templates/toc.html | 24 + internal/docsite/theme_test.go | 295 +++++ scripts/check-phase11.1.sh | 9 + 39 files changed, 3114 insertions(+), 145 deletions(-) create mode 100644 internal/docsite/highlight.go create mode 100644 internal/docsite/serve.go create mode 100644 internal/docsite/theme/assets/LICENSE-lucide.txt create mode 100644 internal/docsite/theme/assets/fonts/LICENSE-dm-mono.txt create mode 100644 internal/docsite/theme/assets/fonts/LICENSE-dm-sans.txt create mode 100644 internal/docsite/theme/assets/fonts/dm-mono-latin-400-normal.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-mono-latin-ext-400-normal.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-sans-latin-400-italic.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-sans-latin-400-normal.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-sans-latin-600-normal.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-sans-latin-ext-400-italic.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-sans-latin-ext-400-normal.woff2 create mode 100644 internal/docsite/theme/assets/fonts/dm-sans-latin-ext-600-normal.woff2 create mode 100644 internal/docsite/theme/assets/search.js create mode 100644 internal/docsite/theme/assets/site.js create mode 100644 internal/docsite/theme/assets/theme-init.js create mode 100644 internal/docsite/theme/templates/404.html create mode 100644 internal/docsite/theme/templates/footer.html create mode 100644 internal/docsite/theme/templates/header.html create mode 100644 internal/docsite/theme/templates/icons.html create mode 100644 internal/docsite/theme/templates/pager.html create mode 100644 internal/docsite/theme/templates/search.html create mode 100644 internal/docsite/theme/templates/sidebar.html create mode 100644 internal/docsite/theme/templates/toc.html create mode 100644 internal/docsite/theme_test.go diff --git a/README.md b/README.md index e397fdc..958e81e 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ npm --prefix admin run gen:api # regenerate TypeScript types from admin/open `summer docs:build` renders `docs/` and every module README into a static site under `site/` (use `--out` for another directory, `--check` to validate without writing). Code blocks with a `src=` reference are copies of real source; after changing that source, run `summer docs:sync` to refresh the copies. +`summer docs:serve` builds the same site into a temporary directory and previews it at `http://127.0.0.1:8088` (`--addr` to change it), rebuilding when the docs, a module or a `src=` source changes; a failed rebuild prints its problems and keeps serving the last good build. It listens only on a loopback address unless you pass `--allow-remote`. Search needs this server: browsers block the search index over `file://`. + ## Design notes Decisions and background live in [`.planning/notes/`](.planning/notes/), including: diff --git a/cmd/summer/docs.go b/cmd/summer/docs.go index 6599a69..b6b3f28 100644 --- a/cmd/summer/docs.go +++ b/cmd/summer/docs.go @@ -3,6 +3,10 @@ package main import ( "context" "errors" + "os" + "os/signal" + "sync" + "syscall" "git.golem15.com/golem15/summercms/internal/docsite" "git.golem15.com/golem15/summercms/modules/backpack" @@ -79,6 +83,43 @@ func docsSyncCommand() bonfire.Command { } } +func docsServeCommand() bonfire.Command { + return bonfire.Command{ + Name: "docs:serve", + Description: "Build the documentation site and preview it on a local address", + Flags: []bonfire.Flag{ + {Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."}, + {Name: "src", Description: "Docs source directory (default /docs)"}, + {Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"}, + {Name: "addr", Description: "Listen address; must be loopback unless --allow-remote", Default: docsite.DefaultServeAddr}, + {Name: "allow-remote", Description: "Allow a non-loopback --addr (serves the docs on the network)", Bare: true}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + addr, _ := in.Flag("addr") + if addr == "" { + addr = docsite.DefaultServeAddr + } + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + return docsite.Serve(ctx, docsOptions(in), addr, flagTrue(in, "allow-remote"), outputWriter{mu: &sync.Mutex{}, out: out}) + }, + } +} + +// outputWriter adapts bonfire.Output to io.Writer for docsite.Serve, which +// writes from its watch goroutine too. +type outputWriter struct { + mu *sync.Mutex + out bonfire.Output +} + +func (w outputWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.out.Printf("%s", p) + return len(p), nil +} + func docsOptions(in bonfire.Input) docsite.Options { opts := docsite.Options{Commands: docsCommands()} opts.Root, _ = in.Flag("root") diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index edd9d42..972fe3c 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -59,6 +59,12 @@ func TestDocsBuildRealTree(t *testing.T) { "setup/installation.html", "setup/installation.md", "llms.txt", "llms-full.txt", "search-index.json", "assets/site.css", docsite.MarkerFile, + "404.html", "assets/site.js", "assets/search.js", "assets/theme-init.js", + "assets/LICENSE-lucide.txt", "assets/fonts/LICENSE-dm-sans.txt", "assets/fonts/LICENSE-dm-mono.txt", + "assets/fonts/dm-sans-latin-400-normal.woff2", "assets/fonts/dm-sans-latin-600-normal.woff2", + "assets/fonts/dm-sans-latin-ext-400-normal.woff2", "assets/fonts/dm-sans-latin-ext-600-normal.woff2", + "assets/fonts/dm-sans-latin-400-italic.woff2", "assets/fonts/dm-sans-latin-ext-400-italic.woff2", + "assets/fonts/dm-mono-latin-400-normal.woff2", "assets/fonts/dm-mono-latin-ext-400-normal.woff2", } { if _, err := os.Stat(filepath.Join(out, name)); err != nil { t.Errorf("missing %s: %v", name, err) @@ -139,6 +145,9 @@ func TestDocsAIOutputsInSync(t *testing.T) { if strings.HasPrefix(rel, "assets/") { return nil } + if rel == "404.html" { + return nil + } switch filepath.Ext(rel) { case ".html": htmlFiles = append(htmlFiles, strings.TrimSuffix(rel, ".html")) diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 83accb0..92858e7 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -48,6 +48,7 @@ func toolCommands() []bonfire.Command { delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"), docsBuildCommand(), docsSyncCommand(), + docsServeCommand(), } } diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 2dcfba5..45fdbf7 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) { for _, c := range toolCommands() { names = append(names, c.Name) } - for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build", "docs:sync"} { + for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build", "docs:sync", "docs:serve"} { if !slices.Contains(names, want) { t.Fatalf("missing %s in %v", want, names) } @@ -36,6 +36,7 @@ func TestToolCommandNames(t *testing.T) { "parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"}, "docs:build": {"--out", "--src", "--root", "--base-url", "--check"}, "docs:sync": {"--src", "--root"}, + "docs:serve": {"--root", "--src", "--base-url", "--addr", "--allow-remote", "127.0.0.1:8088"}, } for cmd, wants := range helpWants { var buf bytes.Buffer diff --git a/go.mod b/go.mod index c2ed882..ef6d3ad 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.27.0 ignore ./admin/node_modules require ( + github.com/alecthomas/chroma/v2 v2.27.0 github.com/disintegration/imaging v1.6.2 github.com/fsnotify/fsnotify v1.10.1 github.com/go-gormigrate/gormigrate/v2 v2.1.7 @@ -48,6 +49,7 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.1 // indirect diff --git a/go.sum b/go.sum index 61c845b..21d6bf4 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,12 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= @@ -92,6 +98,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -149,6 +157,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= diff --git a/internal/docsite/check_commands.go b/internal/docsite/check_commands.go index dbf3428..b6b91a3 100644 --- a/internal/docsite/check_commands.go +++ b/internal/docsite/check_commands.go @@ -26,7 +26,8 @@ type Commands struct { App []string } -// shellLangs are the fence languages whose lines are read as commands. +// shellLangs are the fence languages read as shell sessions: their lines +// are checked as commands and highlighted with prompts. var shellLangs = []string{"sh", "shell", "bash", "console"} // commandToken finds `summer ` and `./bin/ ` at the start diff --git a/internal/docsite/docsite_test.go b/internal/docsite/docsite_test.go index 1cdd11e..5668fbd 100644 --- a/internal/docsite/docsite_test.go +++ b/internal/docsite/docsite_test.go @@ -129,7 +129,7 @@ func TestReadmeIngestion(t *testing.T) { return string(b) } html := read("api/alpha.html") - for _, want := range []string{`href="/api/delta.html#api"`, `href="/setup/start.html"`, `

Usage

`} { + for _, want := range []string{`href="/api/delta.html#api"`, `href="/setup/start.html"`, `

Usage#

`} { if !strings.Contains(html, want) { t.Errorf("api/alpha.html missing %s", want) } diff --git a/internal/docsite/emit.go b/internal/docsite/emit.go index 807fe28..a9d6383 100644 --- a/internal/docsite/emit.go +++ b/internal/docsite/emit.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "html/template" + "io" "io/fs" "path" "strings" @@ -25,6 +26,11 @@ func (s *site) url(p string) string { return s.base + "/" + p } +// writeIcon writes the inline Lucide SVG named name (icons.html). +func writeIcon(w io.Writer, name string) { + _ = pageTmpl.ExecuteTemplate(w, "icon-"+name, nil) +} + type navItem struct { Title string URL string @@ -33,21 +39,62 @@ type navItem struct { type navSection struct { Title string + // API marks the API reference section, whose items are module names + // set in DM Mono. + API bool Items []navItem } +type tocItem struct { + ID string + Text string + Level int +} + +type pagerLink struct { + Title string + URL string + // Section is set only when the target is in another section. + Section string +} + +// pageView is the data of page.html and 404.html. type pageView struct { DocTitle string Title string Description string + Eyebrow string Content template.HTML - CSS string + Assets string HomeURL string + SearchIndex string + LLMS string + LLMSFull string + EditURL string + MarkdownURL string Nav []navSection + TOC []tocItem + Prev, Next *pagerLink +} + +// minTOC is the number of H2/H3 headings a page needs for a TOC. +const minTOC = 2 + +// baseView fills the fields every page shares. +func (s *site) baseView(current *Page) pageView { + return pageView{ + Assets: s.url("assets"), + HomeURL: s.url("index.html"), + SearchIndex: s.url("search-index.json"), + LLMS: s.url("llms.txt"), + LLMSFull: s.url("llms-full.txt"), + Nav: s.nav(current), + } } // render renders every page and fills s.outputs with the HTML pages, their -// Markdown siblings, llms.txt, llms-full.txt, search-index.json and assets. +// Markdown siblings, 404.html, llms.txt, llms-full.txt, search-index.json +// and assets. func (s *site) render() ([]Problem, error) { md := newMarkdown() var problems []Problem @@ -60,17 +107,33 @@ func (s *site) render() ([]Problem, error) { rendered[i] = r } for i, p := range s.pages { - view := pageView{ - DocTitle: p.Title + " · " + s.cfg.Title + " docs", - Title: p.Title, - Description: p.Description, - Content: template.HTML(rendered[i].html), //nolint:gosec // goldmark output in safe mode - CSS: s.url("assets/site.css"), - HomeURL: s.url("index.html"), - Nav: s.nav(p), + view := s.baseView(p) + view.DocTitle = p.Title + " · " + s.cfg.Title + " docs" + view.Title = p.Title + view.Description = p.Description + view.Content = template.HTML(rendered[i].html) //nolint:gosec // goldmark output in safe mode + view.MarkdownURL = s.url(p.URL + ".md") + if s.cfg.EditURL != "" { + view.EditURL = strings.ReplaceAll(s.cfg.EditURL, "{path}", p.Source) } if p.Section == indexSection { view.DocTitle = s.cfg.Title + " documentation" + } else { + view.Eyebrow = s.cfg.sectionTitle(p.Section) + } + for _, h := range rendered[i].headings { + if h.Level == 2 || h.Level == 3 { + view.TOC = append(view.TOC, tocItem{ID: h.ID, Text: h.Text, Level: h.Level}) + } + } + if len(view.TOC) < minTOC { + view.TOC = nil + } + if i > 0 { + view.Prev = s.pagerLink(p, s.pages[i-1]) + } + if i < len(s.pages)-1 { + view.Next = s.pagerLink(p, s.pages[i+1]) } var buf bytes.Buffer if err := pageTmpl.ExecuteTemplate(&buf, "page.html", view); err != nil { @@ -78,6 +141,15 @@ func (s *site) render() ([]Problem, error) { } s.outputs[p.URL+".html"] = buf.Bytes() } + notFound := s.baseView(nil) + notFound.DocTitle = "Page not found · " + s.cfg.Title + " docs" + notFound.Title = "Page not found" + notFound.Description = "This page does not exist or has moved." + var buf bytes.Buffer + if err := pageTmpl.ExecuteTemplate(&buf, "404.html", notFound); err != nil { + return nil, fmt.Errorf("docsite: render 404.html: %w", err) + } + s.outputs["404.html"] = buf.Bytes() bodies := make([]string, len(s.pages)) for i, p := range s.pages { bodies[i] = markdownBody(p, rendered[i].mdLinks) @@ -96,16 +168,26 @@ func (s *site) render() ([]Problem, error) { return problems, nil } +// pagerLink describes a prev/next target; the section line is kept only +// when the target sits in another section (and is not the index page). +func (s *site) pagerLink(from, to *Page) *pagerLink { + l := &pagerLink{Title: to.Title, URL: s.url(to.URL + ".html")} + if to.Section != from.Section && to.Section != indexSection { + l.Section = s.cfg.sectionTitle(to.Section) + } + return l +} + // nav builds the sidebar: every site.yaml section in order with its pages. func (s *site) nav(current *Page) []navSection { var out []navSection for _, sec := range s.cfg.Sections { - ns := navSection{Title: sec.Title} + ns := navSection{Title: sec.Title, API: sec.Name == apiSection} for _, p := range s.pages { if p.Section != sec.Name { continue } - ns.Items = append(ns.Items, navItem{Title: p.Title, URL: s.url(p.URL + ".html"), Current: p == current}) + ns.Items = append(ns.Items, navItem{Title: p.Title, URL: s.url(p.URL + ".html"), Current: current != nil && p == current}) } out = append(out, ns) } diff --git a/internal/docsite/highlight.go b/internal/docsite/highlight.go new file mode 100644 index 0000000..e52dab5 --- /dev/null +++ b/internal/docsite/highlight.go @@ -0,0 +1,200 @@ +package docsite + +import ( + "html" + "slices" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// codeRenderer renders every fenced code block as the UI-SPEC code block: +//
, a
naming the source of a src= fence +// (linked to source_url), a copy button that stays hidden until site.js +// finds a clipboard, and
 highlighted at build time. Tokens
+// come from chroma/v2 lexers and are mapped onto the tok-* classes; the
+// chroma HTML formatter and its styles are not used, so the output has no
+// inline style and only the UI-SPEC class names.
+type codeRenderer struct{}
+
+func (codeRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
+	r.Register(ast.KindFencedCodeBlock, renderFence)
+}
+
+func attrString(n ast.Node, name string) string {
+	v, ok := n.AttributeString(name)
+	if !ok {
+		return ""
+	}
+	b, _ := v.([]byte)
+	return string(b)
+}
+
+func renderFence(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return ast.WalkContinue, nil
+	}
+	n := node.(*ast.FencedCodeBlock)
+	_, _ = w.WriteString(`
`) + if ref := attrString(n, "data-src"); ref != "" { + _, _ = w.WriteString("
") + if href := attrString(n, "data-href"); href != "" { + _, _ = w.WriteString(`` + html.EscapeString(ref) + "") + } else { + _, _ = w.WriteString(html.EscapeString(ref)) + } + _, _ = w.WriteString("
") + } + _, _ = w.WriteString(`
')
+	var code strings.Builder
+	for i := 0; i < n.Lines().Len(); i++ {
+		line := n.Lines().At(i)
+		code.Write(line.Value(src))
+	}
+	_, _ = w.WriteString(highlight(lang, code.String()))
+	_, _ = w.WriteString("
\n") + return ast.WalkSkipChildren, nil +} + +// highlight returns code as escaped HTML with tok-* spans. Unknown +// languages, plain text and lexer errors fall back to escaped text. +func highlight(lang, code string) string { + var b strings.Builder + if slices.Contains(shellLangs, lang) { + highlightShell(&b, code) + return b.String() + } + lexer := lexerFor(lang) + if lexer == nil || !writeTokens(&b, lexer, lang, code) { + return html.EscapeString(code) + } + return b.String() +} + +// lexerFor returns the chroma lexer for a fence language, or nil for +// plain text and unknown languages. +func lexerFor(lang string) chroma.Lexer { + switch lang { + case "", "text", "txt", "plain", "plaintext": + return nil + } + l := lexers.Get(lang) + if l == nil { + return nil + } + return chroma.Coalesce(l) +} + +// writeTokens tokenises code and writes each token as a tok-* span or as +// escaped plain text. It reports false when the lexer fails. +func writeTokens(b *strings.Builder, lexer chroma.Lexer, lang, code string) bool { + it, err := lexer.Tokenise(nil, code) + if err != nil { + return false + } + tokens := it.Tokens() + // Lexers configured with EnsureNL append a newline the source did not + // have; drop it so a shell line fragment stays one line. + if n := len(tokens); n > 0 && !strings.HasSuffix(code, "\n") { + tokens[n-1].Value = strings.TrimSuffix(tokens[n-1].Value, "\n") + } + for _, tok := range tokens { + writeSpan(b, tokClass(tok.Type, lang), tok.Value) + } + return true +} + +func writeSpan(b *strings.Builder, class, value string) { + if value == "" { + return + } + if class == "" { + b.WriteString(html.EscapeString(value)) + return + } + b.WriteString(`` + html.EscapeString(value) + "") +} + +// tokClass maps a chroma token type onto a UI-SPEC syntax class, or "" for +// plain text (identifiers, operators, punctuation). +func tokClass(t chroma.TokenType, lang string) string { + data := lang == "yaml" || lang == "yml" || lang == "json" + switch { + case t == chroma.GenericPrompt: + return "tok-prompt" + case t.InCategory(chroma.Comment): + return "tok-com" + case t == chroma.NameTag || t == chroma.NameAttribute: + return "tok-key" + case t == chroma.KeywordConstant && data: + // YAML and JSON booleans and null. + return "tok-num" + case t.InCategory(chroma.Keyword): + return "tok-kw" + case t.InSubCategory(chroma.LiteralString): + return "tok-str" + case t.InSubCategory(chroma.LiteralNumber): + return "tok-num" + case t == chroma.Literal && data: + // A plain YAML scalar is a string. + return "tok-str" + } + return "" +} + +// highlightShell highlights a shell session line by line: a leading "$ " +// is a tok-prompt (never copied), a "#" line is a comment, the command +// word is tok-kw and the rest goes through the bash lexer. Continuation +// lines (after a trailing "\") have no command word. +func highlightShell(b *strings.Builder, code string) { + lexer := lexerFor("bash") + cont := false + lines := strings.SplitAfter(code, "\n") + for _, line := range lines { + body := strings.TrimSuffix(line, "\n") + nl := len(body) < len(line) + rest := body + if !cont { + trimmed := strings.TrimLeft(body, " \t") + b.WriteString(html.EscapeString(body[:len(body)-len(trimmed)])) + rest = trimmed + if p, ok := strings.CutPrefix(rest, "$ "); ok { + writeSpan(b, "tok-prompt", "$ ") + rest = p + } + if strings.HasPrefix(rest, "#") { + writeSpan(b, "tok-com", rest) + rest = "" + } else if rest != "" { + word, tail, found := strings.Cut(rest, " ") + writeSpan(b, "tok-kw", word) + if found { + b.WriteString(" ") + } + rest = tail + } + } + if rest != "" { + if lexer == nil || !writeTokens(b, lexer, "bash", rest) { + b.WriteString(html.EscapeString(rest)) + } + } + cont = strings.HasSuffix(strings.TrimRight(body, " \t"), `\`) + if nl { + b.WriteString("\n") + } + } +} diff --git a/internal/docsite/render.go b/internal/docsite/render.go index 1c6c71b..28acf43 100644 --- a/internal/docsite/render.go +++ b/internal/docsite/render.go @@ -6,6 +6,7 @@ import ( "fmt" "html" "path" + "regexp" "slices" "strings" "unicode" @@ -33,11 +34,16 @@ func newMarkdown() goldmark.Markdown { util.Prioritized(h1Stripper{}, 100), util.Prioritized(linkRewriter{}, 200), util.Prioritized(fenceAnnotator{}, 300), + util.Prioritized(calloutTransformer{}, 400), ), ), // goldmark registers lower priority values last, so 100 overrides the - // default html renderer (1000) for fenced code blocks. - goldmark.WithRendererOptions(renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 100))), + // default html renderer (1000) for fenced code blocks and headings. + goldmark.WithRendererOptions(renderer.WithNodeRenderers( + util.Prioritized(codeRenderer{}, 100), + util.Prioritized(headingRenderer{}, 100), + util.Prioritized(calloutRenderer{}, 100), + )), ) } @@ -65,49 +71,123 @@ func (fenceAnnotator) Transform(doc *ast.Document, reader text.Reader, pc parser }) } -// codeRenderer renders every fenced code block inside
; -// a src= fence gets a
naming its source, linked to source_url. -type codeRenderer struct{} +// headingRenderer renders headings with their slug ID and, on H2 and H3, +// a permalink after the text. +type headingRenderer struct{} -func (codeRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { - r.Register(ast.KindFencedCodeBlock, renderFence) +func (headingRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { + r.Register(ast.KindHeading, renderHeading) } -func attrString(n ast.Node, name string) string { - v, ok := n.AttributeString(name) - if !ok { - return "" - } - b, _ := v.([]byte) - return string(b) -} - -func renderFence(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { +func renderHeading(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.Heading) + tag := fmt.Sprintf("h%d", n.Level) + id := attrString(n, "id") + if entering { + _, _ = w.WriteString("<" + tag) + if id != "" { + _, _ = w.WriteString(` id="` + html.EscapeString(id) + `"`) + } + _ = w.WriteByte('>') return ast.WalkContinue, nil } - n := node.(*ast.FencedCodeBlock) - _, _ = w.WriteString(`
`) - if ref := attrString(n, "data-src"); ref != "" { - _, _ = w.WriteString("
") - if href := attrString(n, "data-href"); href != "" { - _, _ = w.WriteString(`` + html.EscapeString(ref) + "") - } else { - _, _ = w.WriteString(html.EscapeString(ref)) + if id != "" && (n.Level == 2 || n.Level == 3) { + _, _ = w.WriteString(`#`) + } + _, _ = w.WriteString("\n") + return ast.WalkContinue, nil +} + +// kindCallout is the AST node kind of a > [!NOTE], [!TIP] or [!WARNING] +// blockquote. +var kindCallout = ast.NewNodeKind("Callout") + +// calloutNode holds the blocks of a callout; CalloutType is NOTE, TIP or WARNING. +type calloutNode struct { + ast.BaseBlock + CalloutType string +} + +func (n *calloutNode) Kind() ast.NodeKind { return kindCallout } + +func (n *calloutNode) Dump(src []byte, level int) { + ast.DumpHelper(n, src, level, map[string]string{"CalloutType": n.CalloutType}, nil) +} + +// calloutTransformer turns a blockquote whose first line is exactly +// [!NOTE], [!TIP] or [!WARNING] into a callout without that line. Other +// types stay blockquotes; checkPolicy reports them. +type calloutTransformer struct{} + +func (calloutTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { + src := reader.Source() + var quotes []*ast.Blockquote + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if bq, ok := n.(*ast.Blockquote); ok && entering { + quotes = append(quotes, bq) } - _, _ = w.WriteString("
") + return ast.WalkContinue, nil + }) + for _, bq := range quotes { + para, ok := bq.FirstChild().(*ast.Paragraph) + if !ok || para.Lines().Len() == 0 { + continue + } + first := para.Lines().At(0) + m := calloutLine.FindStringSubmatch(strings.TrimSpace(string(first.Value(src)))) + if m == nil || !slices.Contains(calloutTypes, m[1]) { + continue + } + for c := para.FirstChild(); c != nil; { + next := c.NextSibling() + t, ok := c.(*ast.Text) + if !ok || t.Segment.Start >= first.Stop { + break + } + para.RemoveChild(para, c) + c = next + } + if para.ChildCount() == 0 { + bq.RemoveChild(bq, para) + } + callout := &calloutNode{CalloutType: m[1]} + for c := bq.FirstChild(); c != nil; { + next := c.NextSibling() + callout.AppendChild(callout, c) + c = next + } + bq.Parent().ReplaceChild(bq.Parent(), bq, callout) } - _, _ = w.WriteString("

+// with its icon and title.
+type calloutRenderer struct{}
+
+func (calloutRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
+	r.Register(kindCallout, renderCallout)
+}
+
+var calloutMeta = map[string]struct{ class, icon, title string }{
+	"NOTE":    {"callout-note", "info", "Note"},
+	"TIP":     {"callout-tip", "lightbulb", "Tip"},
+	"WARNING": {"callout-warning", "triangle-alert", "Warning"},
+}
+
+func renderCallout(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	meta := calloutMeta[node.(*calloutNode).CalloutType]
+	if !entering {
+		_, _ = w.WriteString("\n")
+		return ast.WalkContinue, nil
 	}
-	_ = w.WriteByte('>')
-	for i := 0; i < n.Lines().Len(); i++ {
-		line := n.Lines().At(i)
-		_, _ = w.WriteString(html.EscapeString(string(line.Value(src))))
-	}
-	_, _ = w.WriteString("
\n") - return ast.WalkSkipChildren, nil + _, _ = w.WriteString(`