main pico / pkg / shared / mdparser.go
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}