mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-03 17:24:22 +00: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