mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 02:46:04 +01:00 
			
		
		
		
	Fix markdown render behaviors (#34122)
* Fix #27645 * Add config options `MATH_CODE_BLOCK_DETECTION`, problematic syntaxes are disabled by default * Fix #33639 * Add config options `RENDER_OPTIONS_*`, old behaviors are kept
This commit is contained in:
		| @@ -1413,14 +1413,14 @@ LEVEL = Info | ||||
| ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; | ||||
| ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; | ||||
| ;; | ||||
| ;; Render soft line breaks as hard line breaks, which means a single newline character between | ||||
| ;; paragraphs will cause a line break and adding trailing whitespace to paragraphs is not | ||||
| ;; necessary to force a line break. | ||||
| ;; Render soft line breaks as hard line breaks for comments | ||||
| ;ENABLE_HARD_LINE_BREAK_IN_COMMENTS = true | ||||
| ;; | ||||
| ;; Render soft line breaks as hard line breaks for markdown documents | ||||
| ;ENABLE_HARD_LINE_BREAK_IN_DOCUMENTS = false | ||||
| ;; Customize render options for different contexts. Set to "none" to disable the defaults, or use comma separated list: | ||||
| ;; * short-issue-pattern: recognized "#123" issue reference and render it as a link to the issue | ||||
| ;; * new-line-hard-break: render soft line breaks as hard line breaks, which means a single newline character between | ||||
| ;;   paragraphs will cause a line break and adding trailing whitespace to paragraphs is not | ||||
| ;;   necessary to force a line break. | ||||
| ;RENDER_OPTIONS_COMMENT = short-issue-pattern, new-line-hard-break | ||||
| ;RENDER_OPTIONS_WIKI = short-issue-pattern | ||||
| ;RENDER_OPTIONS_REPO_FILE = | ||||
| ;; | ||||
| ;; Comma separated list of custom URL-Schemes that are allowed as links when rendering Markdown | ||||
| ;; for example git,magnet,ftp (more at https://en.wikipedia.org/wiki/List_of_URI_schemes) | ||||
| @@ -1434,6 +1434,12 @@ LEVEL = Info | ||||
| ;; | ||||
| ;; Enables math inline and block detection | ||||
| ;ENABLE_MATH = true | ||||
| ;; | ||||
| ;; Enable delimiters for math code block detection. Set to "none" to disable all, | ||||
| ;; or use comma separated list: inline-dollar, inline-parentheses, block-dollar, block-square-brackets | ||||
| ;; Defaults to "inline-dollar,block-dollar" to follow GitHub's behavior. | ||||
| ;MATH_CODE_BLOCK_DETECTION = | ||||
| ;; | ||||
|  | ||||
| ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; | ||||
| ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; | ||||
|   | ||||
| @@ -56,7 +56,7 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor | ||||
| 	if repo != nil { | ||||
| 		helper.repoLink = repo.Link() | ||||
| 		helper.commitChecker = newCommitChecker(ctx, repo) | ||||
| 		rctx = rctx.WithMetas(repo.ComposeMetas(ctx)) | ||||
| 		rctx = rctx.WithMetas(repo.ComposeCommentMetas(ctx)) | ||||
| 	} else { | ||||
| 		// this is almost dead code, only to pass the incorrect tests | ||||
| 		helper.repoLink = fmt.Sprintf("%s/%s", helper.opts.DeprecatedOwnerName, helper.opts.DeprecatedRepoName) | ||||
| @@ -64,7 +64,7 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor | ||||
| 			"user": helper.opts.DeprecatedOwnerName, | ||||
| 			"repo": helper.opts.DeprecatedRepoName, | ||||
|  | ||||
| 			"markdownLineBreakStyle":       "comment", | ||||
| 			"markdownNewLineHardBreak":     "true", | ||||
| 			"markupAllowShortIssuePattern": "true", | ||||
| 		}) | ||||
| 	} | ||||
|   | ||||
| @@ -61,15 +61,13 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, | ||||
| 	if repo != nil { | ||||
| 		helper.repoLink = repo.Link() | ||||
| 		helper.commitChecker = newCommitChecker(ctx, repo) | ||||
| 		rctx = rctx.WithMetas(repo.ComposeDocumentMetas(ctx)) | ||||
| 		rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx)) | ||||
| 	} else { | ||||
| 		// this is almost dead code, only to pass the incorrect tests | ||||
| 		helper.repoLink = fmt.Sprintf("%s/%s", helper.opts.DeprecatedOwnerName, helper.opts.DeprecatedRepoName) | ||||
| 		rctx = rctx.WithMetas(map[string]string{ | ||||
| 			"user": helper.opts.DeprecatedOwnerName, | ||||
| 			"repo": helper.opts.DeprecatedRepoName, | ||||
|  | ||||
| 			"markdownLineBreakStyle": "document", | ||||
| 		}) | ||||
| 	} | ||||
| 	rctx = rctx.WithHelper(helper) | ||||
|   | ||||
| @@ -68,7 +68,6 @@ func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, | ||||
| 			"user": helper.opts.DeprecatedOwnerName, | ||||
| 			"repo": helper.opts.DeprecatedRepoName, | ||||
|  | ||||
| 			"markdownLineBreakStyle":       "document", | ||||
| 			"markupAllowShortIssuePattern": "true", | ||||
| 		}) | ||||
| 	} | ||||
|   | ||||
| @@ -512,15 +512,15 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin | ||||
| 			"repo": repo.Name, | ||||
| 		} | ||||
|  | ||||
| 		unit, err := repo.GetUnit(ctx, unit.TypeExternalTracker) | ||||
| 		unitExternalTracker, err := repo.GetUnit(ctx, unit.TypeExternalTracker) | ||||
| 		if err == nil { | ||||
| 			metas["format"] = unit.ExternalTrackerConfig().ExternalTrackerFormat | ||||
| 			switch unit.ExternalTrackerConfig().ExternalTrackerStyle { | ||||
| 			metas["format"] = unitExternalTracker.ExternalTrackerConfig().ExternalTrackerFormat | ||||
| 			switch unitExternalTracker.ExternalTrackerConfig().ExternalTrackerStyle { | ||||
| 			case markup.IssueNameStyleAlphanumeric: | ||||
| 				metas["style"] = markup.IssueNameStyleAlphanumeric | ||||
| 			case markup.IssueNameStyleRegexp: | ||||
| 				metas["style"] = markup.IssueNameStyleRegexp | ||||
| 				metas["regexp"] = unit.ExternalTrackerConfig().ExternalTrackerRegexpPattern | ||||
| 				metas["regexp"] = unitExternalTracker.ExternalTrackerConfig().ExternalTrackerRegexpPattern | ||||
| 			default: | ||||
| 				metas["style"] = markup.IssueNameStyleNumeric | ||||
| 			} | ||||
| @@ -544,11 +544,11 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin | ||||
| 	return repo.commonRenderingMetas | ||||
| } | ||||
|  | ||||
| // ComposeMetas composes a map of metas for properly rendering comments or comment-like contents (commit message) | ||||
| func (repo *Repository) ComposeMetas(ctx context.Context) map[string]string { | ||||
| // ComposeCommentMetas composes a map of metas for properly rendering comments or comment-like contents (commit message) | ||||
| func (repo *Repository) ComposeCommentMetas(ctx context.Context) map[string]string { | ||||
| 	metas := maps.Clone(repo.composeCommonMetas(ctx)) | ||||
| 	metas["markdownLineBreakStyle"] = "comment" | ||||
| 	metas["markupAllowShortIssuePattern"] = "true" | ||||
| 	metas["markdownNewLineHardBreak"] = strconv.FormatBool(setting.Markdown.RenderOptionsComment.NewLineHardBreak) | ||||
| 	metas["markupAllowShortIssuePattern"] = strconv.FormatBool(setting.Markdown.RenderOptionsComment.ShortIssuePattern) | ||||
| 	return metas | ||||
| } | ||||
|  | ||||
| @@ -556,16 +556,17 @@ func (repo *Repository) ComposeMetas(ctx context.Context) map[string]string { | ||||
| func (repo *Repository) ComposeWikiMetas(ctx context.Context) map[string]string { | ||||
| 	// does wiki need the "teams" and "org" from common metas? | ||||
| 	metas := maps.Clone(repo.composeCommonMetas(ctx)) | ||||
| 	metas["markdownLineBreakStyle"] = "document" | ||||
| 	metas["markupAllowShortIssuePattern"] = "true" | ||||
| 	metas["markdownNewLineHardBreak"] = strconv.FormatBool(setting.Markdown.RenderOptionsWiki.NewLineHardBreak) | ||||
| 	metas["markupAllowShortIssuePattern"] = strconv.FormatBool(setting.Markdown.RenderOptionsWiki.ShortIssuePattern) | ||||
| 	return metas | ||||
| } | ||||
|  | ||||
| // ComposeDocumentMetas composes a map of metas for properly rendering documents (repo files) | ||||
| func (repo *Repository) ComposeDocumentMetas(ctx context.Context) map[string]string { | ||||
| // ComposeRepoFileMetas composes a map of metas for properly rendering documents (repo files) | ||||
| func (repo *Repository) ComposeRepoFileMetas(ctx context.Context) map[string]string { | ||||
| 	// does document(file) need the "teams" and "org" from common metas? | ||||
| 	metas := maps.Clone(repo.composeCommonMetas(ctx)) | ||||
| 	metas["markdownLineBreakStyle"] = "document" | ||||
| 	metas["markdownNewLineHardBreak"] = strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.NewLineHardBreak) | ||||
| 	metas["markupAllowShortIssuePattern"] = strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.ShortIssuePattern) | ||||
| 	return metas | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -86,7 +86,7 @@ func TestMetas(t *testing.T) { | ||||
|  | ||||
| 	repo.Units = nil | ||||
|  | ||||
| 	metas := repo.ComposeMetas(db.DefaultContext) | ||||
| 	metas := repo.ComposeCommentMetas(db.DefaultContext) | ||||
| 	assert.Equal(t, "testRepo", metas["repo"]) | ||||
| 	assert.Equal(t, "testOwner", metas["user"]) | ||||
|  | ||||
| @@ -100,7 +100,7 @@ func TestMetas(t *testing.T) { | ||||
| 	testSuccess := func(expectedStyle string) { | ||||
| 		repo.Units = []*RepoUnit{&externalTracker} | ||||
| 		repo.commonRenderingMetas = nil | ||||
| 		metas := repo.ComposeMetas(db.DefaultContext) | ||||
| 		metas := repo.ComposeCommentMetas(db.DefaultContext) | ||||
| 		assert.Equal(t, expectedStyle, metas["style"]) | ||||
| 		assert.Equal(t, "testRepo", metas["repo"]) | ||||
| 		assert.Equal(t, "testOwner", metas["user"]) | ||||
| @@ -121,7 +121,7 @@ func TestMetas(t *testing.T) { | ||||
| 	repo, err := GetRepositoryByID(db.DefaultContext, 3) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	metas = repo.ComposeMetas(db.DefaultContext) | ||||
| 	metas = repo.ComposeCommentMetas(db.DefaultContext) | ||||
| 	assert.Contains(t, metas, "org") | ||||
| 	assert.Contains(t, metas, "teams") | ||||
| 	assert.Equal(t, "org3", metas["org"]) | ||||
|   | ||||
| @@ -9,7 +9,6 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/container" | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| 	"code.gitea.io/gitea/modules/markup/internal" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
|  | ||||
| 	"github.com/yuin/goldmark/ast" | ||||
| 	east "github.com/yuin/goldmark/extension/ast" | ||||
| @@ -69,16 +68,8 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa | ||||
| 			g.transformList(ctx, v, rc) | ||||
| 		case *ast.Text: | ||||
| 			if v.SoftLineBreak() && !v.HardLineBreak() { | ||||
| 				// TODO: this was a quite unclear part, old code: `if metas["mode"] != "document" { use comment link break setting }` | ||||
| 				// many places render non-comment contents with no mode=document, then these contents also use comment's hard line break setting | ||||
| 				// especially in many tests. | ||||
| 				markdownLineBreakStyle := ctx.RenderOptions.Metas["markdownLineBreakStyle"] | ||||
| 				switch markdownLineBreakStyle { | ||||
| 				case "comment": | ||||
| 					v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments) | ||||
| 				case "document": | ||||
| 					v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments) | ||||
| 				} | ||||
| 				newLineHardBreak := ctx.RenderOptions.Metas["markdownNewLineHardBreak"] == "true" | ||||
| 				v.SetHardLineBreak(newLineHardBreak) | ||||
| 			} | ||||
| 		case *ast.CodeSpan: | ||||
| 			g.transformCodeSpan(ctx, v, reader) | ||||
|   | ||||
| @@ -126,11 +126,11 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender { | ||||
| 				highlighting.WithWrapperRenderer(r.highlightingRenderer), | ||||
| 			), | ||||
| 			math.NewExtension(&ctx.RenderInternal, math.Options{ | ||||
| 				Enabled:           setting.Markdown.EnableMath, | ||||
| 				ParseDollarInline: true, | ||||
| 				ParseDollarBlock:  true, | ||||
| 				ParseSquareBlock:  true, // TODO: this is a bad syntax "\[ ... \]", it conflicts with normal markdown escaping, it should be deprecated in the future (by some config options) | ||||
| 				// ParseBracketInline: true, // TODO: this is also a bad syntax "\( ... \)", it also conflicts, it should be deprecated in the future | ||||
| 				Enabled:                  setting.Markdown.EnableMath, | ||||
| 				ParseInlineDollar:        setting.Markdown.MathCodeBlockOptions.ParseInlineDollar, | ||||
| 				ParseInlineParentheses:   setting.Markdown.MathCodeBlockOptions.ParseInlineParentheses, // this is a bad syntax "\( ... \)", it conflicts with normal markdown escaping | ||||
| 				ParseBlockDollar:         setting.Markdown.MathCodeBlockOptions.ParseBlockDollar, | ||||
| 				ParseBlockSquareBrackets: setting.Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets, //  this is a bad syntax "\[ ... \]", it conflicts with normal markdown escaping | ||||
| 			}), | ||||
| 			meta.Meta, | ||||
| 		), | ||||
|   | ||||
| @@ -8,6 +8,8 @@ import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/test" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| @@ -15,6 +17,7 @@ import ( | ||||
| const nl = "\n" | ||||
|  | ||||
| func TestMathRender(t *testing.T) { | ||||
| 	setting.Markdown.MathCodeBlockOptions = setting.MarkdownMathCodeBlockOptions{ParseInlineDollar: true, ParseInlineParentheses: true} | ||||
| 	testcases := []struct { | ||||
| 		testcase string | ||||
| 		expected string | ||||
| @@ -69,7 +72,7 @@ func TestMathRender(t *testing.T) { | ||||
| 		}, | ||||
| 		{ | ||||
| 			"$$a$$", | ||||
| 			`<code class="language-math display">a</code>` + nl, | ||||
| 			`<p><code class="language-math">a</code></p>` + nl, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"$$a$$ test", | ||||
| @@ -111,6 +114,7 @@ func TestMathRender(t *testing.T) { | ||||
| } | ||||
|  | ||||
| func TestMathRenderBlockIndent(t *testing.T) { | ||||
| 	setting.Markdown.MathCodeBlockOptions = setting.MarkdownMathCodeBlockOptions{ParseBlockDollar: true, ParseBlockSquareBrackets: true} | ||||
| 	testcases := []struct { | ||||
| 		name     string | ||||
| 		testcase string | ||||
| @@ -243,3 +247,64 @@ x | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestMathRenderOptions(t *testing.T) { | ||||
| 	setting.Markdown.MathCodeBlockOptions = setting.MarkdownMathCodeBlockOptions{} | ||||
| 	defer test.MockVariableValue(&setting.Markdown.MathCodeBlockOptions) | ||||
| 	test := func(t *testing.T, expected, input string) { | ||||
| 		res, err := RenderString(markup.NewTestRenderContext(), input) | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(res)), "input: %s", input) | ||||
| 	} | ||||
|  | ||||
| 	// default (non-conflict) inline syntax | ||||
| 	test(t, `<p><code class="language-math">a</code></p>`, "$`a`$") | ||||
|  | ||||
| 	// ParseInlineDollar | ||||
| 	test(t, `<p>$a$</p>`, `$a$`) | ||||
| 	setting.Markdown.MathCodeBlockOptions.ParseInlineDollar = true | ||||
| 	test(t, `<p><code class="language-math">a</code></p>`, `$a$`) | ||||
|  | ||||
| 	// ParseInlineParentheses | ||||
| 	test(t, `<p>(a)</p>`, `\(a\)`) | ||||
| 	setting.Markdown.MathCodeBlockOptions.ParseInlineParentheses = true | ||||
| 	test(t, `<p><code class="language-math">a</code></p>`, `\(a\)`) | ||||
|  | ||||
| 	// ParseBlockDollar | ||||
| 	test(t, `<p>$$ | ||||
| a | ||||
| $$</p> | ||||
| `, ` | ||||
| $$ | ||||
| a | ||||
| $$ | ||||
| `) | ||||
| 	setting.Markdown.MathCodeBlockOptions.ParseBlockDollar = true | ||||
| 	test(t, `<pre class="code-block is-loading"><code class="language-math display"> | ||||
| a | ||||
| </code></pre> | ||||
| `, ` | ||||
| $$ | ||||
| a | ||||
| $$ | ||||
| `) | ||||
|  | ||||
| 	// ParseBlockSquareBrackets | ||||
| 	test(t, `<p>[ | ||||
| a | ||||
| ]</p> | ||||
| `, ` | ||||
| \[ | ||||
| a | ||||
| \] | ||||
| `) | ||||
| 	setting.Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets = true | ||||
| 	test(t, `<pre class="code-block is-loading"><code class="language-math display"> | ||||
| a | ||||
| </code></pre> | ||||
| `, ` | ||||
| \[ | ||||
| a | ||||
| \] | ||||
| `) | ||||
| } | ||||
|   | ||||
| @@ -15,26 +15,26 @@ type inlineParser struct { | ||||
| 	trigger              []byte | ||||
| 	endBytesSingleDollar []byte | ||||
| 	endBytesDoubleDollar []byte | ||||
| 	endBytesBracket      []byte | ||||
| 	endBytesParentheses  []byte | ||||
| 	enableInlineDollar   bool | ||||
| } | ||||
|  | ||||
| var defaultInlineDollarParser = &inlineParser{ | ||||
| 	trigger:              []byte{'$'}, | ||||
| 	endBytesSingleDollar: []byte{'$'}, | ||||
| 	endBytesDoubleDollar: []byte{'$', '$'}, | ||||
| func NewInlineDollarParser(enableInlineDollar bool) parser.InlineParser { | ||||
| 	return &inlineParser{ | ||||
| 		trigger:              []byte{'$'}, | ||||
| 		endBytesSingleDollar: []byte{'$'}, | ||||
| 		endBytesDoubleDollar: []byte{'$', '$'}, | ||||
| 		enableInlineDollar:   enableInlineDollar, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func NewInlineDollarParser() parser.InlineParser { | ||||
| 	return defaultInlineDollarParser | ||||
| var defaultInlineParenthesesParser = &inlineParser{ | ||||
| 	trigger:             []byte{'\\', '('}, | ||||
| 	endBytesParentheses: []byte{'\\', ')'}, | ||||
| } | ||||
|  | ||||
| var defaultInlineBracketParser = &inlineParser{ | ||||
| 	trigger:         []byte{'\\', '('}, | ||||
| 	endBytesBracket: []byte{'\\', ')'}, | ||||
| } | ||||
|  | ||||
| func NewInlineBracketParser() parser.InlineParser { | ||||
| 	return defaultInlineBracketParser | ||||
| func NewInlineParenthesesParser() parser.InlineParser { | ||||
| 	return defaultInlineParenthesesParser | ||||
| } | ||||
|  | ||||
| // Trigger triggers this parser on $ or \ | ||||
| @@ -46,7 +46,7 @@ func isPunctuation(b byte) bool { | ||||
| 	return b == '.' || b == '!' || b == '?' || b == ',' || b == ';' || b == ':' | ||||
| } | ||||
|  | ||||
| func isBracket(b byte) bool { | ||||
| func isParenthesesClose(b byte) bool { | ||||
| 	return b == ')' | ||||
| } | ||||
|  | ||||
| @@ -86,7 +86,11 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. | ||||
| 		} | ||||
| 	} else { | ||||
| 		startMarkLen = 2 | ||||
| 		stopMark = parser.endBytesBracket | ||||
| 		stopMark = parser.endBytesParentheses | ||||
| 	} | ||||
|  | ||||
| 	if line[0] == '$' && !parser.enableInlineDollar && (len(line) == 1 || line[1] != '`') { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if checkSurrounding { | ||||
| @@ -110,7 +114,7 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. | ||||
| 				succeedingCharacter = line[i+len(stopMark)] | ||||
| 			} | ||||
| 			// check valid ending character | ||||
| 			isValidEndingChar := isPunctuation(succeedingCharacter) || isBracket(succeedingCharacter) || | ||||
| 			isValidEndingChar := isPunctuation(succeedingCharacter) || isParenthesesClose(succeedingCharacter) || | ||||
| 				succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 | ||||
| 			if checkSurrounding && !isValidEndingChar { | ||||
| 				break | ||||
|   | ||||
| @@ -14,10 +14,11 @@ import ( | ||||
| ) | ||||
|  | ||||
| type Options struct { | ||||
| 	Enabled           bool | ||||
| 	ParseDollarInline bool | ||||
| 	ParseDollarBlock  bool | ||||
| 	ParseSquareBlock  bool | ||||
| 	Enabled                  bool | ||||
| 	ParseInlineDollar        bool // inline $$ xxx $$ text | ||||
| 	ParseInlineParentheses   bool // inline \( xxx \) text | ||||
| 	ParseBlockDollar         bool // block $$ multiple-line $$ text | ||||
| 	ParseBlockSquareBrackets bool // block \[ multiple-line \] text | ||||
| } | ||||
|  | ||||
| // Extension is a math extension | ||||
| @@ -42,16 +43,16 @@ func (e *Extension) Extend(m goldmark.Markdown) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	inlines := []util.PrioritizedValue{util.Prioritized(NewInlineBracketParser(), 501)} | ||||
| 	if e.options.ParseDollarInline { | ||||
| 		inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 502)) | ||||
| 	var inlines []util.PrioritizedValue | ||||
| 	if e.options.ParseInlineParentheses { | ||||
| 		inlines = append(inlines, util.Prioritized(NewInlineParenthesesParser(), 501)) | ||||
| 	} | ||||
| 	inlines = append(inlines, util.Prioritized(NewInlineDollarParser(e.options.ParseInlineDollar), 502)) | ||||
|  | ||||
| 	m.Parser().AddOptions(parser.WithInlineParsers(inlines...)) | ||||
|  | ||||
| 	m.Parser().AddOptions(parser.WithBlockParsers( | ||||
| 		util.Prioritized(NewBlockParser(e.options.ParseDollarBlock, e.options.ParseSquareBlock), 701), | ||||
| 		util.Prioritized(NewBlockParser(e.options.ParseBlockDollar, e.options.ParseBlockSquareBrackets), 701), | ||||
| 	)) | ||||
|  | ||||
| 	m.Renderer().AddOptions(renderer.WithNodeRenderers( | ||||
| 		util.Prioritized(NewBlockRenderer(e.renderInternal), 501), | ||||
| 		util.Prioritized(NewInlineRenderer(e.renderInternal), 502), | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| @@ -46,7 +47,7 @@ type RenderOptions struct { | ||||
| 	// user&repo, format&style®exp (for external issue pattern), teams&org (for mention) | ||||
| 	// RefTypeNameSubURL (for iframe&asciicast) | ||||
| 	// markupAllowShortIssuePattern | ||||
| 	// markdownLineBreakStyle (comment, document) | ||||
| 	// markdownNewLineHardBreak | ||||
| 	Metas map[string]string | ||||
|  | ||||
| 	// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page | ||||
| @@ -247,7 +248,8 @@ func Init(renderHelpFuncs *RenderHelperFuncs) { | ||||
| } | ||||
|  | ||||
| func ComposeSimpleDocumentMetas() map[string]string { | ||||
| 	return map[string]string{"markdownLineBreakStyle": "document"} | ||||
| 	// TODO: there is no separate config option for "simple document" rendering, so temporarily use the same config as "repo file" | ||||
| 	return map[string]string{"markdownNewLineHardBreak": strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.NewLineHardBreak)} | ||||
| } | ||||
|  | ||||
| type TestRenderHelper struct { | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import ( | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
|  | ||||
| // ExternalMarkupRenderers represents the external markup renderers | ||||
| @@ -23,18 +24,33 @@ const ( | ||||
| 	RenderContentModeIframe      = "iframe" | ||||
| ) | ||||
|  | ||||
| type MarkdownRenderOptions struct { | ||||
| 	NewLineHardBreak  bool | ||||
| 	ShortIssuePattern bool // Actually it is a "markup" option because it is used in "post processor" | ||||
| } | ||||
|  | ||||
| type MarkdownMathCodeBlockOptions struct { | ||||
| 	ParseInlineDollar        bool | ||||
| 	ParseInlineParentheses   bool | ||||
| 	ParseBlockDollar         bool | ||||
| 	ParseBlockSquareBrackets bool | ||||
| } | ||||
|  | ||||
| // Markdown settings | ||||
| var Markdown = struct { | ||||
| 	EnableHardLineBreakInComments  bool | ||||
| 	EnableHardLineBreakInDocuments bool | ||||
| 	CustomURLSchemes               []string `ini:"CUSTOM_URL_SCHEMES"` | ||||
| 	FileExtensions                 []string | ||||
| 	EnableMath                     bool | ||||
| 	RenderOptionsComment  MarkdownRenderOptions `ini:"-"` | ||||
| 	RenderOptionsWiki     MarkdownRenderOptions `ini:"-"` | ||||
| 	RenderOptionsRepoFile MarkdownRenderOptions `ini:"-"` | ||||
|  | ||||
| 	CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"` // Actually it is a "markup" option because it is used in "post processor" | ||||
| 	FileExtensions   []string | ||||
|  | ||||
| 	EnableMath             bool | ||||
| 	MathCodeBlockDetection []string | ||||
| 	MathCodeBlockOptions   MarkdownMathCodeBlockOptions `ini:"-"` | ||||
| }{ | ||||
| 	EnableHardLineBreakInComments:  true, | ||||
| 	EnableHardLineBreakInDocuments: false, | ||||
| 	FileExtensions:                 strings.Split(".md,.markdown,.mdown,.mkd,.livemd", ","), | ||||
| 	EnableMath:                     true, | ||||
| 	FileExtensions: strings.Split(".md,.markdown,.mdown,.mkd,.livemd", ","), | ||||
| 	EnableMath:     true, | ||||
| } | ||||
|  | ||||
| // MarkupRenderer defines the external parser configured in ini | ||||
| @@ -60,6 +76,56 @@ type MarkupSanitizerRule struct { | ||||
|  | ||||
| func loadMarkupFrom(rootCfg ConfigProvider) { | ||||
| 	mustMapSetting(rootCfg, "markdown", &Markdown) | ||||
| 	const none = "none" | ||||
|  | ||||
| 	const renderOptionShortIssuePattern = "short-issue-pattern" | ||||
| 	const renderOptionNewLineHardBreak = "new-line-hard-break" | ||||
| 	cfgMarkdown := rootCfg.Section("markdown") | ||||
| 	parseMarkdownRenderOptions := func(key string, defaults []string) (ret MarkdownRenderOptions) { | ||||
| 		options := cfgMarkdown.Key(key).Strings(",") | ||||
| 		options = util.IfEmpty(options, defaults) | ||||
| 		for _, opt := range options { | ||||
| 			switch opt { | ||||
| 			case renderOptionShortIssuePattern: | ||||
| 				ret.ShortIssuePattern = true | ||||
| 			case renderOptionNewLineHardBreak: | ||||
| 				ret.NewLineHardBreak = true | ||||
| 			case none: | ||||
| 				ret = MarkdownRenderOptions{} | ||||
| 			case "": | ||||
| 			default: | ||||
| 				log.Error("Unknown markdown render option in %s: %s", key, opt) | ||||
| 			} | ||||
| 		} | ||||
| 		return ret | ||||
| 	} | ||||
| 	Markdown.RenderOptionsComment = parseMarkdownRenderOptions("RENDER_OPTIONS_COMMENT", []string{renderOptionShortIssuePattern, renderOptionNewLineHardBreak}) | ||||
| 	Markdown.RenderOptionsWiki = parseMarkdownRenderOptions("RENDER_OPTIONS_WIKI", []string{renderOptionShortIssuePattern}) | ||||
| 	Markdown.RenderOptionsRepoFile = parseMarkdownRenderOptions("RENDER_OPTIONS_REPO_FILE", nil) | ||||
|  | ||||
| 	const mathCodeInlineDollar = "inline-dollar" | ||||
| 	const mathCodeInlineParentheses = "inline-parentheses" | ||||
| 	const mathCodeBlockDollar = "block-dollar" | ||||
| 	const mathCodeBlockSquareBrackets = "block-square-brackets" | ||||
| 	Markdown.MathCodeBlockDetection = util.IfEmpty(Markdown.MathCodeBlockDetection, []string{mathCodeInlineDollar, mathCodeBlockDollar}) | ||||
| 	Markdown.MathCodeBlockOptions = MarkdownMathCodeBlockOptions{} | ||||
| 	for _, s := range Markdown.MathCodeBlockDetection { | ||||
| 		switch s { | ||||
| 		case mathCodeInlineDollar: | ||||
| 			Markdown.MathCodeBlockOptions.ParseInlineDollar = true | ||||
| 		case mathCodeInlineParentheses: | ||||
| 			Markdown.MathCodeBlockOptions.ParseInlineParentheses = true | ||||
| 		case mathCodeBlockDollar: | ||||
| 			Markdown.MathCodeBlockOptions.ParseBlockDollar = true | ||||
| 		case mathCodeBlockSquareBrackets: | ||||
| 			Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets = true | ||||
| 		case none: | ||||
| 			Markdown.MathCodeBlockOptions = MarkdownMathCodeBlockOptions{} | ||||
| 		case "": | ||||
| 		default: | ||||
| 			log.Error("Unknown math code block detection option: %s", s) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(5000) | ||||
| 	ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10) | ||||
|   | ||||
							
								
								
									
										51
									
								
								modules/setting/markup_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								modules/setting/markup_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| // Copyright 2025 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package setting | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestLoadMarkup(t *testing.T) { | ||||
| 	cfg, _ := NewConfigProviderFromData(``) | ||||
| 	loadMarkupFrom(cfg) | ||||
| 	assert.Equal(t, MarkdownMathCodeBlockOptions{ParseInlineDollar: true, ParseBlockDollar: true}, Markdown.MathCodeBlockOptions) | ||||
| 	assert.Equal(t, MarkdownRenderOptions{NewLineHardBreak: true, ShortIssuePattern: true}, Markdown.RenderOptionsComment) | ||||
| 	assert.Equal(t, MarkdownRenderOptions{ShortIssuePattern: true}, Markdown.RenderOptionsWiki) | ||||
| 	assert.Equal(t, MarkdownRenderOptions{}, Markdown.RenderOptionsRepoFile) | ||||
|  | ||||
| 	t.Run("Math", func(t *testing.T) { | ||||
| 		cfg, _ = NewConfigProviderFromData(` | ||||
| [markdown] | ||||
| MATH_CODE_BLOCK_DETECTION = none | ||||
| `) | ||||
| 		loadMarkupFrom(cfg) | ||||
| 		assert.Equal(t, MarkdownMathCodeBlockOptions{}, Markdown.MathCodeBlockOptions) | ||||
|  | ||||
| 		cfg, _ = NewConfigProviderFromData(` | ||||
| [markdown] | ||||
| MATH_CODE_BLOCK_DETECTION = inline-dollar, inline-parentheses, block-dollar, block-square-brackets | ||||
| `) | ||||
| 		loadMarkupFrom(cfg) | ||||
| 		assert.Equal(t, MarkdownMathCodeBlockOptions{ParseInlineDollar: true, ParseInlineParentheses: true, ParseBlockDollar: true, ParseBlockSquareBrackets: true}, Markdown.MathCodeBlockOptions) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("Render", func(t *testing.T) { | ||||
| 		cfg, _ = NewConfigProviderFromData(` | ||||
| [markdown] | ||||
| RENDER_OPTIONS_COMMENT = none | ||||
| `) | ||||
| 		loadMarkupFrom(cfg) | ||||
| 		assert.Equal(t, MarkdownRenderOptions{}, Markdown.RenderOptionsComment) | ||||
|  | ||||
| 		cfg, _ = NewConfigProviderFromData(` | ||||
| [markdown] | ||||
| RENDER_OPTIONS_REPO_FILE = short-issue-pattern, new-line-hard-break | ||||
| `) | ||||
| 		loadMarkupFrom(cfg) | ||||
| 		assert.Equal(t, MarkdownRenderOptions{NewLineHardBreak: true, ShortIssuePattern: true}, Markdown.RenderOptionsRepoFile) | ||||
| 	}) | ||||
| } | ||||
| @@ -51,7 +51,7 @@ var testMetas = map[string]string{ | ||||
| 	"user":                         "user13", | ||||
| 	"repo":                         "repo11", | ||||
| 	"repoPath":                     "../../tests/gitea-repositories-meta/user13/repo11.git/", | ||||
| 	"markdownLineBreakStyle":       "comment", | ||||
| 	"markdownNewLineHardBreak":     "true", | ||||
| 	"markupAllowShortIssuePattern": "true", | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -219,6 +219,13 @@ func IfZero[T comparable](v, def T) T { | ||||
| 	return v | ||||
| } | ||||
|  | ||||
| func IfEmpty[T any](v, def []T) []T { | ||||
| 	if len(v) == 0 { | ||||
| 		return def | ||||
| 	} | ||||
| 	return v | ||||
| } | ||||
|  | ||||
| // OptionalArg helps the "optional argument" in Golang: | ||||
| // | ||||
| //	func foo(optArg ...int) { return OptionalArg(optArg) } | ||||
|   | ||||
| @@ -200,9 +200,9 @@ func ViewPost(ctx *context_module.Context) { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// TODO: "ComposeMetas" (usually for comment) is not quite right, but it is still the same as what template "RenderCommitMessage" does. | ||||
| 	// TODO: "ComposeCommentMetas" (usually for comment) is not quite right, but it is still the same as what template "RenderCommitMessage" does. | ||||
| 	// need to be refactored together in the future | ||||
| 	metas := ctx.Repo.Repository.ComposeMetas(ctx) | ||||
| 	metas := ctx.Repo.Repository.ComposeCommentMetas(ctx) | ||||
|  | ||||
| 	// the title for the "run" is from the commit message | ||||
| 	resp.State.Run.Title = run.Title | ||||
|   | ||||
| @@ -278,7 +278,7 @@ func ViewIssue(ctx *context.Context) { | ||||
| 		extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) | ||||
| 		if err == nil && extIssueUnit != nil { | ||||
| 			if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { | ||||
| 				metas := ctx.Repo.Repository.ComposeMetas(ctx) | ||||
| 				metas := ctx.Repo.Repository.ComposeCommentMetas(ctx) | ||||
| 				metas["index"] = ctx.PathParam("index") | ||||
| 				res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) | ||||
| 				if err != nil { | ||||
|   | ||||
| @@ -176,7 +176,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { | ||||
| 		if markupType != "" && !shouldRenderSource { | ||||
| 			ctx.Data["IsMarkup"] = true | ||||
| 			ctx.Data["MarkupType"] = markupType | ||||
| 			metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) | ||||
| 			metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx) | ||||
| 			metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL() | ||||
| 			rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ | ||||
| 				CurrentRefPath:  ctx.Repo.RefTypeNameSubURL(), | ||||
|   | ||||
| @@ -27,7 +27,7 @@ | ||||
| 									<button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DefaultBranchBranch.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button> | ||||
| 									{{template "repo/commit_statuses" dict "Status" (index $.CommitStatus .DefaultBranchBranch.DBBranch.CommitID) "Statuses" (index $.CommitStatuses .DefaultBranchBranch.DBBranch.CommitID)}} | ||||
| 								</div> | ||||
| 								<p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{.RepoLink}}/commit/{{PathEscape .DefaultBranchBranch.DBBranch.CommitID}}">{{ShortSha .DefaultBranchBranch.DBBranch.CommitID}}</a> · <span class="commit-message">{{ctx.RenderUtils.RenderCommitMessage .DefaultBranchBranch.DBBranch.CommitMessage (.Repository.ComposeMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .DefaultBranchBranch.DBBranch.CommitTime}}{{if .DefaultBranchBranch.DBBranch.Pusher}}  {{template "shared/user/avatarlink" dict "user" .DefaultBranchBranch.DBBranch.Pusher}}{{template "shared/user/namelink" .DefaultBranchBranch.DBBranch.Pusher}}{{end}}</p> | ||||
| 								<p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{.RepoLink}}/commit/{{PathEscape .DefaultBranchBranch.DBBranch.CommitID}}">{{ShortSha .DefaultBranchBranch.DBBranch.CommitID}}</a> · <span class="commit-message">{{ctx.RenderUtils.RenderCommitMessage .DefaultBranchBranch.DBBranch.CommitMessage (.Repository.ComposeCommentMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .DefaultBranchBranch.DBBranch.CommitTime}}{{if .DefaultBranchBranch.DBBranch.Pusher}}  {{template "shared/user/avatarlink" dict "user" .DefaultBranchBranch.DBBranch.Pusher}}{{template "shared/user/namelink" .DefaultBranchBranch.DBBranch.Pusher}}{{end}}</p> | ||||
| 							</td> | ||||
| 							{{/* FIXME: here and below, the tw-overflow-visible is not quite right but it is still needed the moment: to show the important buttons when the width is narrow */}} | ||||
| 							<td class="tw-text-right tw-overflow-visible"> | ||||
| @@ -103,7 +103,7 @@ | ||||
| 									<button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button> | ||||
| 									{{template "repo/commit_statuses" dict "Status" (index $.CommitStatus .DBBranch.CommitID) "Statuses" (index $.CommitStatuses .DBBranch.CommitID)}} | ||||
| 								</div> | ||||
| 								<p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{$.RepoLink}}/commit/{{PathEscape .DBBranch.CommitID}}">{{ShortSha .DBBranch.CommitID}}</a> · <span class="commit-message">{{ctx.RenderUtils.RenderCommitMessage .DBBranch.CommitMessage ($.Repository.ComposeMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .DBBranch.CommitTime}}{{if .DBBranch.Pusher}}  {{template "shared/user/avatarlink" dict "user" .DBBranch.Pusher}}  {{template "shared/user/namelink" .DBBranch.Pusher}}{{end}}</p> | ||||
| 								<p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{$.RepoLink}}/commit/{{PathEscape .DBBranch.CommitID}}">{{ShortSha .DBBranch.CommitID}}</a> · <span class="commit-message">{{ctx.RenderUtils.RenderCommitMessage .DBBranch.CommitMessage ($.Repository.ComposeCommentMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .DBBranch.CommitTime}}{{if .DBBranch.Pusher}}  {{template "shared/user/avatarlink" dict "user" .DBBranch.Pusher}}  {{template "shared/user/namelink" .DBBranch.Pusher}}{{end}}</p> | ||||
| 							{{end}} | ||||
| 							</td> | ||||
| 							<td class="two wide ui"> | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
| 	<div class="ui container fluid padded"> | ||||
| 		<div class="ui top attached header clearing segment tw-relative commit-header"> | ||||
| 			<div class="tw-flex tw-mb-4 tw-gap-1"> | ||||
| 				<h3 class="tw-mb-0 tw-flex-1"><span class="commit-summary" title="{{.Commit.Summary}}">{{ctx.RenderUtils.RenderCommitMessage .Commit.Message ($.Repository.ComposeMetas ctx)}}</span>{{template "repo/commit_statuses" dict "Status" .CommitStatus "Statuses" .CommitStatuses}}</h3> | ||||
| 				<h3 class="tw-mb-0 tw-flex-1"><span class="commit-summary" title="{{.Commit.Summary}}">{{ctx.RenderUtils.RenderCommitMessage .Commit.Message ($.Repository.ComposeCommentMetas ctx)}}</span>{{template "repo/commit_statuses" dict "Status" .CommitStatus "Statuses" .CommitStatuses}}</h3> | ||||
| 				{{if not $.PageIsWiki}} | ||||
| 					<div class="commit-header-buttons"> | ||||
| 						<a class="ui primary tiny button" href="{{.SourcePath}}"> | ||||
| @@ -122,7 +122,7 @@ | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 			{{if IsMultilineCommitMessage .Commit.Message}} | ||||
| 				<pre class="commit-body">{{ctx.RenderUtils.RenderCommitBody .Commit.Message ($.Repository.ComposeMetas ctx)}}</pre> | ||||
| 				<pre class="commit-body">{{ctx.RenderUtils.RenderCommitBody .Commit.Message ($.Repository.ComposeCommentMetas ctx)}}</pre> | ||||
| 			{{end}} | ||||
| 			{{template "repo/commit_load_branches_and_tags" .}} | ||||
| 		</div> | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
| 							<span class="commit-summary {{if gt .ParentCount 1}} grey text{{end}}" title="{{.Summary}}">{{.Summary | ctx.RenderUtils.RenderEmoji}}</span> | ||||
| 						{{else}} | ||||
| 							{{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape .ID.String)}} | ||||
| 							<span class="commit-summary {{if gt .ParentCount 1}} grey text{{end}}" title="{{.Summary}}">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .Message $commitLink ($.Repository.ComposeMetas ctx)}}</span> | ||||
| 							<span class="commit-summary {{if gt .ParentCount 1}} grey text{{end}}" title="{{.Summary}}">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .Message $commitLink ($.Repository.ComposeCommentMetas ctx)}}</span> | ||||
| 						{{end}} | ||||
| 						</span> | ||||
| 						{{if IsMultilineCommitMessage .Message}} | ||||
| @@ -52,7 +52,7 @@ | ||||
| 						{{end}} | ||||
| 						{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}} | ||||
| 						{{if IsMultilineCommitMessage .Message}} | ||||
| 						<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody .Message ($.Repository.ComposeMetas ctx)}}</pre> | ||||
| 						<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody .Message ($.Repository.ComposeCommentMetas ctx)}}</pre> | ||||
| 						{{end}} | ||||
| 						{{if $.CommitsTagsMap}} | ||||
| 							{{range (index $.CommitsTagsMap .ID.String)}} | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
| 		{{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape .ID.String)}} | ||||
|  | ||||
| 		<span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{.Summary}}"> | ||||
| 			{{- ctx.RenderUtils.RenderCommitMessageLinkSubject .Message $commitLink ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx) -}} | ||||
| 			{{- ctx.RenderUtils.RenderCommitMessageLinkSubject .Message $commitLink ($.comment.Issue.PullRequest.BaseRepo.ComposeCommentMetas ctx) -}} | ||||
| 		</span> | ||||
|  | ||||
| 		{{if IsMultilineCommitMessage .Message}} | ||||
| @@ -29,7 +29,7 @@ | ||||
| 	</div> | ||||
| 	{{if IsMultilineCommitMessage .Message}} | ||||
| 	<pre class="commit-body tw-ml-[33px] tw-hidden" data-singular-commit-body-for="{{$tag}}"> | ||||
| 		{{- ctx.RenderUtils.RenderCommitBody .Message ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx) -}} | ||||
| 		{{- ctx.RenderUtils.RenderCommitBody .Message ($.comment.Issue.PullRequest.BaseRepo.ComposeCommentMetas ctx) -}} | ||||
| 	</pre> | ||||
| 	{{end}} | ||||
| {{end}} | ||||
|   | ||||
| @@ -189,7 +189,7 @@ | ||||
| 				<div class="ui segment flex-text-block tw-gap-4"> | ||||
| 					{{template "shared/issueicon" .}} | ||||
| 					<div class="issue-title tw-break-anywhere"> | ||||
| 						{{ctx.RenderUtils.RenderIssueTitle .PullRequest.Issue.Title ($.Repository.ComposeMetas ctx)}} | ||||
| 						{{ctx.RenderUtils.RenderIssueTitle .PullRequest.Issue.Title ($.Repository.ComposeCommentMetas ctx)}} | ||||
| 						<span class="index">#{{.PullRequest.Issue.Index}}</span> | ||||
| 					</div> | ||||
| 					<a href="{{$.RepoLink}}/pulls/{{.PullRequest.Issue.Index}}" class="ui compact button primary"> | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
| 					{{template "repo/commit_sign_badge" dict "Commit" $commit.Commit "CommitBaseLink" (print $.RepoLink "/commit") "CommitSignVerification" $commit.Verification}} | ||||
|  | ||||
| 					<span class="message tw-inline-block gt-ellipsis"> | ||||
| 						<span>{{ctx.RenderUtils.RenderCommitMessage $commit.Subject ($.Repository.ComposeMetas ctx)}}</span> | ||||
| 						<span>{{ctx.RenderUtils.RenderCommitMessage $commit.Subject ($.Repository.ComposeCommentMetas ctx)}}</span> | ||||
| 					</span> | ||||
|  | ||||
| 					<span class="commit-refs flex-text-inline"> | ||||
|   | ||||
| @@ -13,7 +13,7 @@ | ||||
| 	{{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}} | ||||
| 	<div class="issue-title" id="issue-title-display"> | ||||
| 		<h1 class="tw-break-anywhere"> | ||||
| 			{{ctx.RenderUtils.RenderIssueTitle .Issue.Title ($.Repository.ComposeMetas ctx)}} | ||||
| 			{{ctx.RenderUtils.RenderIssueTitle .Issue.Title ($.Repository.ComposeCommentMetas ctx)}} | ||||
| 			<span class="index">#{{.Issue.Index}}</span> | ||||
| 		</h1> | ||||
| 		<div class="issue-title-buttons"> | ||||
|   | ||||
| @@ -21,10 +21,10 @@ | ||||
| 	{{template "repo/commit_statuses" dict "Status" .LatestCommitStatus "Statuses" .LatestCommitStatuses}} | ||||
|  | ||||
| 	{{$commitLink:= printf "%s/commit/%s" .RepoLink (PathEscape .LatestCommit.ID.String)}} | ||||
| 	<span class="grey commit-summary" title="{{.LatestCommit.Summary}}"><span class="message-wrapper">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .LatestCommit.Message $commitLink ($.Repository.ComposeMetas ctx)}}</span> | ||||
| 	<span class="grey commit-summary" title="{{.LatestCommit.Summary}}"><span class="message-wrapper">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .LatestCommit.Message $commitLink ($.Repository.ComposeCommentMetas ctx)}}</span> | ||||
| 		{{if IsMultilineCommitMessage .LatestCommit.Message}} | ||||
| 			<button class="ui button ellipsis-button" aria-expanded="false" data-global-click="onRepoEllipsisButtonClick">...</button> | ||||
| 			<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody .LatestCommit.Message ($.Repository.ComposeMetas ctx)}}</pre> | ||||
| 			<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody .LatestCommit.Message ($.Repository.ComposeCommentMetas ctx)}}</pre> | ||||
| 		{{end}} | ||||
| 	</span> | ||||
| {{end}} | ||||
|   | ||||
| @@ -46,7 +46,7 @@ | ||||
| 			<div class="repo-file-cell message loading-icon-2px"> | ||||
| 				{{if $commit}} | ||||
| 					{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}} | ||||
| 					{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink ($.Repository.ComposeMetas ctx)}} | ||||
| 					{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink ($.Repository.ComposeCommentMetas ctx)}} | ||||
| 				{{else}} | ||||
| 					… {{/* will be loaded again by LastCommitLoaderURL */}} | ||||
| 				{{end}} | ||||
|   | ||||
| @@ -94,7 +94,7 @@ | ||||
| 								<img class="ui avatar" src="{{$push.AvatarLink ctx .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16"> | ||||
| 								<a class="ui sha label" href="{{$commitLink}}">{{ShortSha .Sha1}}</a> | ||||
| 								<span class="text truncate"> | ||||
| 									{{ctx.RenderUtils.RenderCommitMessage .Message ($repo.ComposeMetas ctx)}} | ||||
| 									{{ctx.RenderUtils.RenderCommitMessage .Message ($repo.ComposeCommentMetas ctx)}} | ||||
| 								</span> | ||||
| 							</div> | ||||
| 						{{end}} | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| import {queryElems} from '../utils/dom.ts'; | ||||
|  | ||||
| export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> { | ||||
|   const el = elMarkup.querySelector('.asciinema-player-container'); | ||||
|   if (!el) return; | ||||
|   queryElems(elMarkup, '.asciinema-player-container', async (el) => { | ||||
|     const [player] = await Promise.all([ | ||||
|       // @ts-expect-error: module exports no types | ||||
|       import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), | ||||
|       import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), | ||||
|     ]); | ||||
|  | ||||
|   const [player] = await Promise.all([ | ||||
|     // @ts-expect-error: module exports no types | ||||
|     import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), | ||||
|     import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), | ||||
|   ]); | ||||
|  | ||||
|   player.create(el.getAttribute('data-asciinema-player-src'), el, { | ||||
|     // poster (a preview frame) to display until the playback is started. | ||||
|     // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. | ||||
|     poster: 'npt:1:0:0', | ||||
|     player.create(el.getAttribute('data-asciinema-player-src'), el, { | ||||
|       // poster (a preview frame) to display until the playback is started. | ||||
|       // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. | ||||
|       poster: 'npt:1:0:0', | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import {svg} from '../svg.ts'; | ||||
| import {queryElems} from '../utils/dom.ts'; | ||||
|  | ||||
| export function makeCodeCopyButton(): HTMLButtonElement { | ||||
|   const button = document.createElement('button'); | ||||
| @@ -8,11 +9,12 @@ export function makeCodeCopyButton(): HTMLButtonElement { | ||||
| } | ||||
|  | ||||
| export function initMarkupCodeCopy(elMarkup: HTMLElement): void { | ||||
|   const el = elMarkup.querySelector('.code-block code'); // .markup .code-block code | ||||
|   if (!el || !el.textContent) return; | ||||
|  | ||||
|   const btn = makeCodeCopyButton(); | ||||
|   // remove final trailing newline introduced during HTML rendering | ||||
|   btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, '')); | ||||
|   el.after(btn); | ||||
|   // .markup .code-block code | ||||
|   queryElems(elMarkup, '.code-block code', (el) => { | ||||
|     if (!el.textContent) return; | ||||
|     const btn = makeCodeCopyButton(); | ||||
|     // remove final trailing newline introduced during HTML rendering | ||||
|     btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, '')); | ||||
|     el.after(btn); | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import {displayError} from './common.ts'; | ||||
| import {queryElems} from '../utils/dom.ts'; | ||||
|  | ||||
| function targetElement(el: Element): {target: Element, displayAsBlock: boolean} { | ||||
|   // The target element is either the parent "code block with loading indicator", or itself | ||||
| @@ -12,35 +13,35 @@ function targetElement(el: Element): {target: Element, displayAsBlock: boolean} | ||||
| } | ||||
|  | ||||
| export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> { | ||||
|   const el = elMarkup.querySelector('code.language-math'); // .markup code.language-math' | ||||
|   if (!el) return; | ||||
|   // .markup code.language-math' | ||||
|   queryElems(elMarkup, 'code.language-math', async (el) => { | ||||
|     const [{default: katex}] = await Promise.all([ | ||||
|       import(/* webpackChunkName: "katex" */'katex'), | ||||
|       import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), | ||||
|     ]); | ||||
|  | ||||
|   const [{default: katex}] = await Promise.all([ | ||||
|     import(/* webpackChunkName: "katex" */'katex'), | ||||
|     import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), | ||||
|   ]); | ||||
|     const MAX_CHARS = 1000; | ||||
|     const MAX_SIZE = 25; | ||||
|     const MAX_EXPAND = 1000; | ||||
|  | ||||
|   const MAX_CHARS = 1000; | ||||
|   const MAX_SIZE = 25; | ||||
|   const MAX_EXPAND = 1000; | ||||
|     const {target, displayAsBlock} = targetElement(el); | ||||
|     if (target.hasAttribute('data-render-done')) return; | ||||
|     const source = el.textContent; | ||||
|  | ||||
|   const {target, displayAsBlock} = targetElement(el); | ||||
|   if (target.hasAttribute('data-render-done')) return; | ||||
|   const source = el.textContent; | ||||
|  | ||||
|   if (source.length > MAX_CHARS) { | ||||
|     displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); | ||||
|     return; | ||||
|   } | ||||
|   try { | ||||
|     const tempEl = document.createElement(displayAsBlock ? 'p' : 'span'); | ||||
|     katex.render(source, tempEl, { | ||||
|       maxSize: MAX_SIZE, | ||||
|       maxExpand: MAX_EXPAND, | ||||
|       displayMode: displayAsBlock, // katex: true for display (block) mode, false for inline mode | ||||
|     }); | ||||
|     target.replaceWith(tempEl); | ||||
|   } catch (error) { | ||||
|     displayError(target, error); | ||||
|   } | ||||
|     if (source.length > MAX_CHARS) { | ||||
|       displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       const tempEl = document.createElement(displayAsBlock ? 'p' : 'span'); | ||||
|       katex.render(source, tempEl, { | ||||
|         maxSize: MAX_SIZE, | ||||
|         maxExpand: MAX_EXPAND, | ||||
|         displayMode: displayAsBlock, // katex: true for display (block) mode, false for inline mode | ||||
|       }); | ||||
|       target.replaceWith(tempEl); | ||||
|     } catch (error) { | ||||
|       displayError(target, error); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import {isDarkTheme} from '../utils.ts'; | ||||
| import {makeCodeCopyButton} from './codecopy.ts'; | ||||
| import {displayError} from './common.ts'; | ||||
| import {queryElems} from '../utils/dom.ts'; | ||||
|  | ||||
| const {mermaidMaxSourceCharacters} = window.config; | ||||
|  | ||||
| @@ -11,77 +12,77 @@ body {margin: 0; padding: 0; overflow: hidden} | ||||
| blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`; | ||||
|  | ||||
| export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> { | ||||
|   const el = elMarkup.querySelector('code.language-mermaid'); // .markup code.language-mermaid | ||||
|   if (!el) return; | ||||
|   // .markup code.language-mermaid | ||||
|   queryElems(elMarkup, 'code.language-mermaid', async (el) => { | ||||
|     const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); | ||||
|  | ||||
|   const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); | ||||
|  | ||||
|   mermaid.initialize({ | ||||
|     startOnLoad: false, | ||||
|     theme: isDarkTheme() ? 'dark' : 'neutral', | ||||
|     securityLevel: 'strict', | ||||
|     suppressErrorRendering: true, | ||||
|   }); | ||||
|  | ||||
|   const pre = el.closest('pre'); | ||||
|   if (pre.hasAttribute('data-render-done')) return; | ||||
|  | ||||
|   const source = el.textContent; | ||||
|   if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { | ||||
|     displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     await mermaid.parse(source); | ||||
|   } catch (err) { | ||||
|     displayError(pre, err); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     // can't use bindFunctions here because we can't cross the iframe boundary. This | ||||
|     // means js-based interactions won't work but they aren't intended to work either | ||||
|     const {svg} = await mermaid.render('mermaid', source); | ||||
|  | ||||
|     const iframe = document.createElement('iframe'); | ||||
|     iframe.classList.add('markup-content-iframe', 'tw-invisible'); | ||||
|     iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`; | ||||
|  | ||||
|     const mermaidBlock = document.createElement('div'); | ||||
|     mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); | ||||
|     mermaidBlock.append(iframe); | ||||
|  | ||||
|     const btn = makeCodeCopyButton(); | ||||
|     btn.setAttribute('data-clipboard-text', source); | ||||
|     mermaidBlock.append(btn); | ||||
|  | ||||
|     const updateIframeHeight = () => { | ||||
|       const body = iframe.contentWindow?.document?.body; | ||||
|       if (body) { | ||||
|         iframe.style.height = `${body.clientHeight}px`; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     iframe.addEventListener('load', () => { | ||||
|       pre.replaceWith(mermaidBlock); | ||||
|       mermaidBlock.classList.remove('tw-hidden'); | ||||
|       updateIframeHeight(); | ||||
|       setTimeout(() => { // avoid flash of iframe background | ||||
|         mermaidBlock.classList.remove('is-loading'); | ||||
|         iframe.classList.remove('tw-invisible'); | ||||
|       }, 0); | ||||
|  | ||||
|       // update height when element's visibility state changes, for example when the diagram is inside | ||||
|       // a <details> + <summary> block and the <details> block becomes visible upon user interaction, it | ||||
|       // would initially set a incorrect height and the correct height is set during this callback. | ||||
|       (new IntersectionObserver(() => { | ||||
|         updateIframeHeight(); | ||||
|       }, {root: document.documentElement})).observe(iframe); | ||||
|     mermaid.initialize({ | ||||
|       startOnLoad: false, | ||||
|       theme: isDarkTheme() ? 'dark' : 'neutral', | ||||
|       securityLevel: 'strict', | ||||
|       suppressErrorRendering: true, | ||||
|     }); | ||||
|  | ||||
|     document.body.append(mermaidBlock); | ||||
|   } catch (err) { | ||||
|     displayError(pre, err); | ||||
|   } | ||||
|     const pre = el.closest('pre'); | ||||
|     if (pre.hasAttribute('data-render-done')) return; | ||||
|  | ||||
|     const source = el.textContent; | ||||
|     if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { | ||||
|       displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await mermaid.parse(source); | ||||
|     } catch (err) { | ||||
|       displayError(pre, err); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       // can't use bindFunctions here because we can't cross the iframe boundary. This | ||||
|       // means js-based interactions won't work but they aren't intended to work either | ||||
|       const {svg} = await mermaid.render('mermaid', source); | ||||
|  | ||||
|       const iframe = document.createElement('iframe'); | ||||
|       iframe.classList.add('markup-content-iframe', 'tw-invisible'); | ||||
|       iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`; | ||||
|  | ||||
|       const mermaidBlock = document.createElement('div'); | ||||
|       mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); | ||||
|       mermaidBlock.append(iframe); | ||||
|  | ||||
|       const btn = makeCodeCopyButton(); | ||||
|       btn.setAttribute('data-clipboard-text', source); | ||||
|       mermaidBlock.append(btn); | ||||
|  | ||||
|       const updateIframeHeight = () => { | ||||
|         const body = iframe.contentWindow?.document?.body; | ||||
|         if (body) { | ||||
|           iframe.style.height = `${body.clientHeight}px`; | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       iframe.addEventListener('load', () => { | ||||
|         pre.replaceWith(mermaidBlock); | ||||
|         mermaidBlock.classList.remove('tw-hidden'); | ||||
|         updateIframeHeight(); | ||||
|         setTimeout(() => { // avoid flash of iframe background | ||||
|           mermaidBlock.classList.remove('is-loading'); | ||||
|           iframe.classList.remove('tw-invisible'); | ||||
|         }, 0); | ||||
|  | ||||
|         // update height when element's visibility state changes, for example when the diagram is inside | ||||
|         // a <details> + <summary> block and the <details> block becomes visible upon user interaction, it | ||||
|         // would initially set a incorrect height and the correct height is set during this callback. | ||||
|         (new IntersectionObserver(() => { | ||||
|           updateIframeHeight(); | ||||
|         }, {root: document.documentElement})).observe(iframe); | ||||
|       }); | ||||
|  | ||||
|       document.body.append(mermaidBlock); | ||||
|     } catch (err) { | ||||
|       displayError(pre, err); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user