// Copyright 2017 The Gitea Authors. All rights reserved.// Copyright 2025 The Forgejo Authors.// SPDX-License-Identifier: MIT package markup import ( "bytes" "io" "net/url" "path" "path/filepath" "regexp" "strings" "sync" "forgejo.org/modules/base" "forgejo.org/modules/emoji" "forgejo.org/modules/git" "forgejo.org/modules/log" "forgejo.org/modules/markup/common" "forgejo.org/modules/references" "forgejo.org/modules/regexplru" "forgejo.org/modules/setting" "forgejo.org/modules/templates/vars" "forgejo.org/modules/translation" "forgejo.org/modules/util" "golang.org/x/net/html" "golang.org/x/net/html/atom" "mvdan.cc/xurls/v2") // Issue name stylesconst ( IssueNameStyleNumeric = "numeric" IssueNameStyleAlphanumeric = "alphanumeric" IssueNameStyleRegexp = "regexp") var ( // NOTE: All below regex matching do not perform any extra validation. // Thus a link is produced even if the linked entity does not exist. // While fast, this is also incorrect and lead to false positives. // TODO: fix invalid linking issue // valid chars in encoded path and parameter: [-+~_%.a-zA-Z0-9/] // hashCurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae // Although SHA1 hashes are 40 chars long, SHA256 are 64, the regex matches the hash from 7 to 64 chars in length // so that abbreviated hash links can be used as well. This matches git and GitHub usability. hashCurrentPattern = regexp.MustCompile(`(?:^|\s)[^\w\d]{0,2}([0-9a-f]{7,64})[^\w\d]{0,2}(?:\s|$)`) // shortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`) // anyHashPattern splits url containing SHA into parts anyHashPattern = regexp.MustCompile(`https?://(?:(?:\S+/){3,4}(?:commit|tree|blob)/)([0-9a-f]{7,64})(/[-+~_%.a-zA-Z0-9/]+)?(\?[-+~_%\.a-zA-Z0-9=&]+)?(#[-+~_%.a-zA-Z0-9]+)?`) // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`) // While this email regex is definitely not perfect and I'm sure you can come up // with edge cases, it is still accepted by the CommonMark specification, as // well as the HTML5 spec: // http://spec.commonmark.org/0.28/#email-address // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail) emailRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))") // Fediverse handle regex (same as emailRegex but with additonal @ or ! // at start) fediRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([@!]([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+)@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+))(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))") // blackfriday extensions create IDs like fn:user-content-footnote blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`) // EmojiShortCodeRegex find emoji by alias like :smile: EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`) InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`")) // CSS class for action keywords (e.g. "closes: #1")const keywordClass = "issue-keyword" // IsLink reports whether link fits valid format.func IsLink(link []byte) bool { return validLinksPattern.Match(link)} func IsLinkStr(link string) bool { return validLinksPattern.MatchString(link)} // regexp for full links to issues/pullsvar issueFullPattern *regexp.Regexp // Once for to prevent racesvar issueFullPatternOnce sync.Once func getIssueFullPattern() *regexp.Regexp { issueFullPatternOnce.Do(func() { // example: https://domain/org/repo/pulls/27#hash issueFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) + `(?P<user>[\w_.-]+)\/(?P<repo>[\w_.-]+)\/(?:issues|pulls)\/(?P<num>(?:\w{1,10}-)?[1-9][0-9]*)(?P<subpath>\/[\w_.-]+)?(?:(?P<comment>#(?:issue|issuecomment)-\d+)|(?:[\?#](?:\S+)?))?\b`) }) return issueFullPattern} // CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within textfunc CustomLinkURLSchemes(schemes []string) { schemes = append(schemes, "http", "https") withAuth := make([]string, 0, len(schemes)) validScheme := regexp.MustCompile(`^[a-z]+$`) for _, s := range schemes { if !validScheme.MatchString(s) { continue } without := false for _, sna := range xurls.SchemesNoAuthority { if s == sna { without = true break } } if without { s += ":" } else { s += "://" } withAuth = append(withAuth, s) } common.LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|"))} type postProcessError struct { context string err error} func (p *postProcessError) Error() string { return "PostProcess: " + p.context + ", " + p.err.Error()} type processor func(ctx *RenderContext, node *html.Node) var defaultProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, filePreviewPatternProcessor, fullHashPatternProcessor, shortLinkProcessor, linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, fediAddressProcessor, emailAddressProcessor, emojiProcessor, emojiShortCodeProcessor,} // PostProcess does the final required transformations to the passed raw HTML// data, and ensures its validity. Transformations include: replacing links and// emails with HTML links, parsing shortlinks in the format of [[Link]], like// MediaWiki, linking issues in the format #ID, and mentions in the format// @user, and others.func PostProcess( ctx *RenderContext, input io.Reader, output io.Writer,) error { return postProcess(ctx, defaultProcessors, input, output)} var commitMessageProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, fullHashPatternProcessor, linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, emailAddressProcessor, emojiProcessor, emojiShortCodeProcessor,} // RenderCommitMessage will use the same logic as PostProcess, but will disable// the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is// set, which changes every text node into a link to the passed default link.func RenderCommitMessage( ctx *RenderContext, content string,) (string, error) { procs := commitMessageProcessors if ctx.DefaultLink != "" { // we don't have to fear data races, because being // commitMessageProcessors of fixed len and cap, every time we append // something to it the slice is realloc+copied, so append always // generates the slice ex-novo. procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink)) } return renderProcessString(ctx, procs, content)} var commitMessageSubjectProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, fullHashPatternProcessor, linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, emojiShortCodeProcessor, emojiProcessor,} var emojiProcessors = []processor{ emojiShortCodeProcessor, emojiProcessor,} // RenderCommitMessageSubject will use the same logic as PostProcess and// RenderCommitMessage, but will disable the shortLinkProcessor and// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,// which changes every text node into a link to the passed default link.func RenderCommitMessageSubject( ctx *RenderContext, content string,) (string, error) { procs := commitMessageSubjectProcessors if ctx.DefaultLink != "" { // we don't have to fear data races, because being // commitMessageSubjectProcessors of fixed len and cap, every time we // append something to it the slice is realloc+copied, so append always // generates the slice ex-novo. procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink)) } return renderProcessString(ctx, procs, content)} // RenderIssueTitle to process title on individual issue/pull pagefunc RenderIssueTitle( ctx *RenderContext, title string,) (string, error) { return renderProcessString(ctx, []processor{ inlineCodeBlockProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, emojiShortCodeProcessor, emojiProcessor, }, title)} // RenderRefIssueTitle to process title on places where an issue is referencedfunc RenderRefIssueTitle( ctx *RenderContext, title string,) (string, error) { return renderProcessString(ctx, []processor{ inlineCodeBlockProcessor, issueIndexPatternProcessor, emojiShortCodeProcessor, emojiProcessor, }, title)} func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) { var buf strings.Builder if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil { return "", err } return buf.String(), nil} // RenderDescriptionHTML will use similar logic as PostProcess, but will// use a single special linkProcessor.func RenderDescriptionHTML( ctx *RenderContext, content string,) (string, error) { return renderProcessString(ctx, []processor{ descriptionLinkProcessor, emojiShortCodeProcessor, emojiProcessor, }, content)} // RenderEmoji for when we want to just process emoji and shortcodes// in various places it isn't already run through the normal markdown processorfunc RenderEmoji( ctx *RenderContext, content string,) (string, error) { return renderProcessString(ctx, emojiProcessors, content)} var ( tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`) nulCleaner = strings.NewReplacer("\000", "")) func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error { defer ctx.Cancel() // FIXME: don't read all content to memory rawHTML, err := io.ReadAll(input) if err != nil { return err } // parse the HTML node, err := html.Parse(io.MultiReader( // prepend "<html><body>" strings.NewReader("<html><body>"), // Strip out nuls - they're always invalid bytes.NewReader(tagCleaner.ReplaceAll([]byte(nulCleaner.Replace(string(rawHTML))), []byte("<$1"))), // close the tags strings.NewReader("</body></html>"), )) if err != nil { return &postProcessError{"invalid HTML", err} } if node.Type == html.DocumentNode { node = node.FirstChild } visitNode(ctx, procs, node) newNodes := make([]*html.Node, 0, 5) if node.Data == "html" { node = node.FirstChild for node != nil && node.Data != "body" { node = node.NextSibling } } if node != nil { if node.Data == "body" { child := node.FirstChild for child != nil { newNodes = append(newNodes, child) child = child.NextSibling } } else { newNodes = append(newNodes, node) } } // Render everything to buf. for _, node := range newNodes { if err := html.Render(output, node); err != nil { return &postProcessError{"error rendering processed HTML", err} } } return nil} func visitNode(ctx *RenderContext, procs []processor, node *html.Node) { // Add user-content- to IDs and "#" links if they don't already have them for idx, attr := range node.Attr { val := strings.TrimPrefix(attr.Val, "#") notHasPrefix := !strings.HasPrefix(val, "user-content-") && !blackfridayExtRegex.MatchString(val) if attr.Key == "id" && notHasPrefix { node.Attr[idx].Val = "user-content-" + attr.Val } if attr.Key == "href" && strings.HasPrefix(attr.Val, "#") && notHasPrefix { node.Attr[idx].Val = "#user-content-" + val } if attr.Key == "class" && attr.Val == "emoji" { procs = nil } } // We ignore code and pre. switch node.Type { case html.TextNode: processTextNodes(ctx, procs, node) case html.ElementNode: if node.Data == "img" { for i, attr := range node.Attr { if attr.Key != "src" { continue } if len(attr.Val) > 0 && !IsLinkStr(attr.Val) && !strings.HasPrefix(attr.Val, "data:image/") { attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val) } attr.Val = camoHandleLink(attr.Val) node.Attr[i] = attr } } else if node.Data == "a" { // Restrict text in links to emojis procs = emojiProcessors } else if node.Data == "code" || node.Data == "pre" { return } else if node.Data == "i" { for _, attr := range node.Attr { if attr.Key != "class" { continue } classes := strings.Split(attr.Val, " ") for i, class := range classes { if class == "icon" { classes[0], classes[i] = classes[i], classes[0] attr.Val = strings.Join(classes, " ") // Remove all children of icons child := node.FirstChild for child != nil { node.RemoveChild(child) child = node.FirstChild } break } } } } for n := node.FirstChild; n != nil; n = n.NextSibling { visitNode(ctx, procs, n) } default: } // ignore everything else} // processTextNodes runs the passed node through various processors, in order to handle// all kinds of special links handled by the post-processing.func processTextNodes(ctx *RenderContext, procs []processor, node *html.Node) { for _, p := range procs { p(ctx, node) }} // createKeyword() renders a highlighted version of an action keywordfunc createKeyword(content string) *html.Node { span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: keywordClass}) text := &html.Node{ Type: html.TextNode, Data: content, } span.AppendChild(text) return span} func createInlineCode(content string) *html.Node { code := &html.Node{ Type: html.ElementNode, Data: atom.Code.String(), Attr: []html.Attribute{}, } code.Attr = append(code.Attr, html.Attribute{Key: "class", Val: "inline-code-block"}) text := &html.Node{ Type: html.TextNode, Data: content, } code.AppendChild(text) return code} func createEmoji(content, class, name, alias string) *html.Node { span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } if class != "" { span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class}) } if name != "" { span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name}) } if alias != "" { span.Attr = append(span.Attr, html.Attribute{Key: "data-alias", Val: alias}) } text := &html.Node{ Type: html.TextNode, Data: content, }Lines 1–500 of 1344. Load more
// Copyright 2017 The Gitea Authors. All rights reserved. // Copyright 2025 The Forgejo Authors. // SPDX-License-Identifier: MIT package markup import ( "bytes" "io" "net/url" "path" "path/filepath" "regexp" "strings" "sync" "forgejo.org/modules/base" "forgejo.org/modules/emoji" "forgejo.org/modules/git" "forgejo.org/modules/log" "forgejo.org/modules/markup/common" "forgejo.org/modules/references" "forgejo.org/modules/regexplru" "forgejo.org/modules/setting" "forgejo.org/modules/templates/vars" "forgejo.org/modules/translation" "forgejo.org/modules/util" "golang.org/x/net/html" "golang.org/x/net/html/atom" "mvdan.cc/xurls/v2" ) // Issue name styles const ( IssueNameStyleNumeric = "numeric" IssueNameStyleAlphanumeric = "alphanumeric" IssueNameStyleRegexp = "regexp" ) var ( // NOTE: All below regex matching do not perform any extra validation. // Thus a link is produced even if the linked entity does not exist. // While fast, this is also incorrect and lead to false positives. // TODO: fix invalid linking issue // valid chars in encoded path and parameter: [-+~_%.a-zA-Z0-9/] // hashCurrentPattern matches string that represents a commit SHA, e.g. d8a994ef243349f321568f9e36d5c3f444b99cae // Although SHA1 hashes are 40 chars long, SHA256 are 64, the regex matches the hash from 7 to 64 chars in length // so that abbreviated hash links can be used as well. This matches git and GitHub usability. hashCurrentPattern = regexp.MustCompile(`(?:^|\s)[^\w\d]{0,2}([0-9a-f]{7,64})[^\w\d]{0,2}(?:\s|$)`) // shortLinkPattern matches short but difficult to parse [[name|link|arg=test]] syntax shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`) // anyHashPattern splits url containing SHA into parts anyHashPattern = regexp.MustCompile(`https?://(?:(?:\S+/){3,4}(?:commit|tree|blob)/)([0-9a-f]{7,64})(/[-+~_%.a-zA-Z0-9/]+)?(\?[-+~_%\.a-zA-Z0-9=&]+)?(#[-+~_%.a-zA-Z0-9]+)?`) // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`) // While this email regex is definitely not perfect and I'm sure you can come up // with edge cases, it is still accepted by the CommonMark specification, as // well as the HTML5 spec: // http://spec.commonmark.org/0.28/#email-address // https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type%3Demail) emailRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))") // Fediverse handle regex (same as emailRegex but with additonal @ or ! // at start) fediRegex = regexp.MustCompile("(?:\\s|^|\\(|\\[)([@!]([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+)@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9]{2,}(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+))(?:\\s|$|\\)|\\]|;|,|\\?|!|\\.(\\s|$))") // blackfriday extensions create IDs like fn:user-content-footnote blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`) // EmojiShortCodeRegex find emoji by alias like :smile: EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`) InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`") ) // CSS class for action keywords (e.g. "closes: #1") const keywordClass = "issue-keyword" // IsLink reports whether link fits valid format. func IsLink(link []byte) bool { return validLinksPattern.Match(link) } func IsLinkStr(link string) bool { return validLinksPattern.MatchString(link) } // regexp for full links to issues/pulls var issueFullPattern *regexp.Regexp // Once for to prevent races var issueFullPatternOnce sync.Once func getIssueFullPattern() *regexp.Regexp { issueFullPatternOnce.Do(func() { // example: https://domain/org/repo/pulls/27#hash issueFullPattern = regexp.MustCompile(regexp.QuoteMeta(setting.AppURL) + `(?P<user>[\w_.-]+)\/(?P<repo>[\w_.-]+)\/(?:issues|pulls)\/(?P<num>(?:\w{1,10}-)?[1-9][0-9]*)(?P<subpath>\/[\w_.-]+)?(?:(?P<comment>#(?:issue|issuecomment)-\d+)|(?:[\?#](?:\S+)?))?\b`) }) return issueFullPattern } // CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text func CustomLinkURLSchemes(schemes []string) { schemes = append(schemes, "http", "https") withAuth := make([]string, 0, len(schemes)) validScheme := regexp.MustCompile(`^[a-z]+$`) for _, s := range schemes { if !validScheme.MatchString(s) { continue } without := false for _, sna := range xurls.SchemesNoAuthority { if s == sna { without = true break } } if without { s += ":" } else { s += "://" } withAuth = append(withAuth, s) } common.LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) } type postProcessError struct { context string err error } func (p *postProcessError) Error() string { return "PostProcess: " + p.context + ", " + p.err.Error() } type processor func(ctx *RenderContext, node *html.Node) var defaultProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, filePreviewPatternProcessor, fullHashPatternProcessor, shortLinkProcessor, linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, fediAddressProcessor, emailAddressProcessor, emojiProcessor, emojiShortCodeProcessor, } // PostProcess does the final required transformations to the passed raw HTML // data, and ensures its validity. Transformations include: replacing links and // emails with HTML links, parsing shortlinks in the format of [[Link]], like // MediaWiki, linking issues in the format #ID, and mentions in the format // @user, and others. func PostProcess( ctx *RenderContext, input io.Reader, output io.Writer, ) error { return postProcess(ctx, defaultProcessors, input, output) } var commitMessageProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, fullHashPatternProcessor, linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, emailAddressProcessor, emojiProcessor, emojiShortCodeProcessor, } // RenderCommitMessage will use the same logic as PostProcess, but will disable // the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is // set, which changes every text node into a link to the passed default link. func RenderCommitMessage( ctx *RenderContext, content string, ) (string, error) { procs := commitMessageProcessors if ctx.DefaultLink != "" { // we don't have to fear data races, because being // commitMessageProcessors of fixed len and cap, every time we append // something to it the slice is realloc+copied, so append always // generates the slice ex-novo. procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink)) } return renderProcessString(ctx, procs, content) } var commitMessageSubjectProcessors = []processor{ fullIssuePatternProcessor, comparePatternProcessor, fullHashPatternProcessor, linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, emojiShortCodeProcessor, emojiProcessor, } var emojiProcessors = []processor{ emojiShortCodeProcessor, emojiProcessor, } // RenderCommitMessageSubject will use the same logic as PostProcess and // RenderCommitMessage, but will disable the shortLinkProcessor and // emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set, // which changes every text node into a link to the passed default link. func RenderCommitMessageSubject( ctx *RenderContext, content string, ) (string, error) { procs := commitMessageSubjectProcessors if ctx.DefaultLink != "" { // we don't have to fear data races, because being // commitMessageSubjectProcessors of fixed len and cap, every time we // append something to it the slice is realloc+copied, so append always // generates the slice ex-novo. procs = append(procs, genDefaultLinkProcessor(ctx.DefaultLink)) } return renderProcessString(ctx, procs, content) } // RenderIssueTitle to process title on individual issue/pull page func RenderIssueTitle( ctx *RenderContext, title string, ) (string, error) { return renderProcessString(ctx, []processor{ inlineCodeBlockProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, hashCurrentPatternProcessor, emojiShortCodeProcessor, emojiProcessor, }, title) } // RenderRefIssueTitle to process title on places where an issue is referenced func RenderRefIssueTitle( ctx *RenderContext, title string, ) (string, error) { return renderProcessString(ctx, []processor{ inlineCodeBlockProcessor, issueIndexPatternProcessor, emojiShortCodeProcessor, emojiProcessor, }, title) } func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) { var buf strings.Builder if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil { return "", err } return buf.String(), nil } // RenderDescriptionHTML will use similar logic as PostProcess, but will // use a single special linkProcessor. func RenderDescriptionHTML( ctx *RenderContext, content string, ) (string, error) { return renderProcessString(ctx, []processor{ descriptionLinkProcessor, emojiShortCodeProcessor, emojiProcessor, }, content) } // RenderEmoji for when we want to just process emoji and shortcodes // in various places it isn't already run through the normal markdown processor func RenderEmoji( ctx *RenderContext, content string, ) (string, error) { return renderProcessString(ctx, emojiProcessors, content) } var ( tagCleaner = regexp.MustCompile(`<((?:/?\w+/\w+)|(?:/[\w ]+/)|(/?[hH][tT][mM][lL]\b)|(/?[hH][eE][aA][dD]\b))`) nulCleaner = strings.NewReplacer("\000", "") ) func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error { defer ctx.Cancel() // FIXME: don't read all content to memory rawHTML, err := io.ReadAll(input) if err != nil { return err } // parse the HTML node, err := html.Parse(io.MultiReader( // prepend "<html><body>" strings.NewReader("<html><body>"), // Strip out nuls - they're always invalid bytes.NewReader(tagCleaner.ReplaceAll([]byte(nulCleaner.Replace(string(rawHTML))), []byte("<$1"))), // close the tags strings.NewReader("</body></html>"), )) if err != nil { return &postProcessError{"invalid HTML", err} } if node.Type == html.DocumentNode { node = node.FirstChild } visitNode(ctx, procs, node) newNodes := make([]*html.Node, 0, 5) if node.Data == "html" { node = node.FirstChild for node != nil && node.Data != "body" { node = node.NextSibling } } if node != nil { if node.Data == "body" { child := node.FirstChild for child != nil { newNodes = append(newNodes, child) child = child.NextSibling } } else { newNodes = append(newNodes, node) } } // Render everything to buf. for _, node := range newNodes { if err := html.Render(output, node); err != nil { return &postProcessError{"error rendering processed HTML", err} } } return nil } func visitNode(ctx *RenderContext, procs []processor, node *html.Node) { // Add user-content- to IDs and "#" links if they don't already have them for idx, attr := range node.Attr { val := strings.TrimPrefix(attr.Val, "#") notHasPrefix := !strings.HasPrefix(val, "user-content-") && !blackfridayExtRegex.MatchString(val) if attr.Key == "id" && notHasPrefix { node.Attr[idx].Val = "user-content-" + attr.Val } if attr.Key == "href" && strings.HasPrefix(attr.Val, "#") && notHasPrefix { node.Attr[idx].Val = "#user-content-" + val } if attr.Key == "class" && attr.Val == "emoji" { procs = nil } } // We ignore code and pre. switch node.Type { case html.TextNode: processTextNodes(ctx, procs, node) case html.ElementNode: if node.Data == "img" { for i, attr := range node.Attr { if attr.Key != "src" { continue } if len(attr.Val) > 0 && !IsLinkStr(attr.Val) && !strings.HasPrefix(attr.Val, "data:image/") { attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val) } attr.Val = camoHandleLink(attr.Val) node.Attr[i] = attr } } else if node.Data == "a" { // Restrict text in links to emojis procs = emojiProcessors } else if node.Data == "code" || node.Data == "pre" { return } else if node.Data == "i" { for _, attr := range node.Attr { if attr.Key != "class" { continue } classes := strings.Split(attr.Val, " ") for i, class := range classes { if class == "icon" { classes[0], classes[i] = classes[i], classes[0] attr.Val = strings.Join(classes, " ") // Remove all children of icons child := node.FirstChild for child != nil { node.RemoveChild(child) child = node.FirstChild } break } } } } for n := node.FirstChild; n != nil; n = n.NextSibling { visitNode(ctx, procs, n) } default: } // ignore everything else } // processTextNodes runs the passed node through various processors, in order to handle // all kinds of special links handled by the post-processing. func processTextNodes(ctx *RenderContext, procs []processor, node *html.Node) { for _, p := range procs { p(ctx, node) } } // createKeyword() renders a highlighted version of an action keyword func createKeyword(content string) *html.Node { span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: keywordClass}) text := &html.Node{ Type: html.TextNode, Data: content, } span.AppendChild(text) return span } func createInlineCode(content string) *html.Node { code := &html.Node{ Type: html.ElementNode, Data: atom.Code.String(), Attr: []html.Attribute{}, } code.Attr = append(code.Attr, html.Attribute{Key: "class", Val: "inline-code-block"}) text := &html.Node{ Type: html.TextNode, Data: content, } code.AppendChild(text) return code } func createEmoji(content, class, name, alias string) *html.Node { span := &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{}, } if class != "" { span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class}) } if name != "" { span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name}) } if alias != "" { span.Attr = append(span.Attr, html.Attribute{Key: "data-alias", Val: alias}) } text := &html.Node{ Type: html.TextNode, Data: content, }