diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html index c4f0bd7553bf..cb4cccc1014c 100644 --- a/Resources/markdown-viewer/shell.html +++ b/Resources/markdown-viewer/shell.html @@ -50,6 +50,33 @@ padding: 0; /* hljs class already pads */ overflow: auto; } +/* Line number gutter for fenced code blocks. */ +pre.cmux-code-with-lines { + display: flex; + flex-direction: row; +} +pre.cmux-code-with-lines code.hljs { + flex: 1; + min-width: 0; +} +.cmux-line-gutter { + display: flex; + flex-direction: column; + padding: 16px 0; + padding-right: 12px; + padding-left: 12px; + border-right: 1px solid var(--borderColor-muted); + text-align: right; + -webkit-user-select: none; + user-select: none; + flex-shrink: 0; +} +.cmux-line-num { + font-size: 12px; + line-height: 1.5; + color: var(--fgColor-muted); + opacity: 0.55; +} .cmux-code-block { position: relative; margin: 0 0 16px 0; @@ -225,6 +252,39 @@ margin: 0.6em 0; text-align: left; } +/* GitHub-style callout/admonition blocks. */ +.cmux-callout { + padding: 12px 16px; + margin: 0 0 16px 0; + border-left: 4px solid; + border-radius: 6px; + background: var(--bgColor-muted); +} +.cmux-callout-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + margin-bottom: 6px; +} +.cmux-callout-title svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} +.cmux-callout-body > :first-child { margin-top: 0; } +.cmux-callout-body > :last-child { margin-bottom: 0; } +.cmux-callout-note { border-left-color: #539bf5; } +.cmux-callout-tip { border-left-color: #3fb950; } +.cmux-callout-important { border-left-color: #a371f7; } +.cmux-callout-warning { border-left-color: #d29922; } +.cmux-callout-caution { border-left-color: #f85149; } +.cmux-callout-note .cmux-callout-title { color: #539bf5; } +.cmux-callout-tip .cmux-callout-title { color: #3fb950; } +.cmux-callout-important .cmux-callout-title { color: #a371f7; } +.cmux-callout-warning .cmux-callout-title { color: #d29922; } +.cmux-callout-caution .cmux-callout-title { color: #f85149; } /* Custom selection color that reads on both themes. */ ::selection { background: rgba(56, 139, 253, 0.4); } /* Smooth-anchor scroll for heading links. */ @@ -280,6 +340,22 @@ return value; } + // FNV-1a hash for fast content-change detection. Uses 32-bit arithmetic + // (safe in JS) to avoid the O(n) innerHTML string comparison on re-render. + function fnv1aHash(str) { + var hash = 0x811c9dc5; + for (var i = 0, len = str.length; i < len; i++) { + hash ^= str.charCodeAt(i); + // Math.imul gives C-like 32-bit integer multiply; plain `*` would lose + // precision once the product exceeds 2^53, corrupting the hash. + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash; + } + // Sentinel must not collide with any valid fnv1aHash() output. fnv1aHash + // ends with `>>> 0`, which produces 0..2^32-1; null sits outside that range. + var lastRenderedHash = null; + function escapeHtml(s) { // Let WebKit's HTML serializer do escaping rather than trying // to keep a hand-written entity table correct. @@ -885,32 +961,74 @@ } } + // Single source of truth for sanitization. Both the full TreeWalker + // sanitizer (sanitizeRenderedHTML) and the fast pre-check (needsSanitization) + // are derived from these lists, so the fast-path can never drift out of sync + // with what the sanitizer actually strips. Add a tag or attribute here once + // and both paths update together. + var SANITIZER_BLOCKED_TAGS = { + script: true, iframe: true, object: true, embed: true, link: true, + meta: true, base: true, style: true, audio: true, video: true, + picture: true, source: true, track: true, form: true, button: true, + textarea: true, select: true, option: true, svg: true, math: true + }; + // Attributes the sanitizer strips outright. Any on* event handler is also + // stripped (handled by prefix, not listed here). `input` (checkbox-only) and + // remote src are special-cased in both the sanitizer and the pattern. + var SANITIZER_BLOCKED_ATTRS = [ + 'style', 'background', 'srcset', 'srcdoc', 'autofocus', 'formaction', 'xlink:href' + ]; + // Valueless-capable (boolean) attributes match on a word boundary in the + // fast-path regex rather than requiring `=` (e.g. bare ``). + var SANITIZER_BOOLEAN_ATTRS = { autofocus: true }; + + function reEscapeForPattern(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + // Build the fast pre-check regex from the same lists the sanitizer uses. + // Most markdown produces HTML with nothing dangerous; when none of these + // alternations match we skip the TreeWalker walk entirely. + // - tag list mirrors SANITIZER_BLOCKED_TAGS + // - ) so the live src + // is stripped before the browser auto-fetches it — + // the "don't auto-load remote images" privacy gate. + // Local/relative images stay on the fast path. + // - on* / blocked attrs mirror SANITIZER_BLOCKED_ATTRS + // - javascript:/... active-protocol href/src + // - href/src ... & any character reference (quoted or unquoted) in an + // href/src value, so entity-encoded payloads like + // `href=javascript:` can't skip the slow path. + function buildSanitizationPattern() { + var tags = Object.keys(SANITIZER_BLOCKED_TAGS).map(reEscapeForPattern).join('|'); + var attrs = SANITIZER_BLOCKED_ATTRS.map(function(a) { + var esc = reEscapeForPattern(a); + return SANITIZER_BOOLEAN_ATTRS[a] ? '\\b' + esc + '\\b' : '\\b' + esc + '\\s*='; + }).join('|'); + return new RegExp( + '<(?:' + tags + ')\\b' + + '|]*type\\s*=\\s*["\']?checkbox)' + + '|]*\\ssrc\\s*=\\s*["\']?\\s*https?:' + + '|(?:\\son[a-z]+\\s*=|' + attrs + ')' + + '|(?:href|src)\\s*=\\s*["\']?\\s*(?:javascript|vbscript|data):' + + '|(?:href|src)\\s*=\\s*(?:"[^"]*&|\'[^\']*&|[^"\'\\s>]*&)', + 'i' + ); + } + var sanitizationPattern = buildSanitizationPattern(); + function needsSanitization(html) { + return sanitizationPattern.test(html || ''); + } + function sanitizeRenderedHTML(html) { var template = document.createElement('template'); template.innerHTML = String(html || ''); - var blockedTags = { - script: true, - iframe: true, - object: true, - embed: true, - link: true, - style: true, - meta: true, - base: true, - audio: true, - form: true, - button: true, - picture: true, - source: true, - textarea: true, - track: true, - video: true, - select: true, - option: true, - svg: true, - math: true - }; + // Reuse the shared block list so the sanitizer and the fast-path regex + // (buildSanitizationPattern) can never disagree about which tags to strip. + var blockedTags = SANITIZER_BLOCKED_TAGS; var walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT); var elements = []; @@ -947,16 +1065,7 @@ el.removeAttribute(attr.name); return; } - if ( - name.indexOf('on') === 0 || - name === 'style' || - name === 'background' || - name === 'srcset' || - name === 'srcdoc' || - name === 'autofocus' || - name === 'formaction' || - name === 'xlink:href' - ) { + if (name.indexOf('on') === 0 || SANITIZER_BLOCKED_ATTRS.indexOf(name) !== -1) { el.removeAttribute(attr.name); return; } @@ -1012,8 +1121,9 @@ pedantic: false, renderer: { codespan(code) { - var raw = code || ''; - return '' + escapeHtml(raw) + ''; + // marked v13 passes code already HTML-escaped; don't double-escape. + // Markdown-file links are detected later in markMarkdownFileLinks(). + return '' + (code || '') + ''; }, code(code, infostring, escaped) { var raw = code || ''; @@ -1044,7 +1154,16 @@ highlighted = escapeHtml(raw); } var langClass = langName ? ' language-' + langName : ''; - return '' + highlighted + '\n'; + // Strip exactly one trailing newline before counting lines so code + // blocks that end in "\n" (the common case from marked) don't render + // a phantom empty line number in the gutter. + var rawForLineCount = raw.replace(/\r?\n$/, ''); + var lineCount = (rawForLineCount.match(/\n/g) || []).length + 1; + var gutter = ''; + for (var ln = 1; ln <= lineCount; ln++) { + gutter += '' + ln + ''; + } + return '' + gutter + '' + highlighted + '\n'; }, heading(text, level, raw) { var slug = uniqueHeadingSlug(raw); @@ -1056,6 +1175,24 @@ html += ' title="' + escapeAttribute(decodeHTML(title)) + '"'; } return html + '>' + sanitizeLinkLabelHTML(text) + ''; + }, + blockquote(quote) { + var calloutMatch = (quote || '').match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i); + if (!calloutMatch) { + return '\n' + quote + '\n'; + } + var type = calloutMatch[1].toLowerCase(); + var body = quote.replace(calloutMatch[0], '').replace(/^\s*(?:)?\s*/, ''); + var title = type.charAt(0).toUpperCase() + type.slice(1); + // Emit a data-attribute placeholder; the icon is injected + // after sanitization by injectCalloutIcons() so the sanitizer + // (which strips all elements as a hardening measure) cannot + // remove our own trusted icons. `type` comes from the allow-listed + // capture group above, so the attribute value is safe. + return '' + + '' + title + '' + + '' + body + '' + + '\n'; } } }); @@ -1533,7 +1670,31 @@ }); } + // Octicon SVGs for GitHub-style callouts. Kept out of the rendered HTML + // pipeline and injected post-sanitization so the sanitizer's blanket + // strip does not remove our own trusted icons. Markup is constant + // and never blended with user-supplied content. + var calloutIcons = { + note: '', + tip: '', + important: '', + warning: '', + caution: '' + }; + + function injectCalloutIcons() { + contentEl.querySelectorAll('.cmux-callout-title[data-cmux-callout-type]').forEach(function(titleEl) { + if (titleEl.querySelector('svg')) { return; } + var type = String(titleEl.getAttribute('data-cmux-callout-type') || '').toLowerCase(); + var icon = calloutIcons[type]; + if (icon) { + titleEl.insertAdjacentHTML('afterbegin', icon + ' '); + } + }); + } + function postProcessSpecialBlocks() { + injectCalloutIcons(); if (contentEl.querySelector('.cmux-mermaid:not([data-rendered])')) { loadLib('mermaid', renderMermaidBlocks); } @@ -1697,15 +1858,22 @@ openMarkdownCandidate(rawPath); }, true); - window.__cmuxRenderMarkdown = function(md) { + // Debounce rapid re-renders (e.g. atomic write-rename fires watcher + // twice in quick succession). 80ms is fast enough to feel instant but + // collapses bursts from editors like vim, emacs, and VS Code. + var renderTimer = null; + function doRender(md) { try { headingSlugCounts = Object.create(null); var documentParts = extractFrontmatter(md || ''); + var parsed = marked.parse(documentParts.body || ''); var html = renderFrontmatter(documentParts.frontmatter) - + sanitizeRenderedHTML(marked.parse(documentParts.body || '')); - var didReplaceContent = contentEl.innerHTML !== html; + + (needsSanitization(parsed) ? sanitizeRenderedHTML(parsed) : parsed); + var hash = fnv1aHash(html); + var didReplaceContent = hash !== lastRenderedHash; var scrollState = didReplaceContent ? captureMarkdownScrollState() : null; if (didReplaceContent) { + lastRenderedHash = hash; contentEl.innerHTML = html; } rewriteLocalImageSources(); @@ -1720,6 +1888,17 @@ + 'markdown render error: ' + escapeHtml(String((e && e.message) || e)) + '' + escapeHtml(md || '') + ''; } + } + window.__cmuxRenderMarkdown = function(md) { + if (renderTimer) { clearTimeout(renderTimer); } + if (lastRenderedHash === null) { + doRender(md); + return; + } + renderTimer = setTimeout(function() { + renderTimer = null; + doRender(md); + }, 80); }; function cleanRenderedContentClone(options) { @@ -1730,6 +1909,11 @@ clone.querySelectorAll('.cmux-code-copy-button').forEach(function(el) { el.remove(); }); + // Line-number gutters are viewer chrome, not content — drop them so + // exported HTML and copied plain text don't carry "1 2 3 …" line numbers. + clone.querySelectorAll('.cmux-line-gutter').forEach(function(el) { + el.remove(); + }); clone.querySelectorAll('.cmux-remote-image-placeholder').forEach(function(el) { el.remove(); }); @@ -1825,6 +2009,23 @@ target: document, getScroller: markdownScroller }); + + // Fix Cmd+A / Ctrl+A to select the entire document content rather + // than just the focused paragraph (WebKit default behavior). + // + // Match on ev.code === 'KeyA' (physical key position) rather than + // ev.key === 'a' so the shortcut works regardless of keyboard layout + // and modifier-induced case changes. + document.addEventListener('keydown', function(ev) { + if ((ev.metaKey || ev.ctrlKey) && !ev.shiftKey && !ev.altKey && ev.code === 'KeyA') { + ev.preventDefault(); + var range = document.createRange(); + range.selectNodeContents(contentEl); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + }); })();
' + escapeHtml(raw) + '
' + (code || '') + '
' + highlighted + '
' + gutter + '' + highlighted + '
\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i); + if (!calloutMatch) { + return '
\n' + quote + '
').replace(/^
\s*(?:)?\s*/, '
'); + var title = type.charAt(0).toUpperCase() + type.slice(1); + // Emit a data-attribute placeholder; the icon is injected + // after sanitization by injectCalloutIcons() so the sanitizer + // (which strips all elements as a hardening measure) cannot + // remove our own trusted icons. `type` comes from the allow-listed + // capture group above, so the attribute value is safe. + return '' + + '' + title + '' + + '' + body + '' + + '\n'; } } }); @@ -1533,7 +1670,31 @@ }); } + // Octicon SVGs for GitHub-style callouts. Kept out of the rendered HTML + // pipeline and injected post-sanitization so the sanitizer's blanket + // strip does not remove our own trusted icons. Markup is constant + // and never blended with user-supplied content. + var calloutIcons = { + note: '', + tip: '', + important: '', + warning: '', + caution: '' + }; + + function injectCalloutIcons() { + contentEl.querySelectorAll('.cmux-callout-title[data-cmux-callout-type]').forEach(function(titleEl) { + if (titleEl.querySelector('svg')) { return; } + var type = String(titleEl.getAttribute('data-cmux-callout-type') || '').toLowerCase(); + var icon = calloutIcons[type]; + if (icon) { + titleEl.insertAdjacentHTML('afterbegin', icon + ' '); + } + }); + } + function postProcessSpecialBlocks() { + injectCalloutIcons(); if (contentEl.querySelector('.cmux-mermaid:not([data-rendered])')) { loadLib('mermaid', renderMermaidBlocks); } @@ -1697,15 +1858,22 @@ openMarkdownCandidate(rawPath); }, true); - window.__cmuxRenderMarkdown = function(md) { + // Debounce rapid re-renders (e.g. atomic write-rename fires watcher + // twice in quick succession). 80ms is fast enough to feel instant but + // collapses bursts from editors like vim, emacs, and VS Code. + var renderTimer = null; + function doRender(md) { try { headingSlugCounts = Object.create(null); var documentParts = extractFrontmatter(md || ''); + var parsed = marked.parse(documentParts.body || ''); var html = renderFrontmatter(documentParts.frontmatter) - + sanitizeRenderedHTML(marked.parse(documentParts.body || '')); - var didReplaceContent = contentEl.innerHTML !== html; + + (needsSanitization(parsed) ? sanitizeRenderedHTML(parsed) : parsed); + var hash = fnv1aHash(html); + var didReplaceContent = hash !== lastRenderedHash; var scrollState = didReplaceContent ? captureMarkdownScrollState() : null; if (didReplaceContent) { + lastRenderedHash = hash; contentEl.innerHTML = html; } rewriteLocalImageSources(); @@ -1720,6 +1888,17 @@ + 'markdown render error: ' + escapeHtml(String((e && e.message) || e)) + '' + escapeHtml(md || '') + ''; } + } + window.__cmuxRenderMarkdown = function(md) { + if (renderTimer) { clearTimeout(renderTimer); } + if (lastRenderedHash === null) { + doRender(md); + return; + } + renderTimer = setTimeout(function() { + renderTimer = null; + doRender(md); + }, 80); }; function cleanRenderedContentClone(options) { @@ -1730,6 +1909,11 @@ clone.querySelectorAll('.cmux-code-copy-button').forEach(function(el) { el.remove(); }); + // Line-number gutters are viewer chrome, not content — drop them so + // exported HTML and copied plain text don't carry "1 2 3 …" line numbers. + clone.querySelectorAll('.cmux-line-gutter').forEach(function(el) { + el.remove(); + }); clone.querySelectorAll('.cmux-remote-image-placeholder').forEach(function(el) { el.remove(); }); @@ -1825,6 +2009,23 @@ target: document, getScroller: markdownScroller }); + + // Fix Cmd+A / Ctrl+A to select the entire document content rather + // than just the focused paragraph (WebKit default behavior). + // + // Match on ev.code === 'KeyA' (physical key position) rather than + // ev.key === 'a' so the shortcut works regardless of keyboard layout + // and modifier-induced case changes. + document.addEventListener('keydown', function(ev) { + if ((ev.metaKey || ev.ctrlKey) && !ev.shiftKey && !ev.altKey && ev.code === 'KeyA') { + ev.preventDefault(); + var range = document.createRange(); + range.selectNodeContents(contentEl); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + }); })();
' + escapeHtml(md || '') + '