From df29f3be86ca8ee7f88c386f26f271c13b294721 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Fri, 15 Aug 2025 17:21:22 +0100 Subject: [PATCH] appview/pages/markup: insert anchor link in headings Change-Id: qwnqkqnmovynxovvmwllrmnwqwrkzmmn Signed-off-by: oppiliappan --- appview/pages/markup/markdown.go | 40 ++++++++++++++++++++++++++++++- appview/pages/markup/sanitizer.go | 1 + input.css | 24 ++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index a3dfb155..6d39b131 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -177,6 +177,8 @@ func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, switch a.rctx.RendererType { case RendererTypeRepoMarkdown: switch n := n.(type) { + case *ast.Heading: + a.rctx.anchorHeadingTransformer(n) case *ast.Link: a.rctx.relativeLinkTransformer(n) case *ast.Image: @@ -185,6 +187,8 @@ func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, } case RendererTypeDefault: switch n := n.(type) { + case *ast.Heading: + a.rctx.anchorHeadingTransformer(n) case *ast.Image: a.rctx.imageFromKnotAstTransformer(n) a.rctx.camoImageLinkAstTransformer(n) @@ -199,7 +203,7 @@ func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { dst := string(link.Destination) - if isAbsoluteUrl(dst) { + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { return } @@ -240,6 +244,32 @@ func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) { img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) } +func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { + idGeneric, exists := h.AttributeString("id") + if !exists { + return // no id, nothing to do + } + id, ok := idGeneric.([]byte) + if !ok { + return + } + + // create anchor link + anchor := ast.NewLink() + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) + anchor.SetAttribute([]byte("class"), []byte("anchor")) + + // create icon text + iconText := ast.NewString([]byte("#")) + anchor.AppendChild(anchor, iconText) + + // set class on heading + h.SetAttribute([]byte("class"), []byte("heading")) + + // append anchor to heading + h.AppendChild(h, anchor) +} + // actualPath decides when to join the file path with the // current repository directory (essentially only when the link // destination is relative. if it's absolute then we assume the @@ -259,3 +289,11 @@ func isAbsoluteUrl(link string) bool { } return parsed.IsAbs() } + +func isFragment(link string) bool { + return strings.HasPrefix(link, "#") +} + +func isMail(link string) bool { + return strings.HasPrefix(link, "mailto:") +} diff --git a/appview/pages/markup/sanitizer.go b/appview/pages/markup/sanitizer.go index 6ba27cc1..4fb92c37 100644 --- a/appview/pages/markup/sanitizer.go +++ b/appview/pages/markup/sanitizer.go @@ -65,6 +65,7 @@ func defaultPolicy() *bluemonday.Policy { // for code blocks policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") + 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") // centering content diff --git a/input.css b/input.css index 33c70da7..b47edc90 100644 --- a/input.css +++ b/input.css @@ -134,8 +134,30 @@ disabled:before:bg-green-400 dark:disabled:before:bg-green-600; } + .prose hr { + @apply my-2; + } + .prose li:has(input) { - list-style: none; + @apply list-none; + } + + .prose ul:has(input) { + @apply pl-2; + } + + .prose .heading .anchor { + @apply no-underline mx-2 opacity-0; + } + + .prose .heading:hover .anchor { + @apply opacity-70; + } + + .prose .heading .anchor:hover { + @apply opacity-70; + } + .prose a.footnote-backref { @apply no-underline; } -- 2.43.0