From 1cf3c5ced2b6064a5d170b52dd9523884e38bcc1 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/extension/atlink.go | 111 +++++++++++++++++++++++ appview/pages/markup/markdown.go | 9 +- appview/pages/markup/sanitizer.go | 3 + input.css | 4 + 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 appview/pages/markup/extension/atlink.go diff --git a/appview/pages/markup/extension/atlink.go b/appview/pages/markup/extension/atlink.go new file mode 100644 index 00000000..fcf03882 --- /dev/null +++ b/appview/pages/markup/extension/atlink.go @@ -0,0 +1,111 @@ +// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions + +package extension + +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), + )) +} diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index 3f561d15..81bea562 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -25,6 +25,7 @@ import ( htmlparse "golang.org/x/net/html" "tangled.org/core/api/tangled" + textension "tangled.org/core/appview/pages/markup/extension" "tangled.org/core/appview/pages/repoinfo" ) @@ -50,7 +51,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 +67,18 @@ func (rctx *RenderContext) RenderMarkdown(source string) string { ), treeblood.MathML(), callout.CalloutExtention, + textension.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/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