package markdown import ( "bytes" "fmt" "html/template" "regexp" "slices" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" ) var ( tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`) ) type Result struct { HTML template.HTML Title string Tags []string } type Renderer struct { markdown goldmark.Markdown } func NewRenderer() *Renderer { return &Renderer{ markdown: goldmark.New( goldmark.WithExtensions( extension.GFM, extension.Footnote, extension.DefinitionList, extension.Table, extension.Strikethrough, extension.TaskList, extension.Typographer, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithHardWraps(), html.WithXHTML(), ), ), } } func (r *Renderer) Render(content []byte) (Result, error) { title := extractTitle(string(content)) tags := extractTags(string(content)) prepared := preprocess(string(content)) var output bytes.Buffer if err := r.markdown.Convert([]byte(prepared), &output); err != nil { return Result{}, fmt.Errorf("render markdown: %w", err) } return Result{ HTML: template.HTML(output.String()), Title: title, Tags: tags, }, nil } func preprocess(input string) string { lines := strings.Split(input, "\n") for index, line := range lines { line = rewriteWikiLinks(line) line = normalizeAdmonition(line) lines[index] = line } return strings.Join(lines, "\n") } func rewriteWikiLinks(line string) string { var builder strings.Builder remaining := line for { start := strings.Index(remaining, "[[") if start == -1 { builder.WriteString(remaining) return builder.String() } end := strings.Index(remaining[start+2:], "]]") if end == -1 { builder.WriteString(remaining) return builder.String() } builder.WriteString(remaining[:start]) label := strings.TrimSpace(remaining[start+2 : start+2+end]) if label == "" { builder.WriteString(remaining[start : start+2+end+2]) } else { builder.WriteString(fmt.Sprintf("[%s](/docs/%s)", label, slugify(label))) } remaining = remaining[start+2+end+2:] } } func normalizeAdmonition(line string) string { switch { case strings.HasPrefix(line, "> [!NOTE]"): return strings.Replace(line, "> [!NOTE]", "> **Note:**", 1) case strings.HasPrefix(line, "> [!TIP]"): return strings.Replace(line, "> [!TIP]", "> **Tip:**", 1) case strings.HasPrefix(line, "> [!WARNING]"): return strings.Replace(line, "> [!WARNING]", "> **Warning:**", 1) default: return line } } func extractTitle(content string) string { for _, line := range strings.Split(content, "\n") { if strings.HasPrefix(line, "# ") { return strings.TrimSpace(strings.TrimPrefix(line, "# ")) } } return "Untitled" } func extractTags(content string) []string { matches := tagPattern.FindAllStringSubmatch(content, -1) tags := make([]string, 0, len(matches)) seen := make(map[string]struct{}, len(matches)) for _, match := range matches { if len(match) < 3 { continue } tag := match[2] if _, exists := seen[tag]; exists { continue } seen[tag] = struct{}{} tags = append(tags, tag) } slices.Sort(tags) return tags } func slugify(value string) string { value = strings.ToLower(strings.TrimSpace(value)) var builder strings.Builder lastDash := false for _, r := range value { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': builder.WriteRune(r) lastDash = false default: if !lastDash { builder.WriteByte('-') lastDash = true } } } return strings.Trim(builder.String(), "-") }