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`) firstH1Pattern = regexp.MustCompile(`(?s)^\s*]*>.*?\s*`) ) 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, titleFromHeading := extractTitle(string(content), "") return r.renderWithTitle(content, title, titleFromHeading) } func (r *Renderer) RenderPath(content []byte, path string) (Result, error) { title, titleFromHeading := extractTitle(string(content), path) return r.renderWithTitle(content, title, titleFromHeading) } func (r *Renderer) renderWithTitle(content []byte, title string, titleFromHeading bool) (Result, error) { 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) } html := output.String() if titleFromHeading { html = firstH1Pattern.ReplaceAllString(html, "") } return Result{ HTML: template.HTML(html), 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, path string) (string, bool) { for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "# ") { return strings.TrimSpace(strings.TrimPrefix(trimmed, "# ")), true } } return titleFromPath(path), false } 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(), "-") } func titleFromPath(path string) string { path = strings.Trim(path, "/") path = strings.TrimSuffix(path, ".md") if path == "" { return "Home" } base := path if strings.HasSuffix(base, "/index") { base = strings.TrimSuffix(base, "/index") } if base == "index" { return "Home" } if idx := strings.LastIndex(base, "/"); idx >= 0 { base = base[idx+1:] } base = strings.ReplaceAll(base, "-", " ") base = strings.ReplaceAll(base, "_", " ") parts := strings.Fields(base) for i, part := range parts { if part == strings.ToUpper(part) { continue } parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:]) } if len(parts) == 0 { return "Home" } return strings.Join(parts, " ") }