From f720d0933edc918a97641ce3b0ec417941fad2b9 Mon Sep 17 00:00:00 2001 From: Roman Dvornov Date: Mon, 11 Nov 2024 00:00:55 +0100 Subject: [PATCH] Refactor markdown view --- src/views/text/markdown-marked-renderer.ts | 143 +++++++++++ src/views/text/markdown.js | 276 ++++++--------------- 2 files changed, 224 insertions(+), 195 deletions(-) create mode 100644 src/views/text/markdown-marked-renderer.ts diff --git a/src/views/text/markdown-marked-renderer.ts b/src/views/text/markdown-marked-renderer.ts new file mode 100644 index 0000000..1ddc401 --- /dev/null +++ b/src/views/text/markdown-marked-renderer.ts @@ -0,0 +1,143 @@ +import type { ViewModel } from '../../main'; +import { Renderer, type Tokens, type MarkedOptions } from 'marked'; +import { slug as generateSlug } from 'github-slugger'; +import { escapeHtml } from '../../core/utils/html.js'; + +export class CustomMarkedRenderer extends Renderer { + declare options: MarkedOptions & { + discoveryjs: { + host: ViewModel, + useAnchors: boolean, + codes: Array<{ + syntax: string | undefined; + source: string; + }> + } + }; + + heading({ tokens, depth, text }: Tokens.Heading) { + const { discoveryjs: { host, useAnchors } } = this.options; + const slug = generateSlug(text); + let anchor = ''; + + if (useAnchors) { + const href = host.encodePageHash( + host.pageId, + host.pageRef, + { ...host.pageParams, '!anchor': slug } + ); + + anchor = ``; + } + + return `${anchor}${ + this.parser.parseInline(tokens) + }\n`; + } + + code({ text: source, lang: syntax }: Tokens.Code) { + const { discoveryjs: { codes } } = this.options; + const id = codes.push({ + syntax, + source + }) - 1; + + return ``; + } + + link({ tokens, href, title }: Tokens.Link) { + const text = this.parser.parseInline(tokens); + + if (href === null) { + return text; + } + + let out = ''; + + return out; + } + + checkbox({ checked }: { checked: boolean }) { + return ( + ' ' + ); + } + + list({ items, ordered, start }: Tokens.List) { + const tag = ordered ? 'ol' : 'ul'; + const startAttr = ordered && start !== 1 ? ` start="${start}"` : ''; + + return ( + `<${tag} class="view-${tag}"${startAttr}>\n` + + items.map(this.listitem, this).join('\n') + + `\n\n` + ); + } + listitem(token: Tokens.ListItem) { + let prefix = ''; + + if (token.task) { + const checkbox = this.checkbox({ checked: Boolean(token.checked) }); + + if (token.loose) { + const firstToken = token.tokens[0]; + + if (firstToken?.type === 'paragraph') { + firstToken.text = checkbox + firstToken.text; + if (Array.isArray(firstToken.tokens) && firstToken.tokens[0]?.type === 'text') { + firstToken.tokens[0].text = checkbox + firstToken.tokens[0].text; + } + } else { + token.tokens.unshift({ + type: 'text', + raw: checkbox, + text: checkbox + }); + } + } else { + prefix += checkbox; + } + } + + return `
  • ${prefix}${ + this.parser.parse(token.tokens, Boolean(token.loose)) + }
  • \n`; + } + + table({ header, rows }: Tokens.Table) { + const body = rows.map(row => + '' + row.map(this.tablecell, this).join('') + '' + ).join('\n'); + + return ( + '\n' + + '\n' + + header.map(this.tablecell, this).join('') + + '\n\n' + + (body ? '\n' + body + '\n\n' : '') + + '
    \n' + ); + } + + tablecell({ tokens, header, align }: Tokens.TableCell) { + const type = header ? 'th' : 'td'; + + return ( + `<${type} class="view-table-cell"${align ? ` align="${align}"` : ''}>` + + this.parser.parseInline(tokens) + + `\n` + ); + } +} diff --git a/src/views/text/markdown.js b/src/views/text/markdown.js index ac993c9..ed49b7b 100644 --- a/src/views/text/markdown.js +++ b/src/views/text/markdown.js @@ -1,8 +1,7 @@ /* eslint-env browser */ -import { Marked, Renderer } from 'marked'; -import { slug as generateSlug } from 'github-slugger'; +import { Marked } from 'marked'; +import { CustomMarkedRenderer } from './markdown-marked-renderer.js'; import usage from './markdown.usage.js'; -import { escapeHtml } from '../../core/utils/html.js'; function applyTextInterpolation(value, values) { return value.replace(/{{(\d+)}}/gs, (_, index) => values[index]); @@ -28,136 +27,69 @@ function applyInterpolations(el, values) { } } -class CustomRenderer extends Renderer { - heading({ tokens, depth, text }) { - const { discoveryjs: { host, useAnchors } } = this.options; - const slug = generateSlug(text); - let anchor = ''; - - if (useAnchors) { - const href = host.encodePageHash( - host.pageId, - host.pageRef, - { ...host.pageParams, '!anchor': slug } - ); - - anchor = `
    `; +function indexMarkdownSections(el) { + const sectionByHeaderEl = new Map(); + let startSectionKey = { after: el.prepend.bind(el) }; + let prevSection = { + next: null, + data: { + sectionIdx: 0, + slug: null, + text: null, + href: null } + }; - return `${anchor}${ - this.parser.parseInline(tokens) - }\n`; - } + sectionByHeaderEl.set(startSectionKey, prevSection); - code({ text: source, lang: syntax }) { - const { discoveryjs: { codes } } = this.options; - const id = codes.push({ - syntax, - source - }) - 1; - - return ``; - } - - link({ tokens, href, title }) { - const text = this.parser.parseInline(tokens); - - if (href === null) { - return text; + for (const headerEl of [...el.querySelectorAll(':scope > :is(h1, h2, h3, h4, h5, h6)')]) { + if (headerEl === el.firstElementChild) { + sectionByHeaderEl.delete(startSectionKey); + startSectionKey = headerEl; + prevSection = null; } - let out = ' a[id^="!anchor:"]'); + const section = { + next: null, + data: { + sectionIdx: sectionByHeaderEl.size, + slug: headerEl.dataset.slug, + text: headerEl.textContent.trim(), + href: anchorEl?.hash + } + }; - if (title) { - out += ' title="' + escapeHtml(title) + '"'; - } + sectionByHeaderEl.set(headerEl, section); - if (!href.startsWith('#')) { - out += ' target="_blank"'; + if (prevSection) { + prevSection.next = headerEl; } - out += '>' + text + ''; - - return out; - } - - checkbox({ checked }) { - return ( - ' ' - ); - } - - list({ items, ordered, start }) { - const tag = ordered ? 'ol' : 'ul'; - const startAttr = ordered && start !== 1 ? ` start="${start}"` : ''; - - return ( - `<${tag} class="view-${tag}"${startAttr}>\n` + - items.map(this.listitem, this).join('\n') + - `\n\n` - ); + prevSection = section; } - listitem(token) { - let prefix = ''; - if (token.task) { - const checkbox = this.checkbox({ checked: Boolean(token.checked) }); - - if (token.loose) { - const firstToken = item.tokens[0]; + return { + sectionByHeaderEl, + findSectionByEl(cursor) { + let section = sectionByHeaderEl.get(startSectionKey); - if (firstToken?.type === 'paragraph') { - firstToken.text = checkbox + firstToken.text; - if (Array.isArray(firstToken.tokens) && firstToken.tokens[0]?.type === 'text') { - firstToken.tokens[0].text = checkbox + firstToken.tokens[0].text; - } - } else { - token.tokens.unshift({ - type: 'text', - raw: checkbox, - text: checkbox - }); + while (cursor !== null && cursor !== el) { + if (sectionByHeaderEl.has(cursor)) { + section = sectionByHeaderEl.get(cursor); + break; } - } else { - prefix += checkbox; - } - } - - return `
  • ${prefix}${ - this.parser.parse(token.tokens, Boolean(token.loose)) - }
  • \n`; - } - - table({ header, rows }) { - const body = rows.map(row => - '' + row.map(this.tablecell, this).join('') + '' - ).join('\n'); - - return ( - '\n' + - '\n' + - header.map(this.tablecell, this).join('') + - '\n\n' + - (body ? '\n' + body + '\n\n' : '') + - '
    \n' - ); - } - tablecell({ tokens, header, align }) { - const type = header ? 'th' : 'td'; + cursor = cursor.previousSibling || cursor.parentNode; + } - return ( - `<${type} class="view-table-cell"${align ? ` align="${align}"` : ''}>` + - this.parser.parseInline(tokens) + - `\n` - ); - } + return section?.data || null; + } + }; } const props = `is not array? | { - source: #.props has no 'source' ? is string ?, + source: #.props has no 'source' ? is (string or array) ?, anchors: true, sectionPrelude: undefined, sectionPostlude: undefined, @@ -167,26 +99,28 @@ const props = `is not array? | { export default function(host) { const marked = new Marked().setOptions({ smartLists: true, - renderer: new CustomRenderer() + renderer: new CustomMarkedRenderer() }); function render(el, config, data, context) { const interpolations = new Map(); const codes = []; + const promises = []; const { - source, anchors = true, sectionPrelude, - sectionPostlude, + sectionPostlude + } = config; + let { + source, codeConfig } = config; - let mdSource = source; - if (Array.isArray(mdSource)) { - mdSource = mdSource.join('\n'); + if (Array.isArray(source)) { + source = source.join('\n'); } - mdSource = mdSource.replace(/{{(.+?)}}/gs, (_, query) => { + source = source.replace(/{{(.+?)}}/gs, (_, query) => { query = query.trim(); if (!interpolations.has(query)) { @@ -197,11 +131,13 @@ export default function(host) { }); el.classList.add('view-markdown'); - - const html = marked.parse(mdSource, { discoveryjs: { host, useAnchors: anchors, codes } }); - const promises = []; - - el.innerHTML = html; + el.innerHTML = marked.parse(source, { + discoveryjs: { + host, + useAnchors: anchors, + codes + } + }); // interpolations if (interpolations.size > 0) { @@ -219,81 +155,31 @@ export default function(host) { } // index sections if needed - const sectionByHeaderEl = new Map(); - let startSectionKey = { after: buffer => el.prepend(buffer) }; - if (codeConfig || sectionPrelude || sectionPostlude) { - const { firstElementChild } = el; - let prevSection = { - next: null, - data: { - sectionIdx: 0, - slug: null, - text: null, - href: null - } - }; - - sectionByHeaderEl.set(startSectionKey, prevSection); - - for (const headerEl of [...el.querySelectorAll(':scope > :is(h1, h2, h3, h4, h5, h6)')]) { - if (headerEl === firstElementChild) { - sectionByHeaderEl.delete(startSectionKey); - startSectionKey = headerEl; - prevSection = null; - } - - const anchorEl = headerEl.querySelector(':scope > a[id^="!anchor:"]'); - const section = { - next: null, - data: { - sectionIdx: sectionByHeaderEl.size, - slug: headerEl.dataset.slug, - text: headerEl.textContent.trim(), - href: anchorEl?.hash - } - }; - - sectionByHeaderEl.set(headerEl, section); - - if (prevSection) { - prevSection.next = headerEl; - } - - prevSection = section; - } - } + const { sectionByHeaderEl, findSectionByEl } = + codes.length || sectionPrelude || sectionPostlude + ? indexMarkdownSections(el) + : { sectionByHeaderEl: new Map(), findSectionByEl: () => null }; // highlight code with a source view - for (const codeEl of [...el.querySelectorAll('.discoveryjs-code')]) { - const buffer = document.createDocumentFragment(); - const id = codeEl.dataset.id; - const { syntax, source } = codes[id]; - let section = sectionByHeaderEl.get(startSectionKey); - let cursor = codeEl.parentNode; + { + codeConfig = typeof codeConfig === 'object' + ? { view: 'source', ...codeConfig } + : codeConfig || 'source'; - while (cursor !== null && cursor !== el) { - if (sectionByHeaderEl.has(cursor)) { - section = sectionByHeaderEl.get(cursor); - break; - } + for (const codeEl of [...el.querySelectorAll('.discoveryjs-code')]) { + const section = findSectionByEl(codeEl); + const buffer = document.createDocumentFragment(); + const id = codeEl.dataset.id; + const { syntax, source } = codes[id]; - cursor = cursor.previousSibling || cursor.parentNode; + promises.push( + this.render(buffer, codeConfig, { syntax, source }, { ...context, section }) + .then(() => codeEl.replaceWith(buffer)) + ); } - - promises.push( - this.render( - buffer, - typeof codeConfig === 'object' - ? { view: 'source', ...codeConfig } - : codeConfig || 'source', - { syntax, source }, - { ...context, section: section?.data } - ).then(() => - codeEl.replaceWith(buffer) - ) - ); } + // render section prefix/postfix if (sectionPrelude || sectionPostlude) { const renderSectionPrePost = (renderConfig, section, insertCallback) => { const buffer = document.createDocumentFragment();