From 87ef8daee0ca7e68edd270cf9adb1eea0e565d22 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 20 Jan 2026 14:11:51 +0900 Subject: [PATCH] appview/pages/markup: smart commit autolink renderer Implemented tangled link extension. This can be extended for other link types like issue/pull references in future. The `tangled.org` host is hardcoded right now. Close: Signed-off-by: Seongmin Lee --- appview/pages/markup/extension/tangledlink.go | 149 ++++++++++++++++++ .../markup/extension/tangledlink_test.go | 52 ++++++ appview/pages/markup/markdown.go | 1 + 3 files changed, 202 insertions(+) create mode 100644 appview/pages/markup/extension/tangledlink.go create mode 100644 appview/pages/markup/extension/tangledlink_test.go diff --git a/appview/pages/markup/extension/tangledlink.go b/appview/pages/markup/extension/tangledlink.go new file mode 100644 index 00000000..c2428eaf --- /dev/null +++ b/appview/pages/markup/extension/tangledlink.go @@ -0,0 +1,149 @@ +package extension + +import ( + "net/url" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// KindTangledLink is a NodeKind of the TangledLink node. +var KindTangledLink = ast.NewNodeKind("TangledLink") + +type TangledLinkNode struct { + ast.BaseInline + Destination string + Commit *TangledCommitLink + // TODO: add more Tangled-link types +} + +type TangledCommitLink struct { + Sha string +} + +var _ ast.Node = new(TangledLinkNode) + +// Dump implements [ast.Node]. +func (n *TangledLinkNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// Kind implements [ast.Node]. +func (n *TangledLinkNode) Kind() ast.NodeKind { + return KindTangledLink +} + +type tangledLinkTransformer struct { + host string +} + +var _ parser.ASTTransformer = new(tangledLinkTransformer) + +// Transform implements [parser.ASTTransformer]. +func (t *tangledLinkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + var dest string + + switch n := n.(type) { + case *ast.AutoLink: + dest = string(n.URL(reader.Source())) + case *ast.Link: + // maybe..? not sure + default: + return ast.WalkContinue, nil + } + + if sha := t.parseLinkCommitSha(dest); sha != "" { + newLink := &TangledLinkNode{ + Destination: dest, + Commit: &TangledCommitLink{ + Sha: sha, + }, + } + n.Parent().ReplaceChild(n.Parent(), n, newLink) + } + + return ast.WalkContinue, nil + }) +} + +func (t *tangledLinkTransformer) parseLinkCommitSha(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host != "tangled.org" { + return "" + } + + // /{owner}/{repo}/commit/ + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 4 || parts[2] != "commit" { + return "" + } + + sha := parts[3] + + // basic sha validation + if len(sha) < 7 { + return "" + } + for _, c := range sha { + if !strings.ContainsRune("0123456789abcdef", c) { + return "" + } + } + + return sha[:8] +} + +type tangledLinkRenderer struct{} + +var _ renderer.NodeRenderer = new(tangledLinkRenderer) + +// RegisterFuncs implements [renderer.NodeRenderer]. +func (r *tangledLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindTangledLink, r.renderTangledLink) +} + +func (r *tangledLinkRenderer) renderTangledLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + link := node.(*TangledLinkNode) + + if link.Commit != nil { + if entering { + w.WriteString(``) + w.WriteString(link.Commit.Sha) + } else { + w.WriteString(``) + } + } + + return ast.WalkContinue, nil +} + +type tangledLinkExt struct { + host string +} + +var _ goldmark.Extender = new(tangledLinkExt) + +func (e *tangledLinkExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithASTTransformers( + util.Prioritized(&tangledLinkTransformer{host: e.host}, 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(&tangledLinkRenderer{}, 500), + )) +} + +func NewTangledLinkExt(host string) goldmark.Extender { + return &tangledLinkExt{host} +} diff --git a/appview/pages/markup/extension/tangledlink_test.go b/appview/pages/markup/extension/tangledlink_test.go new file mode 100644 index 00000000..80652be2 --- /dev/null +++ b/appview/pages/markup/extension/tangledlink_test.go @@ -0,0 +1,52 @@ +package extension_test + +import ( + "bytes" + "testing" + + "tangled.org/core/appview/pages/markup" +) + +func TestTangledLinkExtension_Rendering(t *testing.T) { + tests := []struct { + name string + markdown string + expected string + }{ + { + name: "renders simple commit link from commonmark autolink", + markdown: "This is a commit: ", + expected: `

This is a commit: cde47050

`, + }, + { + name: "renders simple commit link from gfm autolink", + markdown: "This is a commit: https://tangled.org/alice.tngl.sh/cool-repo/commit/cde4705021a07e3cb11322fb9ef78a6c786b41c0", + expected: `

This is a commit: cde47050

`, + }, + { + name: "skip non-autolink links", + markdown: "This is a commit: [a commit](https://tangled.org/alice.tngl.sh/cool-repo/commit/cde4705021a07e3cb11322fb9ef78a6c786b41c0)", + expected: `

This is a commit: a commit

`, + }, + { + name: "skip non-autolink links with content same as dest", + markdown: "This is a commit: [https://tangled.org/alice.tngl.sh/cool-repo/commit/cde4705021a07e3cb11322fb9ef78a6c786b41c0](https://tangled.org/alice.tngl.sh/cool-repo/commit/cde4705021a07e3cb11322fb9ef78a6c786b41c0)", + expected: `

This is a commit: https://tangled.org/alice.tngl.sh/cool-repo/commit/cde4705021a07e3cb11322fb9ef78a6c786b41c0

`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + md := markup.NewMarkdown() + + var buf bytes.Buffer + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { + t.Fatalf("failed to convert markdown: %v", err) + } + + result := buf.String() + if result != tt.expected+"\n" { + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) + } + }) + } +} diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index f17e14ea..e2557fb1 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -67,6 +67,7 @@ func NewMarkdown() goldmark.Markdown { ), callout.CalloutExtention, textension.AtExt, + textension.NewTangledLinkExt("tangled.org"), emoji.Emoji, ), goldmark.WithParserOptions( -- 2.51.2