diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 9197dd2fe..999ae52bb 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -7,6 +7,7 @@ package markdown import ( "bytes" + "strings" "sync" "code.gitea.io/gitea/modules/log" @@ -57,13 +58,33 @@ func render(body []byte, urlPrefix string, metas map[string]string, wikiMarkdown chromahtml.PreventSurroundingPre(true), ), highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { - language, _ := c.Language() - if language == nil { - language = []byte("text") - } if entering { + language, _ := c.Language() + if language == nil { + language = []byte("text") + } + + languageStr := string(language) + + preClasses := []string{} + if languageStr == "mermaid" { + preClasses = append(preClasses, "is-loading") + } + + if len(preClasses) > 0 { + _, err := w.WriteString(`
`) + if err != nil { + return + } + } else { + _, err := w.WriteString(``) + if err != nil { + return + } + } + // include language-x class as part of commonmark spec - _, err := w.WriteString("") + _, err := w.WriteString(`
`) if err != nil { return } diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index e5f6e7508..ba73650bd 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -38,6 +38,7 @@ func NewSanitizer() { func ReplaceSanitizer() { sanitizer.policy = bluemonday.UGCPolicy() // For Chroma markdown plugin + sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^is-loading$`)).OnElements("pre") sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code") // Checkboxes diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index cc98539ea..af65813b3 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -41,9 +41,7 @@ data-markdown-file-exts="{{.MarkdownFileExts}}" data-line-wrap-extensions="{{.LineWrapExtensions}}"> {{.FileContent}} -
- {{.i18n.Tr "loading"}} -+{{.i18n.Tr "loading"}} diff --git a/web_src/js/markdown/content.js b/web_src/js/markdown/content.js index f41800ee3..918cd6fe8 100644 --- a/web_src/js/markdown/content.js +++ b/web_src/js/markdown/content.js @@ -1,5 +1,5 @@ import {renderMermaid} from './mermaid.js'; export default async function renderMarkdownContent() { - await renderMermaid(document.querySelectorAll('.language-mermaid')); + await renderMermaid(document.querySelectorAll('code.language-mermaid')); } diff --git a/web_src/js/markdown/mermaid.js b/web_src/js/markdown/mermaid.js index 1fda101dc..a518bc734 100644 --- a/web_src/js/markdown/mermaid.js +++ b/web_src/js/markdown/mermaid.js @@ -1,23 +1,56 @@ -import {random} from '../utils.js'; +const MAX_SOURCE_CHARACTERS = 5000; + +function displayError(el, err) { + el.closest('pre').classList.remove('is-loading'); + const errorNode = document.createElement('div'); + errorNode.setAttribute('class', 'ui message error markdown-block-error mono'); + errorNode.textContent = err.str || err.message || String(err); + el.closest('pre').before(errorNode); +} export async function renderMermaid(els) { if (!els || !els.length) return; - const {mermaidAPI} = await import(/* webpackChunkName: "mermaid" */'mermaid'); + const mermaid = await import(/* webpackChunkName: "mermaid" */'mermaid'); - mermaidAPI.initialize({ - startOnLoad: false, + mermaid.initialize({ + mermaid: { + startOnLoad: false, + }, + flowchart: { + useMaxWidth: true, + htmlLabels: false, + }, theme: 'neutral', securityLevel: 'strict', }); for (const el of els) { - mermaidAPI.render(`mermaid-${random(12)}`, el.textContent, (svg, bindFunctions) => { - const div = document.createElement('div'); - div.classList.add('mermaid-chart'); - div.innerHTML = svg; - if (typeof bindFunctions === 'function') bindFunctions(div); - el.closest('pre').replaceWith(div); - }); + if (el.textContent.length > MAX_SOURCE_CHARACTERS) { + displayError(el, new Error(`Mermaid source of ${el.textContent.length} characters exceeds the maximum allowed length of ${MAX_SOURCE_CHARACTERS}.`)); + continue; + } + + let valid; + try { + valid = mermaid.parse(el.textContent); + } catch (err) { + displayError(el, err); + } + + if (!valid) { + el.closest('pre').classList.remove('is-loading'); + continue; + } + + try { + mermaid.init(undefined, el, (id) => { + const svg = document.getElementById(id); + svg.classList.add('mermaid-chart'); + svg.closest('pre').replaceWith(svg); + }); + } catch (err) { + displayError(el, err); + } } } diff --git a/web_src/less/_markdown.less b/web_src/less/_markdown.less index 0f57bc444..1b9c412f6 100644 --- a/web_src/less/_markdown.less +++ b/web_src/less/_markdown.less @@ -495,10 +495,20 @@ } } -.mermaid-chart { - display: flex; - justify-content: center; - align-items: center; - padding: 1rem; - margin: 1rem 0; +.markdown-block-error { + margin-bottom: 0 !important; + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; + box-shadow: none !important; + font-size: 85% !important; + white-space: pre !important; + padding: .5rem 1rem !important; + text-align: left !important; +} + +.markdown-block-error + pre { + border-top: none !important; + margin-top: 0 !important; + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; } diff --git a/web_src/less/features/animations.less b/web_src/less/features/animations.less new file mode 100644 index 000000000..65ff1fef3 --- /dev/null +++ b/web_src/less/features/animations.less @@ -0,0 +1,34 @@ +@keyframes isloadingspin { + 0% { transform: translate(-50%, -50%) rotate(0deg); } + 100% { transform: translate(-50%, -50%) rotate(360deg); } +} + +.is-loading { + background: transparent !important; + color: transparent !important; + border: transparent !important; + pointer-events: none !important; + position: relative !important; + overflow: hidden !important; +} + +.is-loading:after { + content: ""; + position: absolute; + display: block; + width: 4rem; + height: 4rem; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + animation: isloadingspin 500ms infinite linear; + border-width: 4px; + border-style: solid; + border-color: #ececec #ececec #666 #666; + border-radius: 100%; +} + +.markdown pre.is-loading, +.editor-loading.is-loading { + height: 12rem; +} diff --git a/web_src/less/index.less b/web_src/less/index.less index 33bd41e6f..ef38f863c 100644 --- a/web_src/less/index.less +++ b/web_src/less/index.less @@ -1,5 +1,7 @@ @import "~font-awesome/css/font-awesome.css"; @import "./vendor/gitGraph.css"; +@import "./features/animations.less"; +@import "./markdown/mermaid.less"; @import "_svg"; @import "_tribute"; diff --git a/web_src/less/markdown/mermaid.less b/web_src/less/markdown/mermaid.less new file mode 100644 index 000000000..2b7951eec --- /dev/null +++ b/web_src/less/markdown/mermaid.less @@ -0,0 +1,12 @@ +.mermaid-chart { + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + margin: 1rem 0; +} + +/* mermaid's errorRenderer seems to unavoidably spew stuff into , hide it */ +body > div[id*="mermaid-"] { + display: none !important; +} diff --git a/web_src/less/themes/theme-arc-green.less b/web_src/less/themes/theme-arc-green.less index 839cf89b1..8de66fd25 100644 --- a/web_src/less/themes/theme-arc-green.less +++ b/web_src/less/themes/theme-arc-green.less @@ -1260,7 +1260,8 @@ input { border-color: #794f31; } -.ui.red.message { +.ui.red.message, +.ui.error.message { background-color: rgba(80, 23, 17, .6); color: #f9cbcb; box-shadow: 0 0 0 1px rgba(121, 71, 66, .5) inset, 0 0 0 0 transparent; @@ -1923,3 +1924,12 @@ footer .container .links > * { .mermaid-chart { filter: invert(84%) hue-rotate(180deg); } + +.is-loading:after { + border-color: #4a4c58 #4a4c58 #d7d7da #d7d7da; +} + +.markdown-block-error { + border: 1px solid rgba(121, 71, 66, .5) !important; + border-bottom: none !important; +}