feat: bootstrap foundation application
This commit is contained in:
168
apps/server/internal/markdown/renderer.go
Normal file
168
apps/server/internal/markdown/renderer.go
Normal file
@@ -0,0 +1,168 @@
|
||||
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(), "-")
|
||||
}
|
||||
23
apps/server/internal/markdown/renderer_test.go
Normal file
23
apps/server/internal/markdown/renderer_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderRewritesWikiLinksAndTags(t *testing.T) {
|
||||
renderer := NewRenderer()
|
||||
|
||||
result, err := renderer.Render([]byte("# Title\n\nLink to [[Project Plan]] and #alpha #beta.\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(result.HTML), `/docs/project-plan`) {
|
||||
t.Fatalf("expected wiki-link rewrite, got %s", result.HTML)
|
||||
}
|
||||
|
||||
if len(result.Tags) != 2 || result.Tags[0] != "alpha" || result.Tags[1] != "beta" {
|
||||
t.Fatalf("unexpected tags: %#v", result.Tags)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user