Files
akvorado/console/docs.go
Vincent Bernat 17b1eeea90
Some checks failed
CI / 🤖 Check dependabot status (push) Has been cancelled
CI / 🐧 Test on Linux (${{ github.ref_type == 'tag' }}, misc) (push) Has been cancelled
CI / 🐧 Test on Linux (coverage) (push) Has been cancelled
CI / 🐧 Test on Linux (regular) (push) Has been cancelled
CI / ❄️ Build on Nix (push) Has been cancelled
CI / 🍏 Build and test on macOS (push) Has been cancelled
CI / 🧪 End-to-end testing (push) Has been cancelled
CI / 🔍 Upload code coverage (push) Has been cancelled
CI / 🔬 Test only Go (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 20) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 22) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 24) (push) Has been cancelled
CI / ⚖️ Check licenses (push) Has been cancelled
CI / 🐋 Build Docker images (push) Has been cancelled
CI / 🐋 Tag Docker images (push) Has been cancelled
CI / 🚀 Publish release (push) Has been cancelled
console: use strings.Builder when working with strings
This is faster than bytes.Buffer.
2025-10-12 08:08:47 +02:00

204 lines
5.0 KiB
Go

// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
package console
import (
"fmt"
"io"
"io/fs"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"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"
)
var (
internalLinkRegexp = regexp.MustCompile("^(([0-9]+)-([a-z]+).md)(#.*|$)")
)
// Header describes a document header.
type Header struct {
Level int `json:"level"`
ID string `json:"id"`
Title string `json:"title"`
}
// DocumentTOC describes the TOC of a document
type DocumentTOC struct {
Name string `json:"name"`
Headers []Header `json:"headers"`
}
func (c *Component) docsHandlerFunc(gc *gin.Context) {
docs := c.embedOrLiveFS("data/docs")
requestedDocument := gc.Param("name")
var markdown []byte
toc := []DocumentTOC{}
// Find right file and compute ToC
entries, err := fs.ReadDir(docs, ".")
if err != nil {
c.r.Err(err).Msg("unable to list documentation files")
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Unable to get documentation files."})
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
matches := internalLinkRegexp.FindStringSubmatch(entry.Name())
if matches == nil {
continue
}
f, err := http.FS(docs).Open(entry.Name())
if err != nil {
c.r.Err(err).Str("path", entry.Name()).Msg("unable to open documentation file")
continue
}
// Markdown rendering to build ToC
content, _ := io.ReadAll(f)
f.Close()
if matches[3] == requestedDocument {
// That's the one we need to do final rendering on.
markdown = content
}
tocLogger := &tocLogger{}
md := goldmark.New(
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithASTTransformers(
util.Prioritized(tocLogger, 500),
),
),
)
var buf strings.Builder
if err = md.Convert(content, &buf); err != nil {
c.r.Err(err).Str("path", entry.Name()).Msg("unable to render markdown document")
continue
}
toc = append(toc, DocumentTOC{
Name: matches[3],
Headers: tocLogger.headers,
})
}
if markdown == nil {
gc.JSON(http.StatusNotFound, gin.H{"message": "Document not found."})
return
}
md := goldmark.New(
goldmark.WithExtensions(
extension.Footnote,
extension.Typographer,
highlighting.NewHighlighting(
highlighting.WithCustomStyle(draculaStyle),
),
&admonitionExtension{},
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithASTTransformers(
util.Prioritized(&internalLinkTransformer{}, 500),
util.Prioritized(&imageLinkTransformer{docs}, 500),
),
),
)
var buf strings.Builder
if err = md.Convert(markdown, &buf); err != nil {
c.r.Err(err).Str("path", requestedDocument).Msg("unable to render markdown document")
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Unable to render document."})
return
}
gc.Header("Cache-Control", "max-age=300, public")
gc.PureJSON(http.StatusOK, gin.H{
"markdown": buf.String(),
"toc": toc,
})
}
type internalLinkTransformer struct{}
func (r *internalLinkTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) {
replaceLinks := func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Link:
matches := internalLinkRegexp.FindStringSubmatch(string(node.Destination))
if matches != nil {
node.Destination = []byte(fmt.Sprintf("%s%s", matches[3], matches[4]))
}
}
return ast.WalkContinue, nil
}
ast.Walk(node, replaceLinks)
}
type imageLinkTransformer struct {
root fs.FS
}
func (r *imageLinkTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) {
replaceLinks := func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Image:
path := string(node.Destination)
if !strings.Contains(path, "/") {
node.Destination = []byte(fmt.Sprintf("../assets/docs/%s", path))
}
}
return ast.WalkContinue, nil
}
ast.Walk(node, replaceLinks)
}
type tocLogger struct {
headers []Header
}
func (r *tocLogger) Transform(node *ast.Document, reader text.Reader, _ parser.Context) {
r.headers = []Header{}
logHeaders := func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Heading:
id, ok := n.AttributeString("id")
if ok {
var title []byte
lastIndex := node.Lines().Len() - 1
if lastIndex > -1 {
lastLine := node.Lines().At(lastIndex)
title = lastLine.Value(reader.Source())
}
if title != nil {
r.headers = append(r.headers, Header{
ID: string(id.([]uint8)),
Level: node.Level,
Title: string(title),
})
}
}
}
return ast.WalkContinue, nil
}
ast.Walk(node, logHeaders)
}