// 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) }