forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview: pages/markup: resolve link destinations against the current dir

Fixes a bug reported on Discord with relative links inside a
repository's subdir would resolve incorrectly since we were naively
"absoluting" the link destination.

Now, we resolve it against the current (parent) directory. For example,
if lol/x.md has a link

[foo](./some.png) => /lol/some.png (instead of just /some.png)

authored by anirudh.fi and committed by Tangled cd60c898 3f274571

Changed files
+87 -17
appview
pages
markup
repoinfo
state
+50 -15
appview/pages/markup/markdown.go
··· 6 "net/url" 7 "path" 8 9 "github.com/yuin/goldmark" 10 "github.com/yuin/goldmark/ast" 11 "github.com/yuin/goldmark/extension" ··· 13 "github.com/yuin/goldmark/renderer/html" 14 "github.com/yuin/goldmark/text" 15 "github.com/yuin/goldmark/util" 16 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 ) 18 ··· 62 return buf.String() 63 } 64 65 type MarkdownTransformer struct { 66 rctx *RenderContext 67 } ··· 74 75 switch a.rctx.RendererType { 76 case RendererTypeRepoMarkdown: 77 - switch n.(type) { 78 case *ast.Link: 79 - a.rctx.relativeLinkTransformer(n.(*ast.Link)) 80 case *ast.Image: 81 - a.rctx.imageFromKnotTransformer(n.(*ast.Image)) 82 - a.rctx.camoImageLinkTransformer(n.(*ast.Image)) 83 } 84 - 85 case RendererTypeDefault: 86 - switch n.(type) { 87 case *ast.Image: 88 - a.rctx.imageFromKnotTransformer(n.(*ast.Image)) 89 - a.rctx.camoImageLinkTransformer(n.(*ast.Image)) 90 } 91 } 92 ··· 95 } 96 97 func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { 98 dst := string(link.Destination) 99 100 if isAbsoluteUrl(dst) { 101 return 102 } 103 104 - newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, dst) 105 link.Destination = []byte(newPath) 106 } 107 ··· 112 return 113 } 114 115 - // strip leading './' 116 - if len(dst) >= 2 && dst[0:2] == "./" { 117 - dst = dst[2:] 118 - } 119 - 120 scheme := "https" 121 if rctx.IsDev { 122 scheme = "http" 123 } 124 parsedURL := &url.URL{ 125 Scheme: scheme, 126 Host: rctx.Knot, ··· 129 rctx.RepoInfo.Name, 130 "raw", 131 url.PathEscape(rctx.RepoInfo.Ref), 132 - dst), 133 } 134 newPath := parsedURL.String() 135 img.Destination = []byte(newPath) 136 } 137 138 func isAbsoluteUrl(link string) bool {
··· 6 "net/url" 7 "path" 8 9 + "github.com/microcosm-cc/bluemonday" 10 "github.com/yuin/goldmark" 11 "github.com/yuin/goldmark/ast" 12 "github.com/yuin/goldmark/extension" ··· 14 "github.com/yuin/goldmark/renderer/html" 15 "github.com/yuin/goldmark/text" 16 "github.com/yuin/goldmark/util" 17 + 18 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 19 ) 20 ··· 64 return buf.String() 65 } 66 67 + func (rctx *RenderContext) Sanitize(html string) string { 68 + policy := bluemonday.UGCPolicy() 69 + policy.AllowAttrs("align", "style").Globally() 70 + policy.AllowStyles( 71 + "margin", 72 + "padding", 73 + "text-align", 74 + "font-weight", 75 + "text-decoration", 76 + "padding-left", 77 + "padding-right", 78 + "padding-top", 79 + "padding-bottom", 80 + "margin-left", 81 + "margin-right", 82 + "margin-top", 83 + "margin-bottom", 84 + ) 85 + return policy.Sanitize(html) 86 + } 87 + 88 type MarkdownTransformer struct { 89 rctx *RenderContext 90 } ··· 97 98 switch a.rctx.RendererType { 99 case RendererTypeRepoMarkdown: 100 + switch n := n.(type) { 101 case *ast.Link: 102 + a.rctx.relativeLinkTransformer(n) 103 case *ast.Image: 104 + a.rctx.imageFromKnotTransformer(n) 105 + a.rctx.camoImageLinkTransformer(n) 106 } 107 case RendererTypeDefault: 108 + switch n := n.(type) { 109 case *ast.Image: 110 + a.rctx.imageFromKnotTransformer(n) 111 + a.rctx.camoImageLinkTransformer(n) 112 } 113 } 114 ··· 117 } 118 119 func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { 120 + 121 dst := string(link.Destination) 122 123 if isAbsoluteUrl(dst) { 124 return 125 } 126 127 + actualPath := rctx.actualPath(dst) 128 + 129 + newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) 130 link.Destination = []byte(newPath) 131 } 132 ··· 137 return 138 } 139 140 scheme := "https" 141 if rctx.IsDev { 142 scheme = "http" 143 } 144 + 145 + actualPath := rctx.actualPath(dst) 146 + 147 parsedURL := &url.URL{ 148 Scheme: scheme, 149 Host: rctx.Knot, ··· 152 rctx.RepoInfo.Name, 153 "raw", 154 url.PathEscape(rctx.RepoInfo.Ref), 155 + actualPath), 156 } 157 newPath := parsedURL.String() 158 img.Destination = []byte(newPath) 159 + } 160 + 161 + // actualPath decides when to join the file path with the 162 + // current repository directory (essentially only when the link 163 + // destination is relative. if it's absolute then we assume the 164 + // user knows what they're doing.) 165 + func (rctx *RenderContext) actualPath(dst string) string { 166 + if path.IsAbs(dst) { 167 + return dst 168 + } 169 + 170 + return path.Join(rctx.CurrentDir, dst) 171 } 172 173 func isAbsoluteUrl(link string) bool {
+3 -2
appview/pages/pages.go
··· 432 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 433 htmlString = p.rctx.RenderMarkdown(params.Readme) 434 params.Raw = false 435 - params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 436 default: 437 htmlString = string(params.Readme) 438 params.Raw = true ··· 562 case markup.FormatMarkdown: 563 p.rctx.RepoInfo = params.RepoInfo 564 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 565 - params.RenderedContents = template.HTML(bluemonday.UGCPolicy().Sanitize(p.rctx.RenderMarkdown(params.Contents))) 566 } 567 } 568
··· 432 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 433 htmlString = p.rctx.RenderMarkdown(params.Readme) 434 params.Raw = false 435 + params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 436 default: 437 htmlString = string(params.Readme) 438 params.Raw = true ··· 562 case markup.FormatMarkdown: 563 p.rctx.RepoInfo = params.RepoInfo 564 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 565 + htmlString := p.rctx.RenderMarkdown(params.Contents) 566 + params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 567 } 568 } 569
+1
appview/pages/repoinfo/repoinfo.go
··· 63 SourceHandle string 64 Ref string 65 DisableFork bool 66 } 67 68 // each tab on a repo could have some metadata:
··· 63 SourceHandle string 64 Ref string 65 DisableFork bool 66 + CurrentDir string 67 } 68 69 // each tab on a repo could have some metadata:
+2
appview/state/repo.go
··· 946 Description string 947 CreatedAt string 948 Ref string 949 } 950 951 func (f *FullyResolvedRepo) OwnerDid() string { ··· 1104 PullCount: pullCount, 1105 }, 1106 DisableFork: disableFork, 1107 } 1108 1109 if sourceRepo != nil {
··· 946 Description string 947 CreatedAt string 948 Ref string 949 + CurrentDir string 950 } 951 952 func (f *FullyResolvedRepo) OwnerDid() string { ··· 1105 PullCount: pullCount, 1106 }, 1107 DisableFork: disableFork, 1108 + CurrentDir: f.CurrentDir, 1109 } 1110 1111 if sourceRepo != nil {
+31
appview/state/repo_util.go
··· 7 "log" 8 "math/big" 9 "net/http" 10 11 "github.com/bluesky-social/indigo/atproto/identity" 12 "github.com/bluesky-social/indigo/atproto/syntax" ··· 59 ref = defaultBranch.Branch 60 } 61 62 // pass through values from the middleware 63 description, ok := r.Context().Value("repoDescription").(string) 64 addedAt, ok := r.Context().Value("repoAddedAt").(string) ··· 71 Description: description, 72 CreatedAt: addedAt, 73 Ref: ref, 74 }, nil 75 } 76 ··· 81 } else { 82 return repoinfo.RolesInRepo{} 83 } 84 } 85 86 func uniqueEmails(commits []*object.Commit) []string {
··· 7 "log" 8 "math/big" 9 "net/http" 10 + "net/url" 11 + "path" 12 + "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 62 ref = defaultBranch.Branch 63 } 64 65 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 66 + 67 // pass through values from the middleware 68 description, ok := r.Context().Value("repoDescription").(string) 69 addedAt, ok := r.Context().Value("repoAddedAt").(string) ··· 76 Description: description, 77 CreatedAt: addedAt, 78 Ref: ref, 79 + CurrentDir: currentDir, 80 }, nil 81 } 82 ··· 87 } else { 88 return repoinfo.RolesInRepo{} 89 } 90 + } 91 + 92 + // extractPathAfterRef gets the actual repository path 93 + // after the ref. for example: 94 + // 95 + // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 96 + func extractPathAfterRef(fullPath, ref string) string { 97 + fullPath = strings.TrimPrefix(fullPath, "/") 98 + 99 + ref = url.PathEscape(ref) 100 + 101 + prefixes := []string{ 102 + fmt.Sprintf("blob/%s/", ref), 103 + fmt.Sprintf("tree/%s/", ref), 104 + fmt.Sprintf("raw/%s/", ref), 105 + } 106 + 107 + for _, prefix := range prefixes { 108 + idx := strings.Index(fullPath, prefix) 109 + if idx != -1 { 110 + return fullPath[idx+len(prefix):] 111 + } 112 + } 113 + 114 + return "" 115 } 116 117 func uniqueEmails(commits []*object.Commit) []string {