Files
llmgw/internal/web/docs.go
2026-10-05 00:15:25 +02:00

259 lines
7.9 KiB
Go

package web
import (
"bytes"
"html/template"
"net/http"
"net/url"
"path"
"strings"
"sync"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
llmgw "gitea.mynodes.xyz/ilker/llmgw"
)
// The docs portal: the manual the binary carries, the pages `llmgw docs` and
// the console show, rendered as a website at /docs on the client port.
//
// Public by default, like the landing page and for the same reason: the
// person who needs the client guide is the one who does not have an account
// yet. The operator documents describe how llmgw is deployed in general, not
// this deployment — no address, no model, no key — so publishing them tells a
// stranger what the project's own repository already does. A site that would
// rather not say even that sets landing.docs_sign_in.
//
// Only the embedded documents are rendered, never anything a visitor sent, and
// in goldmark's safe mode all the same: raw HTML in a document is dropped
// rather than trusted, so a page here can never carry a script for the
// Content-Security-Policy to have to refuse.
// docLink is one entry of the portal's sidebar.
type docLink struct{ Key, Title string }
// docSection is a second-level heading, offered under the open document.
type docSection struct{ ID, Title string }
// renderedDoc is a document turned into HTML once, when first asked for.
type renderedDoc struct {
docLink
Sections []docSection
Body template.HTML
}
// docsData is what the docs template is rendered with.
type docsData struct {
Version string
// Doc is the open document; nil on the index.
Doc *renderedDoc
Docs []docLink
// Clients is the client developer's guide, which the index leads with.
Clients *docLink
// Nav is the menu of whoever is signed in, and SignedIn whether anyone is.
Nav Nav
SignedIn bool
// SignIn offers a sign-in link to someone who is not.
SignIn bool
}
// manualOnce renders every document the first time the portal is opened:
// the configuration reference alone is two thousand lines, and rendering it
// again on every request would be work for nothing — the documents change
// only when the binary does.
var (
manualOnce sync.Once
manualDocs []renderedDoc
)
func manual() []renderedDoc {
manualOnce.Do(func() {
docs := llmgw.Manual()
paths := map[string]string{}
for _, d := range docs {
paths[d.Path] = d.Key
}
for _, d := range docs {
body, sections := renderDoc(d.Source(), d.Path, paths)
manualDocs = append(manualDocs, renderedDoc{
docLink: docLink{Key: d.Key, Title: d.Title}, Sections: sections, Body: body,
})
}
})
return manualDocs
}
// renderDoc turns one embedded document into HTML. from is its path in the
// embed, which its relative links are resolved against; paths are the paths
// of every document the portal has, by key.
func renderDoc(src, from string, paths map[string]string) (template.HTML, []docSection) {
t := &docTransform{from: from, paths: paths}
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
// The same ids GitHub gives headings, which is what the documents'
// own #links were written against.
parser.WithAutoHeadingID(),
parser.WithASTTransformers(util.Prioritized(t, 100))),
)
var out bytes.Buffer
if err := md.Convert([]byte(src), &out); err != nil {
return template.HTML("<p>This document could not be rendered.</p>"), nil
}
// goldmark in its default, safe mode over documents compiled into the
// binary, with links rewritten below: not something to escape again.
return template.HTML(out.String()), t.sections //nolint:gosec
}
// docTransform rewrites a document's links for the portal, gives every
// heading a link to itself, and collects the sections for the sidebar.
type docTransform struct {
from string
paths map[string]string
sections []docSection
}
func (t *docTransform) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) {
src := reader.Source()
var headings []*ast.Heading
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n := n.(type) {
case *ast.Link:
n.Destination = []byte(t.rewrite(string(n.Destination)))
case *ast.Heading:
headings = append(headings, n)
}
return ast.WalkContinue, nil
})
// After the walk, so the links added here are not themselves rewritten.
for _, h := range headings {
id, ok := h.AttributeString("id")
if !ok {
continue
}
idStr := string(id.([]byte))
if h.Level == 2 {
t.sections = append(t.sections, docSection{ID: idStr, Title: plainText(h, src)})
}
a := ast.NewLink()
a.Destination = []byte("#" + idStr)
a.Title = []byte("Link to this section")
a.SetAttributeString("class", []byte("anchor"))
a.AppendChild(a, ast.NewString([]byte("#")))
h.AppendChild(h, a)
}
}
// rewrite points a link at where its target is served from.
//
// A link to another document becomes its page under /docs, keeping the
// section it names. A link to anything else in the repository — deploy/,
// the backlog, the design plan — is not in the binary, so it goes to the
// repository itself rather than to a 404 here. Absolute links and links
// within the page are left alone.
func (t *docTransform) rewrite(dest string) string {
u, err := url.Parse(dest)
if err != nil || u.Scheme != "" || u.Host != "" || u.Path == "" || strings.HasPrefix(u.Path, "/") {
return dest
}
target := path.Clean(path.Join(path.Dir(t.from), u.Path))
frag := ""
if u.Fragment != "" {
frag = "#" + u.EscapedFragment()
}
if key, ok := t.paths[target]; ok {
return "/docs/" + key + frag
}
if strings.HasPrefix(target, "../") {
return dest // outside the repository: nothing better to point it at
}
if strings.HasSuffix(u.Path, "/") {
target += "/"
}
return repoURL + "/src/branch/main/" + target + frag
}
// plainText is a heading's words without their markup: "`name`" is "name".
func plainText(n ast.Node, src []byte) string {
var b strings.Builder
_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch c := c.(type) {
case *ast.Text:
b.Write(c.Segment.Value(src))
if c.SoftLineBreak() {
b.WriteByte(' ')
}
case *ast.String:
b.Write(c.Value)
}
return ast.WalkContinue, nil
})
return b.String()
}
// Docs serves the portal: the index at /docs and a document at
// /docs/{doc}. signIn is whether this gateway has accounts to sign in to,
// which decides whether the page offers it.
func Docs(version string, signIn bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
docs := manual()
d := docsData{Version: version, SignIn: signIn}
if n, ok := r.Context().Value(navKey{}).(Nav); ok {
n.Current = "docs"
d.Nav, d.SignedIn = n, true
}
for i := range docs {
d.Docs = append(d.Docs, docs[i].docLink)
if docs[i].Key == "clients" {
d.Clients = &docs[i].docLink
}
}
key := strings.TrimSuffix(strings.ToLower(r.PathValue("doc")), ".md")
if key == "" {
render(w, r, "docs", d)
return
}
for i := range docs {
if docs[i].Key == key {
d.Doc = &docs[i]
render(w, r, "docs", d)
return
}
}
http.NotFound(w, r)
}
}
// DocsPage serves the docs portal with the menu of whoever is signed in and,
// when required is set, to nobody else: someone not signed in is sent to sign
// in, as every other page that needs a session does.
func (p *AccountPages) DocsPage(docs http.HandlerFunc, required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, user, ok := p.session(r)
if !ok {
if required {
noStore(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
docs(w, r)
return
}
// The page carries this person's menu, so no cache may keep it for
// the next.
noStore(w)
docs(w, p.withNav(r, user))
}
}