From 0e8d90565f9026a8a60f52590bc9bc5eaeac45a9 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 25 Jul 2025 00:27:46 +0900 Subject: [PATCH] appview/pages: markup: add `@` user-mention parsing in markdown Change-Id: ulyruprmrqxnopnnuuovqqzuzonqrpyl add custom styling rule for mention links (no underline unless hover) Signed-off-by: Seongmin Lee --- appview/pages/markup/markdown.go | 8 +- appview/pages/markup/markdown_at_extension.go | 135 ++++++++++++++++++ appview/pages/markup/sanitizer.go | 3 + input.css | 4 + 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 appview/pages/markup/markdown_at_extension.go diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index 3f561d15..9c15ee76 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -50,7 +50,7 @@ type RenderContext struct { Files fs.FS } -func (rctx *RenderContext) RenderMarkdown(source string) string { +func NewMarkdown() goldmark.Markdown { md := goldmark.New( goldmark.WithExtensions( extension.GFM, @@ -66,12 +66,18 @@ func (rctx *RenderContext) RenderMarkdown(source string) string { ), treeblood.MathML(), callout.CalloutExtention, + AtExt, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions(html.WithUnsafe()), ) + return md +} + +func (rctx *RenderContext) RenderMarkdown(source string) string { + md := NewMarkdown() if rctx != nil { var transformers []util.PrioritizedValue diff --git a/appview/pages/markup/markdown_at_extension.go b/appview/pages/markup/markdown_at_extension.go new file mode 100644 index 00000000..65c63d48 --- /dev/null +++ b/appview/pages/markup/markdown_at_extension.go @@ -0,0 +1,135 @@ +// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions + +package markup + +import ( + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// An AtNode struct represents an AtNode +type AtNode struct { + handle string + ast.BaseInline +} + +var _ ast.Node = &AtNode{} + +// Dump implements Node.Dump. +func (n *AtNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// KindAt is a NodeKind of the At node. +var KindAt = ast.NewNodeKind("At") + +// Kind implements Node.Kind. +func (n *AtNode) Kind() ast.NodeKind { + return KindAt +} + +var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) + +type atParser struct{} + +// NewAtParser return a new InlineParser that parses +// at expressions. +func NewAtParser() parser.InlineParser { + return &atParser{} +} + +func (s *atParser) Trigger() []byte { + return []byte{'@'} +} + +func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, segment := block.PeekLine() + m := atRegexp.FindSubmatchIndex(line) + if m == nil { + return nil + } + atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) + block.Advance(m[1]) + node := &AtNode{} + node.AppendChild(node, ast.NewTextSegment(atSegment)) + node.handle = string(atSegment.Value(block.Source())[1:]) + return node +} + +// atHtmlRenderer is a renderer.NodeRenderer implementation that +// renders At nodes. +type atHtmlRenderer struct { + html.Config +} + +// NewAtHTMLRenderer returns a new AtHTMLRenderer. +func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &atHtmlRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindAt, r.renderAt) +} + +func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + w.WriteString(``) + } else { + w.WriteString("") + } + return ast.WalkContinue, nil +} + +type atExt struct{} + +// At is an extension that allow you to use at expression like '@user.bsky.social' . +var AtExt = &atExt{} + +func (e *atExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(NewAtParser(), 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewAtHTMLRenderer(), 500), + )) +} + +// FindUserMentions returns Set of user handles from given markup soruce. +// It doesn't guarntee unique DIDs +func FindUserMentions(source string) []string { + var ( + mentions []string + mentionsSet = make(map[string]struct{}) + md = NewMarkdown() + sourceBytes = []byte(source) + root = md.Parser().Parse(text.NewReader(sourceBytes)) + ) + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.Kind() == KindAt { + handle := n.(*AtNode).handle + mentionsSet[handle] = struct{}{} + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) + for handle := range mentionsSet { + mentions = append(mentions, handle) + } + return mentions +} diff --git a/appview/pages/markup/sanitizer.go b/appview/pages/markup/sanitizer.go index a2855208..4ce8e1b6 100644 --- a/appview/pages/markup/sanitizer.go +++ b/appview/pages/markup/sanitizer.go @@ -77,6 +77,9 @@ func defaultPolicy() *bluemonday.Policy { policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") + // at-mentions + policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") + // centering content policy.AllowElements("center") diff --git a/input.css b/input.css index c04d53c2..8fe49d4a 100644 --- a/input.css +++ b/input.css @@ -161,6 +161,10 @@ @apply no-underline; } + .prose a.mention { + @apply no-underline hover:underline; + } + .prose li { @apply my-0 py-0; } -- 2.43.0