Monorepo for Tangled tangled.org

appview/pages: markup: add `@` user-mention parsing in markdown

add custom styling rule for mention links (no underline unless hover)

Signed-off-by: Seongmin Lee <boltlessengineer@proton.me>

Changed files
+126 -1
appview
pages
+111
appview/pages/markup/extension/atlink.go
··· 1 + // heavily inspired by: https://github.com/kaleocheng/goldmark-extensions 2 + 3 + package extension 4 + 5 + import ( 6 + "regexp" 7 + 8 + "github.com/yuin/goldmark" 9 + "github.com/yuin/goldmark/ast" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer" 12 + "github.com/yuin/goldmark/renderer/html" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // An AtNode struct represents an AtNode 18 + type AtNode struct { 19 + handle string 20 + ast.BaseInline 21 + } 22 + 23 + var _ ast.Node = &AtNode{} 24 + 25 + // Dump implements Node.Dump. 26 + func (n *AtNode) Dump(source []byte, level int) { 27 + ast.DumpHelper(n, source, level, nil, nil) 28 + } 29 + 30 + // KindAt is a NodeKind of the At node. 31 + var KindAt = ast.NewNodeKind("At") 32 + 33 + // Kind implements Node.Kind. 34 + func (n *AtNode) Kind() ast.NodeKind { 35 + return KindAt 36 + } 37 + 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 + 40 + type atParser struct{} 41 + 42 + // NewAtParser return a new InlineParser that parses 43 + // at expressions. 44 + func NewAtParser() parser.InlineParser { 45 + return &atParser{} 46 + } 47 + 48 + func (s *atParser) Trigger() []byte { 49 + return []byte{'@'} 50 + } 51 + 52 + func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 53 + line, segment := block.PeekLine() 54 + m := atRegexp.FindSubmatchIndex(line) 55 + if m == nil { 56 + return nil 57 + } 58 + atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 + block.Advance(m[1]) 60 + node := &AtNode{} 61 + node.AppendChild(node, ast.NewTextSegment(atSegment)) 62 + node.handle = string(atSegment.Value(block.Source())[1:]) 63 + return node 64 + } 65 + 66 + // atHtmlRenderer is a renderer.NodeRenderer implementation that 67 + // renders At nodes. 68 + type atHtmlRenderer struct { 69 + html.Config 70 + } 71 + 72 + // NewAtHTMLRenderer returns a new AtHTMLRenderer. 73 + func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 74 + r := &atHtmlRenderer{ 75 + Config: html.NewConfig(), 76 + } 77 + for _, opt := range opts { 78 + opt.SetHTMLOption(&r.Config) 79 + } 80 + return r 81 + } 82 + 83 + // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 84 + func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 85 + reg.Register(KindAt, r.renderAt) 86 + } 87 + 88 + func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 + if entering { 90 + w.WriteString(`<a href="/@`) 91 + w.WriteString(n.(*AtNode).handle) 92 + w.WriteString(`" class="mention">`) 93 + } else { 94 + w.WriteString("</a>") 95 + } 96 + return ast.WalkContinue, nil 97 + } 98 + 99 + type atExt struct{} 100 + 101 + // At is an extension that allow you to use at expression like '@user.bsky.social' . 102 + var AtExt = &atExt{} 103 + 104 + func (e *atExt) Extend(m goldmark.Markdown) { 105 + m.Parser().AddOptions(parser.WithInlineParsers( 106 + util.Prioritized(NewAtParser(), 500), 107 + )) 108 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 109 + util.Prioritized(NewAtHTMLRenderer(), 500), 110 + )) 111 + }
+8 -1
appview/pages/markup/markdown.go
··· 25 25 htmlparse "golang.org/x/net/html" 26 26 27 27 "tangled.org/core/api/tangled" 28 + textension "tangled.org/core/appview/pages/markup/extension" 28 29 "tangled.org/core/appview/pages/repoinfo" 29 30 ) 30 31 ··· 50 51 Files fs.FS 51 52 } 52 53 53 - func (rctx *RenderContext) RenderMarkdown(source string) string { 54 + func NewMarkdown() goldmark.Markdown { 54 55 md := goldmark.New( 55 56 goldmark.WithExtensions( 56 57 extension.GFM, ··· 66 67 ), 67 68 treeblood.MathML(), 68 69 callout.CalloutExtention, 70 + textension.AtExt, 69 71 ), 70 72 goldmark.WithParserOptions( 71 73 parser.WithAutoHeadingID(), 72 74 ), 73 75 goldmark.WithRendererOptions(html.WithUnsafe()), 74 76 ) 77 + return md 78 + } 79 + 80 + func (rctx *RenderContext) RenderMarkdown(source string) string { 81 + md := NewMarkdown() 75 82 76 83 if rctx != nil { 77 84 var transformers []util.PrioritizedValue
+3
appview/pages/markup/sanitizer.go
··· 77 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 79 80 + // at-mentions 81 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") 82 + 80 83 // centering content 81 84 policy.AllowElements("center") 82 85
+4
input.css
··· 161 161 @apply no-underline; 162 162 } 163 163 164 + .prose a.mention { 165 + @apply no-underline hover:underline; 166 + } 167 + 164 168 .prose li { 165 169 @apply my-0 py-0; 166 170 }