Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
259 lines
7.9 KiB
Go
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))
|
|
}
|
|
}
|