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 }