Files
MarkdownToIHKChemnits/markdown_parser.go

223 lines
5.8 KiB
Go

package main
import (
"bytes"
"compress/zlib"
"crypto/sha256"
"encoding/base64"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"gopkg.in/yaml.v3"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
content, err := os.ReadFile(mdPath)
if err != nil {
return Config{}, nil, nil, err
}
md := goldmark.New(
goldmark.WithExtensions(meta.Meta, extension.Table),
)
context := parser.NewContext()
doc := md.Parser().Parse(text.NewReader(content), parser.WithContext(context))
metaData := meta.Get(context)
// Convert metaData map to Config struct
var config Config
yamlData, _ := yaml.Marshal(metaData)
err = yaml.Unmarshal(yamlData, &config)
if err != nil {
return Config{}, nil, nil, fmt.Errorf("error parsing metadata: %v", err)
}
return config, doc, content, nil
}
type parserState struct {
nextCodeIsAppendix bool
appendixTitle string
}
func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
r.StartFrontMatter()
state := &parserState{}
return ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Heading:
if node.Level == 1 && r.numType == NumRoman {
title := extractText(node, content)
if title != "Vorwort" && title != "Abkürzungsverzeichnis" {
r.StartMainBody()
}
}
title := extractText(node, content)
r.RenderHeader(node.Level, title)
return ast.WalkSkipChildren, nil
case *ast.Paragraph:
text := extractText(node, content)
lines := strings.Split(text, "\n")
isMeta := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "@Quelle:") {
r.AddSource(strings.TrimPrefix(line, "@Quelle:"))
isMeta = true
} else if strings.HasPrefix(line, "@Anhang:") {
r.AddAppendix(strings.TrimPrefix(line, "@Anhang:"))
isMeta = true
} else if strings.HasPrefix(line, "@AnhangUML:") {
state.nextCodeIsAppendix = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
isMeta = true
}
}
if isMeta {
return ast.WalkSkipChildren, nil
}
r.RenderParagraph(text)
return ast.WalkSkipChildren, nil
case *ast.FencedCodeBlock:
lang := string(node.Language(content))
code := extractCode(node, content)
if lang == "mermaid" || lang == "plantuml" || lang == "puml" {
imgPath, err := RenderDiagramViaKroki(lang, code)
if err == nil {
if state.nextCodeIsAppendix {
r.AddAppendix(state.appendixTitle + " | " + imgPath)
state.nextCodeIsAppendix = false
} else {
r.RenderImage(imgPath, "Diagramm: "+lang)
}
return ast.WalkSkipChildren, nil
}
}
case *ast.Image:
imgPath := string(node.Destination)
title := string(node.Title)
r.RenderImage(imgPath, title)
return ast.WalkSkipChildren, nil
case *ast.Blockquote:
// Check if first paragraph starts with "Quelle:"
first := node.FirstChild()
if first != nil {
if para, ok := first.(*ast.Paragraph); ok {
pText := extractText(para, content)
if strings.HasPrefix(pText, "Quelle:") || strings.HasPrefix(pText, "Source:") {
sourceText := strings.TrimPrefix(pText, "Quelle:")
sourceText = strings.TrimPrefix(sourceText, "Source:")
r.AddSource(sourceText)
return ast.WalkSkipChildren, nil
}
}
}
case *ast.List:
// Items will be handled by ListItem
case *ast.ListItem:
text := extractText(node, content)
r.RenderListItem(text, true, 0) // Basic bullet point for now
return ast.WalkSkipChildren, nil
case *extast.Table:
var tableData [][]string
for row := node.FirstChild(); row != nil; row = row.NextSibling() {
var rowData []string
for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() {
rowData = append(rowData, extractText(cell, content))
}
tableData = append(tableData, rowData)
}
r.RenderTable(tableData)
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})
}
func extractText(n ast.Node, content []byte) string {
var textStr string
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if textNode, ok := child.(*ast.Text); ok {
textStr += string(textNode.Segment.Value(content))
if textNode.HardLineBreak() || textNode.SoftLineBreak() {
textStr += "\n"
}
} else {
textStr += extractText(child, content)
}
}
if textStr == "" {
// Fallback for simple nodes
return string(n.Text(content))
}
return textStr
}
func extractCode(n *ast.FencedCodeBlock, content []byte) string {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(content))
}
return buf.String()
}
func RenderDiagramViaKroki(lang string, code string) (string, error) {
if lang == "puml" {
lang = "plantuml"
}
// Kroki encoding: zlib + base64url
var b bytes.Buffer
w := zlib.NewWriter(&b)
w.Write([]byte(code))
w.Close()
encoded := base64.URLEncoding.EncodeToString(b.Bytes())
url := fmt.Sprintf("https://kroki.io/%s/png/%s", lang, encoded)
// Cache based on hash
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
cachePath := filepath.Join(os.TempDir(), "ihk_cache_"+hash+".png")
if _, err := os.Stat(cachePath); err == nil {
return cachePath, nil
}
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("kroki error: %d", resp.StatusCode)
}
out, err := os.Create(cachePath)
if err != nil {
return "", err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return cachePath, err
}