diff --git a/modules/markup/markdown/markdown_attention_test.go b/modules/markup/markdown/markdown_attention_test.go new file mode 100644 index 0000000000..f6ec775b2c --- /dev/null +++ b/modules/markup/markdown/markdown_attention_test.go @@ -0,0 +1,56 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown_test + +import ( + "strings" + "testing" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/svg" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func TestAttention(t *testing.T) { + defer svg.MockIcon("octicon-info")() + defer svg.MockIcon("octicon-light-bulb")() + defer svg.MockIcon("octicon-report")() + defer svg.MockIcon("octicon-alert")() + defer svg.MockIcon("octicon-stop")() + + renderAttention := func(attention, icon string) string { + tmpl := `
") + + test(`> [!note]`, renderAttention("note", "octicon-info")+"\n") + test(`> [!tip]`, renderAttention("tip", "octicon-light-bulb")+"\n") + test(`> [!important]`, renderAttention("important", "octicon-report")+"\n") + test(`> [!warning]`, renderAttention("warning", "octicon-alert")+"\n") + test(`> [!caution]`, renderAttention("caution", "octicon-stop")+"\n") + + // escaped by mdformat + test(`> \[!NOTE\]`, renderAttention("note", "octicon-info")+"\n") + + // legacy GitHub style + test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n") +} diff --git a/modules/markup/markdown/markdown_benchmark_test.go b/modules/markup/markdown/markdown_benchmark_test.go new file mode 100644 index 0000000000..0f7e3eea6f --- /dev/null +++ b/modules/markup/markdown/markdown_benchmark_test.go @@ -0,0 +1,25 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown_test + +import ( + "testing" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" +) + +func BenchmarkSpecializedMarkdown(b *testing.B) { + // 240856 4719 ns/op + for i := 0; i < b.N; i++ { + markdown.SpecializedMarkdown(&markup.RenderContext{}) + } +} + +func BenchmarkMarkdownRender(b *testing.B) { + // 23202 50840 ns/op + for i := 0; i < b.N; i++ { + _, _ = markdown.RenderString(markup.NewTestRenderContext(), "https://example.com\n- a\n- b\n") + } +} diff --git a/modules/markup/markdown/markdown_math_test.go b/modules/markup/markdown/markdown_math_test.go new file mode 100644 index 0000000000..0e5adeeac8 --- /dev/null +++ b/modules/markup/markdown/markdown_math_test.go @@ -0,0 +1,163 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "strings" + "testing" + + "code.gitea.io/gitea/modules/markup" + + "github.com/stretchr/testify/assert" +) + +func TestMathRender(t *testing.T) { + const nl = "\n" + testcases := []struct { + testcase string + expected string + }{ + { + "$a$", + `{Attention}
` + tmpl = strings.ReplaceAll(tmpl, "{attention}", attention) + tmpl = strings.ReplaceAll(tmpl, "{icon}", icon) + tmpl = strings.ReplaceAll(tmpl, "{Attention}", cases.Title(language.English).String(attention)) + return tmpl + } + + test := func(input, expected string) { + result, err := markdown.RenderString(markup.NewTestRenderContext(), input) + assert.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(result))) + } + + test(` +> [!NOTE] +> text +`, renderAttention("note", "octicon-info")+"\ntext
\n
a
a
a
b
a
b
a
.
.$a$
` + nl, + }, + { + `$a a$b b$`, + `$a a$b b$
` + nl, + }, + { + `a a$b b`, + `a a$b b
` + nl, + }, + { + `a$b $a a$b b$`, + `a$b $a a$b b$
` + nl, + }, + { + "a$x$", + `a$x$
` + nl, + }, + { + "$x$a", + `$x$a
` + nl, + }, + { + "$a$ ($b$) [$c$] {$d$}", + `a
(b
) [$c$] {$d$}
a
` + nl,
+ },
+ {
+ "$$a$$ test",
+ `a
test
test a
foo x=\$
bar
+\alpha
+
+`,
+ },
+ {
+ "indent-1",
+ `
+ \[
+ \alpha
+ \]
+`,
+ `
+\alpha
+
+`,
+ },
+ {
+ "indent-2",
+ `
+ \[
+ \alpha
+ \]
+`,
+ `
+\alpha
+
+`,
+ },
+ {
+ "indent-0-oneline",
+ `$$ x $$
+foo`,
+ ` x
+foo
+`, + }, + { + "indent-3-oneline", + ` $$ x $$ x
+foo
+`, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + res, err := RenderString(markup.NewTestRenderContext(), strings.ReplaceAll(test.testcase, "a
a
a
b
a
b
a
.
.$a$
` + nl, - }, - { - `$a a$b b$`, - `$a a$b b$
` + nl, - }, - { - `a a$b b`, - `a a$b b
` + nl, - }, - { - `a$b $a a$b b$`, - `a$b $a a$b b$
` + nl, - }, - { - "a$x$", - `a$x$
` + nl, - }, - { - "$x$a", - `$x$a
` + nl, - }, - { - "$$a$$", - `a
` + nl,
- },
- {
- "$a$ ($b$) [$c$] {$d$}",
- `a
(b
) [$c$] {$d$}
a
test
test a
") - - test(`> [!note]`, renderAttention("note", "octicon-info")+"\n") - test(`> [!tip]`, renderAttention("tip", "octicon-light-bulb")+"\n") - test(`> [!important]`, renderAttention("important", "octicon-report")+"\n") - test(`> [!warning]`, renderAttention("warning", "octicon-alert")+"\n") - test(`> [!caution]`, renderAttention("caution", "octicon-stop")+"\n") - - // escaped by mdformat - test(`> \[!NOTE\]`, renderAttention("note", "octicon-info")+"\n") - - // legacy GitHub style - test(`> **warning**`, renderAttention("warning", "octicon-alert")+"\n") -} - -func BenchmarkSpecializedMarkdown(b *testing.B) { - // 240856 4719 ns/op - for i := 0; i < b.N; i++ { - markdown.SpecializedMarkdown(&markup.RenderContext{}) - } -} - -func BenchmarkMarkdownRender(b *testing.B) { - // 23202 50840 ns/op - for i := 0; i < b.N; i++ { - _, _ = markdown.RenderString(markup.NewTestRenderContext(), "https://example.com\n- a\n- b\n") - } -} diff --git a/modules/markup/markdown/math/block_parser.go b/modules/markup/markdown/math/block_parser.go index a23f48b637..f31cfb09ad 100644 --- a/modules/markup/markdown/math/block_parser.go +++ b/modules/markup/markdown/math/block_parser.go @@ -54,21 +54,19 @@ func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Contex idx := bytes.Index(line[pos+2:], endBytes) if idx >= 0 { // for case $$ ... $$ any other text - for i := pos + idx + 4; i < len(line); i++ { + for i := pos + 2 + idx + 2; i < len(line); i++ { if line[i] != ' ' && line[i] != '\n' { return nil, parser.NoChildren } } - segment.Stop = segment.Start + idx + 2 - reader.Advance(segment.Len() - 1) - segment.Start += 2 + segment.Start += pos + 2 + segment.Stop = segment.Start + idx node.Lines().Append(segment) node.Closed = true return node, parser.Close | parser.NoChildren } - reader.Advance(segment.Len() - 1) - segment.Start += 2 + segment.Start += pos + 2 node.Lines().Append(segment) return node, parser.NoChildren } @@ -103,7 +101,6 @@ func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Cont pos, padding := util.IndentPosition(line, 0, block.Indent) seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding) node.Lines().Append(seg) - reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding) return parser.Continue | parser.NoChildren } diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go index b11195d551..56ae3d57eb 100644 --- a/modules/markup/markdown/math/inline_parser.go +++ b/modules/markup/markdown/math/inline_parser.go @@ -26,7 +26,6 @@ var defaultDualDollarParser = &inlineParser{ end: []byte{'$', '$'}, } -// NewInlineDollarParser returns a new inline parser func NewInlineDollarParser() parser.InlineParser { return defaultInlineDollarParser } @@ -40,7 +39,6 @@ var defaultInlineBracketParser = &inlineParser{ end: []byte{'\\', ')'}, } -// NewInlineDollarParser returns a new inline parser func NewInlineBracketParser() parser.InlineParser { return defaultInlineBracketParser } @@ -81,35 +79,29 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. opener := len(parser.start) // Now look for an ending line - ender := opener - for { - pos := bytes.Index(line[ender:], parser.end) - if pos < 0 { - return nil - } - - ender += pos - - // Now we want to check the character at the end of our parser section - // that is ender + len(parser.end) and check if char before ender is '\' - pos = ender + len(parser.end) - if len(line) <= pos { + ender := -1 + for i := opener; i < len(line); i++ { + if bytes.HasPrefix(line[i:], parser.end) { + succeedingCharacter := byte(0) + if i+len(parser.end) < len(line) { + succeedingCharacter = line[i+len(parser.end)] + } + // check valid ending character + isValidEndingChar := isPunctuation(succeedingCharacter) || isBracket(succeedingCharacter) || + succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 + if !isValidEndingChar { + break + } + ender = i break } - suceedingCharacter := line[pos] - // check valid ending character - if !isPunctuation(suceedingCharacter) && - !(suceedingCharacter == ' ') && - !(suceedingCharacter == '\n') && - !isBracket(suceedingCharacter) { - return nil + if line[i] == '\\' { + i++ + continue } - if line[ender-1] != '\\' { - break - } - - // move the pointer onwards - ender += len(parser.end) + } + if ender == -1 { + return nil } block.Advance(opener) diff --git a/package-lock.json b/package-lock.json index 2261566c02..e3f7a0116f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "@citation-js/plugin-csl": "0.7.14", "@citation-js/plugin-software-formats": "0.6.1", "@github/markdown-toolbar-element": "2.2.3", - "@github/relative-time-element": "4.4.3", + "@github/relative-time-element": "4.4.4", "@github/text-expander-element": "2.8.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.13.0", @@ -3025,9 +3025,9 @@ "license": "MIT" }, "node_modules/@github/relative-time-element": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.4.3.tgz", - "integrity": "sha512-EVKokqx9/DdUAZ2l9WVyY51EtRCO2gQWWMvsRIn7r4glJ91q9CXcnILVHZVCpfD52ucXUhUvtYsAjNJ4qP4uIg==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.4.4.tgz", + "integrity": "sha512-Oi8uOL8O+ZWLD7dHRWCkm2cudcTYtB3VyOYf9BtzCgDGm+OKomyOREtItNMtWl1dxvec62BTKErq36uy+RYxQg==", "license": "MIT" }, "node_modules/@github/text-expander-element": { diff --git a/package.json b/package.json index 095deb28fa..d30aedc54f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@citation-js/plugin-csl": "0.7.14", "@citation-js/plugin-software-formats": "0.6.1", "@github/markdown-toolbar-element": "2.2.3", - "@github/relative-time-element": "4.4.3", + "@github/relative-time-element": "4.4.4", "@github/text-expander-element": "2.8.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.13.0", diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go new file mode 100644 index 0000000000..37e94aa802 --- /dev/null +++ b/routers/web/devtest/mock_actions.go @@ -0,0 +1,108 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package devtest + +import ( + "fmt" + mathRand "math/rand/v2" + "net/http" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo/actions" + "code.gitea.io/gitea/services/context" +) + +func generateMockStepsLog(logCur actions.LogCursor) (stepsLog []*actions.ViewStepLog) { + mockedLogs := []string{ + "::group::test group for: step={step}, cursor={cursor}", + "in group msg for: step={step}, cursor={cursor}", + "in group msg for: step={step}, cursor={cursor}", + "in group msg for: step={step}, cursor={cursor}", + "::endgroup::", + "message for: step={step}, cursor={cursor}", + "message for: step={step}, cursor={cursor}", + "message for: step={step}, cursor={cursor}", + "message for: step={step}, cursor={cursor}", + "message for: step={step}, cursor={cursor}", + } + cur := logCur.Cursor // usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally + for i := 0; i < util.Iif(logCur.Step == 0, 3, 1); i++ { + logStr := mockedLogs[int(cur)%len(mockedLogs)] + cur++ + logStr = strings.ReplaceAll(logStr, "{step}", fmt.Sprintf("%d", logCur.Step)) + logStr = strings.ReplaceAll(logStr, "{cursor}", fmt.Sprintf("%d", cur)) + stepsLog = append(stepsLog, &actions.ViewStepLog{ + Step: logCur.Step, + Cursor: cur, + Started: time.Now().Unix() - 1, + Lines: []*actions.ViewStepLogLine{ + {Index: cur, Message: logStr, Timestamp: float64(time.Now().UnixNano()) / float64(time.Second)}, + }, + }) + } + return stepsLog +} + +func MockActionsRunsJobs(ctx *context.Context) { + req := web.GetForm(ctx).(*actions.ViewRequest) + + resp := &actions.ViewResponse{} + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ + Name: "artifact-a", + Size: 100 * 1024, + Status: "expired", + }) + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ + Name: "artifact-b", + Size: 1024 * 1024, + Status: "completed", + }) + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ + Summary: "step 0 (mock slow)", + Duration: time.Hour.String(), + Status: actions_model.StatusRunning.String(), + }) + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ + Summary: "step 1 (mock fast)", + Duration: time.Hour.String(), + Status: actions_model.StatusRunning.String(), + }) + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ + Summary: "step 2 (mock error)", + Duration: time.Hour.String(), + Status: actions_model.StatusRunning.String(), + }) + if len(req.LogCursors) == 0 { + ctx.JSON(http.StatusOK, resp) + return + } + + resp.Logs.StepsLog = []*actions.ViewStepLog{} + doSlowResponse := false + doErrorResponse := false + for _, logCur := range req.LogCursors { + if !logCur.Expanded { + continue + } + doSlowResponse = doSlowResponse || logCur.Step == 0 + doErrorResponse = doErrorResponse || logCur.Step == 2 + resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur)...) + } + if doErrorResponse { + if mathRand.Float64() > 0.5 { + ctx.Error(http.StatusInternalServerError, "devtest mock error response") + return + } + } + if doSlowResponse { + time.Sleep(time.Duration(3000) * time.Millisecond) + } else { + time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine + } + ctx.JSON(http.StatusOK, resp) +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index f86d4c6177..0f0d7d1ebd 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -66,15 +66,25 @@ func View(ctx *context_module.Context) { ctx.HTML(http.StatusOK, tplViewActions) } +type LogCursor struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Expanded bool `json:"expanded"` +} + type ViewRequest struct { - LogCursors []struct { - Step int `json:"step"` - Cursor int64 `json:"cursor"` - Expanded bool `json:"expanded"` - } `json:"logCursors"` + LogCursors []LogCursor `json:"logCursors"` +} + +type ArtifactsViewItem struct { + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` } type ViewResponse struct { + Artifacts []*ArtifactsViewItem `json:"artifacts"` + State struct { Run struct { Link string `json:"link"` @@ -146,6 +156,25 @@ type ViewStepLogLine struct { Timestamp float64 `json:"timestamp"` } +func getActionsViewArtifacts(ctx context.Context, repoID, runIndex int64) (artifactsViewItems []*ArtifactsViewItem, err error) { + run, err := actions_model.GetRunByIndex(ctx, repoID, runIndex) + if err != nil { + return nil, err + } + artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) + if err != nil { + return nil, err + } + for _, art := range artifacts { + artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{ + Name: art.ArtifactName, + Size: art.FileSize, + Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + }) + } + return artifactsViewItems, nil +} + func ViewPost(ctx *context_module.Context) { req := web.GetForm(ctx).(*ViewRequest) runIndex := getRunIndex(ctx) @@ -157,11 +186,19 @@ func ViewPost(ctx *context_module.Context) { } run := current.Run if err := run.LoadAttributes(ctx); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("run.LoadAttributes", err) return } + var err error resp := &ViewResponse{} + resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if !errors.Is(err, util.ErrNotExist) { + ctx.ServerError("getActionsViewArtifacts", err) + return + } + } resp.State.Run.Title = run.Title resp.State.Run.Link = run.Link() @@ -205,12 +242,12 @@ func ViewPost(ctx *context_module.Context) { var err error task, err = actions_model.GetTaskByID(ctx, current.TaskID) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("actions_model.GetTaskByID", err) return } task.Job = current if err := task.LoadAttributes(ctx); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("task.LoadAttributes", err) return } } @@ -278,7 +315,7 @@ func ViewPost(ctx *context_module.Context) { offset := task.LogIndexes[index] logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("actions.ReadLogs", err) return } @@ -555,49 +592,6 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions return jobs[0], jobs } -type ArtifactsViewResponse struct { - Artifacts []*ArtifactsViewItem `json:"artifacts"` -} - -type ArtifactsViewItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - Status string `json:"status"` -} - -func ArtifactsView(ctx *context_module.Context) { - runIndex := getRunIndex(ctx) - run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.Error(http.StatusNotFound, err.Error()) - return - } - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - artifactsResponse := ArtifactsViewResponse{ - Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)), - } - for _, art := range artifacts { - status := "completed" - if art.Status == actions_model.ArtifactStatusExpired { - status = "expired" - } - artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ - Name: art.ArtifactName, - Size: art.FileSize, - Status: status, - }) - } - ctx.JSON(http.StatusOK, artifactsResponse) -} - func ArtifactsDeleteView(ctx *context_module.Context) { if !ctx.Repo.CanWrite(unit.TypeActions) { ctx.Error(http.StatusForbidden, "no permission") diff --git a/routers/web/web.go b/routers/web/web.go index 6113a6457b..85e0fdc41e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1424,7 +1424,6 @@ func registerRoutes(m *web.Router) { }) m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) m.Post("/approve", reqRepoActionsWriter, actions.Approve) - m.Get("/artifacts", actions.ArtifactsView) m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) @@ -1626,9 +1625,12 @@ func registerRoutes(m *web.Router) { } if !setting.IsProd { - m.Any("/devtest", devtest.List) - m.Any("/devtest/fetch-action-test", devtest.FetchActionTest) - m.Any("/devtest/{sub}", devtest.Tmpl) + m.Group("/devtest", func() { + m.Any("", devtest.List) + m.Any("/fetch-action-test", devtest.FetchActionTest) + m.Any("/{sub}", devtest.Tmpl) + m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) + }) } m.NotFound(func(w http.ResponseWriter, req *http.Request) { diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl new file mode 100644 index 0000000000..1fa71c0e5f --- /dev/null +++ b/templates/devtest/repo-action-view.tmpl @@ -0,0 +1,30 @@ +{{template "base/head" .}} +{Attention}
` - tmpl = strings.ReplaceAll(tmpl, "{attention}", attention) - tmpl = strings.ReplaceAll(tmpl, "{icon}", icon) - tmpl = strings.ReplaceAll(tmpl, "{Attention}", cases.Title(language.English).String(attention)) - return tmpl - } - - test := func(input, expected string) { - result, err := markdown.RenderString(markup.NewTestRenderContext(), input) - assert.NoError(t, err) - assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(result))) - } - - test(` -> [!NOTE] -> text -`, renderAttention("note", "octicon-info")+"\ntext
\n