Eric Bower
·
2026-10-01
1package shared
2
3import (
4 "bytes"
5 "fmt"
6 "strings"
7 "time"
8
9 "github.com/alecthomas/chroma/v2/formatters/html"
10 "github.com/araddon/dateparse"
11 "github.com/microcosm-cc/bluemonday"
12 "github.com/yuin/goldmark"
13 highlighting "github.com/yuin/goldmark-highlighting/v2"
14 meta "github.com/yuin/goldmark-meta"
15 "github.com/yuin/goldmark/ast"
16 "github.com/yuin/goldmark/extension"
17 "github.com/yuin/goldmark/parser"
18 ghtml "github.com/yuin/goldmark/renderer/html"
19 gtext "github.com/yuin/goldmark/text"
20 "go.abhg.dev/goldmark/anchor"
21 "go.abhg.dev/goldmark/hashtag"
22 "go.abhg.dev/goldmark/toc"
23 yaml "gopkg.in/yaml.v2"
24)
25
26type Link struct {
27 URL string
28 Text string
29}
30
31type MetaData struct {
32 PublishAt *time.Time
33 Title string
34 Description string
35 Nav []Link
36 Tags []string
37 Aliases []string
38 Image string
39 ImageCard string
40 Favicon string
41 Hidden bool
42 Domain string
43}
44
45type ParsedText struct {
46 Html string
47 *MetaData
48}
49
50func HtmlPolicy() *bluemonday.Policy {
51 policy := bluemonday.UGCPolicy()
52 policy.AllowStyling()
53 policy.AllowAttrs("rel").OnElements("a")
54 return policy
55}
56
57var policy = HtmlPolicy()
58
59func toString(obj interface{}) (string, error) {
60 if obj == nil {
61 return "", nil
62 }
63 switch val := obj.(type) {
64 case string:
65 return val, nil
66 default:
67 return "", fmt.Errorf("incorrect type for value: %T, should be string", val)
68 }
69}
70
71func toBool(obj interface{}, fallback bool) (bool, error) {
72 if obj == nil {
73 return fallback, nil
74 }
75 switch val := obj.(type) {
76 case bool:
77 return val, nil
78 default:
79 return false, fmt.Errorf("incorrect type for value: %T, should be bool", val)
80 }
81}
82
83// The toc frontmatter can take a boolean or an integer.
84//
85// A value of -1 or false means "do not generate a toc".
86// A value of 0 or true means "generate a toc with no depth limit".
87// A value of >0 means "generate a toc with a depth limit of $value past title".
88func toToc(obj interface{}) (int, error) {
89 if obj == nil {
90 return -1, nil
91 }
92 switch val := obj.(type) {
93 case bool:
94 if val {
95 return 0, nil
96 }
97 return -1, nil
98 case int:
99 if val < -1 {
100 val = -1
101 }
102 return val, nil
103 default:
104 return -1, fmt.Errorf("incorrect type for value: %T, should be bool or int", val)
105 }
106}
107
108func toLinks(orderedMetaData yaml.MapSlice) ([]Link, error) {
109 var navData interface{}
110 for i := 0; i < len(orderedMetaData); i++ {
111 var item = orderedMetaData[i]
112 if item.Key == "nav" {
113 navData = item.Value
114 break
115 }
116 }
117
118 links := []Link{}
119 if navData == nil {
120 return links, nil
121 }
122
123 addLinks := func(raw yaml.MapSlice) {
124 for _, k := range raw {
125 links = append(links, Link{
126 Text: k.Key.(string),
127 URL: k.Value.(string),
128 })
129 }
130 }
131
132 switch raw := navData.(type) {
133 case yaml.MapSlice:
134 addLinks(raw)
135 case []interface{}:
136 for _, v := range raw {
137 switch linkRaw := v.(type) {
138 case yaml.MapSlice:
139 addLinks(v.(yaml.MapSlice))
140 default:
141 return links, fmt.Errorf("unsupported type for `nav` link item (%T), looking for map (`text: href`)", linkRaw)
142 }
143 }
144 default:
145 return links, fmt.Errorf("unsupported type for `nav` variable: %T", raw)
146 }
147
148 return links, nil
149}
150
151func toAliases(obj interface{}) ([]string, error) {
152 arr := make([]string, 0)
153 if obj == nil {
154 return arr, nil
155 }
156
157 switch raw := obj.(type) {
158 case []interface{}:
159 for _, alias := range raw {
160 als := strings.TrimSpace(alias.(string))
161 arr = append(arr, strings.TrimPrefix(als, "/"))
162 }
163 case string:
164 aliases := strings.Split(raw, " ")
165 for _, alias := range aliases {
166 als := strings.TrimSpace(alias)
167 arr = append(arr, strings.TrimPrefix(als, "/"))
168 }
169 default:
170 return arr, fmt.Errorf("unsupported type for `aliases` variable: %T", raw)
171 }
172
173 return arr, nil
174}
175
176func toTags(obj interface{}) ([]string, error) {
177 arr := make([]string, 0)
178 if obj == nil {
179 return arr, nil
180 }
181
182 switch raw := obj.(type) {
183 case []interface{}:
184 for _, tag := range raw {
185 arr = append(arr, tag.(string))
186 }
187 case string:
188 tags := strings.Split(raw, " ")
189 for _, tag := range tags {
190 arr = append(arr, strings.TrimSpace(tag))
191 }
192 default:
193 return arr, fmt.Errorf("unsupported type for `tags` variable: %T", raw)
194 }
195
196 return arr, nil
197}
198
199func CreateGoldmark(extenders ...goldmark.Extender) goldmark.Markdown {
200 return goldmark.New(
201 goldmark.WithExtensions(
202 extenders...,
203 ),
204 goldmark.WithParserOptions(
205 parser.WithAutoHeadingID(),
206 ),
207 goldmark.WithRendererOptions(
208 ghtml.WithUnsafe(),
209 ),
210 )
211}
212
213func ParseText(text string) (*ParsedText, error) {
214 parsed := ParsedText{
215 MetaData: &MetaData{
216 Tags: []string{},
217 Aliases: []string{},
218 PublishAt: &time.Time{},
219 },
220 }
221 hili := highlighting.NewHighlighting(
222 highlighting.WithFormatOptions(
223 html.WithLineNumbers(true),
224 html.WithClasses(true),
225 ),
226 )
227 extenders := []goldmark.Extender{
228 extension.GFM,
229 extension.Footnote,
230 meta.Meta,
231 &hashtag.Extender{},
232 hili,
233 &anchor.Extender{
234 Position: anchor.After,
235 Texter: anchor.Text("#"),
236 },
237 }
238 md := CreateGoldmark(extenders...)
239 context := parser.NewContext()
240 // we do the Parse/Render steps manually to get a chance to examine the AST
241 btext := []byte(text)
242 doc := md.Parser().Parse(gtext.NewReader(btext), parser.WithContext(context))
243 metaData := meta.Get(context)
244
245 // title:
246 // 1. if specified in frontmatter, use that
247 title, err := toString(metaData["title"])
248 if err != nil {
249 return &parsed, fmt.Errorf("front-matter field (%s): %w", "title", err)
250 }
251 // 2. If an <h1> is found before a <p> or other heading is found, use that
252 if title == "" {
253 title = AstTitle(doc, btext, true)
254 }
255 // 3. else, set it to nothing (slug should get used later down the line)
256 // this is implicit since it's already ""
257 parsed.Title = title
258
259 // only handle toc after the title is extracted (if it's getting extracted)
260 mtoc, err := toToc(metaData["toc"])
261 if err != nil {
262 return &parsed, fmt.Errorf("front-matter field (%s): %w", "toc", err)
263 }
264 if mtoc >= 0 {
265 err = AstToc(doc, btext, mtoc)
266 if err != nil {
267 return &parsed, fmt.Errorf("error generating toc: %w", err)
268 }
269 }
270
271 description, err := toString(metaData["description"])
272 if err != nil {
273 return &parsed, fmt.Errorf("front-matter field (%s): %w", "description", err)
274 }
275 parsed.Description = description
276
277 domain, err := toString(metaData["domain"])
278 if err != nil {
279 return &parsed, fmt.Errorf("front-matter field (%s): %w", "domain", err)
280 }
281 parsed.Domain = domain
282
283 image, err := toString(metaData["image"])
284 if err != nil {
285 return &parsed, fmt.Errorf("front-matter field (%s): %w", "image", err)
286 }
287 parsed.Image = image
288
289 card, err := toString(metaData["card"])
290 if err != nil {
291 return &parsed, fmt.Errorf("front-matter field (%s): %w", "card", err)
292 }
293 parsed.ImageCard = card
294
295 hidden, err := toBool(metaData["draft"], false)
296 if err != nil {
297 return &parsed, fmt.Errorf("front-matter field (%s): %w", "draft", err)
298 }
299 parsed.Hidden = hidden
300
301 favicon, err := toString(metaData["favicon"])
302 if err != nil {
303 return &parsed, fmt.Errorf("front-matter field (%s): %w", "favicon", err)
304 }
305 parsed.Favicon = favicon
306
307 publishAt := &time.Time{}
308 date, err := toString(metaData["date"])
309 if err != nil {
310 return &parsed, fmt.Errorf("front-matter field (%s): %w", "date", err)
311 }
312
313 if date != "" {
314 nextDate, err := dateparse.ParseStrict(date)
315 if err != nil {
316 return &parsed, err
317 }
318 publishAt = &nextDate
319 }
320 parsed.PublishAt = publishAt
321
322 orderedMetaData := meta.GetItems(context)
323
324 nav, err := toLinks(orderedMetaData)
325 if err != nil {
326 return &parsed, err
327 }
328 parsed.Nav = nav
329
330 aliases, err := toAliases(metaData["aliases"])
331 if err != nil {
332 return &parsed, err
333 }
334 parsed.Aliases = aliases
335
336 rtags := metaData["tags"]
337 tags, err := toTags(rtags)
338 if err != nil {
339 return &parsed, err
340 }
341 // fill from hashtag ASTs as fallback
342 if rtags == nil {
343 tags = AstTags(doc)
344 }
345 parsed.Tags = tags
346
347 // Rendering happens last to allow any of the previous steps to manipulate
348 // the AST.
349 var buf bytes.Buffer
350 if err := md.Renderer().Render(&buf, btext, doc); err != nil {
351 return &parsed, err
352 }
353 parsed.Html = policy.Sanitize(buf.String())
354
355 return &parsed, nil
356}
357
358func AstTags(doc ast.Node) []string {
359 var tags []string
360 err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
361 switch n.Kind() {
362 // ignore hashtags inside of these sections
363 case ast.KindBlockquote, ast.KindCodeBlock, ast.KindCodeSpan:
364 return ast.WalkSkipChildren, nil
365 // register hashtags
366 case hashtag.Kind:
367 t := n.(*hashtag.Node)
368 if entering { // only add each tag once
369 tags = append(tags, string(t.Tag))
370 }
371 }
372 // out-of-switch default
373 return ast.WalkContinue, nil
374 })
375 if err != nil {
376 panic(err) // unreachable
377 }
378
379 // sort and deduplicate results
380 dedupe := removeDuplicateStr(tags)
381 return dedupe
382}
383
384// https://stackoverflow.com/a/66751055
385func removeDuplicateStr(strSlice []string) []string {
386 allKeys := make(map[string]bool)
387 list := []string{}
388 for _, item := range strSlice {
389 if _, value := allKeys[item]; !value {
390 allKeys[item] = true
391 list = append(list, item)
392 }
393 }
394 return list
395}
396
397// AstTitle extracts the title (if any) from a parsed markdown document.
398//
399// If "clean" is true, it will also remove the heading node from the AST.
400func AstTitle(doc ast.Node, src []byte, clean bool) string {
401 out := ""
402 err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
403 if n.Kind() == ast.KindHeading {
404 if h := n.(*ast.Heading); h.Level == 1 {
405 if clean {
406 p := h.Parent()
407 p.RemoveChild(p, n)
408 }
409 out = string(h.Lines().Value(src))
410 }
411 return ast.WalkStop, nil
412 }
413 if ast.IsParagraph(n) {
414 return ast.WalkStop, nil
415 }
416 return ast.WalkContinue, nil
417 })
418 if err != nil {
419 panic(err) // unreachable
420 }
421 return out
422}
423
424func AstToc(doc ast.Node, src []byte, mtoc int) error {
425 var tree *toc.TOC
426 if mtoc >= 0 {
427 var err error
428 if mtoc > 0 {
429 tree, err = toc.Inspect(doc, src, toc.Compact(true), toc.MinDepth(2), toc.MaxDepth(mtoc+1))
430 } else {
431 tree, err = toc.Inspect(doc, src, toc.Compact(true), toc.MinDepth(2))
432 }
433 if err != nil {
434 return err
435 }
436 if tree == nil {
437 return nil // no headings?
438 }
439 }
440 list := toc.RenderList(tree)
441 if list == nil {
442 return nil // no headings
443 }
444
445 list.SetAttributeString("id", []byte("toc-list"))
446
447 // generate # toc
448 heading := ast.NewHeading(2)
449 heading.SetAttributeString("id", []byte("toc"))
450 heading.AppendChild(heading, ast.NewString([]byte("Table of Contents")))
451
452 // insert
453 doc.InsertBefore(doc, doc.FirstChild(), list)
454 doc.InsertBefore(doc, doc.FirstChild(), heading)
455 return nil
456}