Files
llmgw/internal/web/docs.go
T
Ilker ManapandClaude Opus 5.5 bd439aca29 A docs portal at /docs and a client developer's guide
The manual the binary carries, rendered on the client port: a sidebar of
the documents in the console's reading order (now one list, llmgw.Manual,
for both), heading anchors, links between documents kept in the portal and
links to files outside the binary sent to the repository, no script.
Public by default; landing.docs_sign_in sends visitors to sign in first.

docs/clients.md shows the official OpenAI and Anthropic SDKs against
llmgw in Python, TypeScript, Go and curl, with llmgw's headers; the
portal's index, the landing page and the account page link to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:46:42 +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, "docs", d)
return
}
for i := range docs {
if docs[i].Key == key {
d.Doc = &docs[i]
render(w, "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))
}
}