From c2dde2579e5b735b04d4212929393b32a27b7e7f Mon Sep 17 00:00:00 2001 From: Roland Bernard <rolbernard@unibz.it> Date: Thu, 3 Jun 2021 22:36:32 +0200 Subject: [PATCH] Added markdown support --- client/public/index.html | 1 + client/src/components/ui/LongText/index.tsx | 10 +- .../src/components/ui/LongText/markdown.scss | 194 +++++ client/src/components/ui/LongText/markdown.ts | 664 ++++++++++++++++++ 4 files changed, 866 insertions(+), 3 deletions(-) create mode 100644 client/src/components/ui/LongText/markdown.scss create mode 100644 client/src/components/ui/LongText/markdown.ts diff --git a/client/public/index.html b/client/public/index.html index c67ac5b..50c041e 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -15,6 +15,7 @@ <meta property="og:title" content="Try planning your next project with ryoko!"> <link rel="preconnect" href="https://fonts.gstatic.com"> <link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet"> + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet"> <title>ryoko | plan your projects like a journey</title> diff --git a/client/src/components/ui/LongText/index.tsx b/client/src/components/ui/LongText/index.tsx index 6c2892e..71eaefc 100644 --- a/client/src/components/ui/LongText/index.tsx +++ b/client/src/components/ui/LongText/index.tsx @@ -1,6 +1,10 @@ import { useState } from 'react'; +import { compileMarkdown } from './markdown'; + +import './markdown.scss'; + interface Props { text: string; } @@ -10,14 +14,14 @@ export default function LongText({ text }: Props) { return ( (text.length < 300) - ? <p>{text}</p> + ? <p dangerouslySetInnerHTML={{ __html: compileMarkdown(text)}}>{}</p> : (more ? <> - <p>{text}</p> + <p dangerouslySetInnerHTML={{ __html: compileMarkdown(text)}}>{}</p> <button onClick={() => setMore(false)}>less</button> </> : <> - <p>{text.substr(0, 300) + '... '}</p> + <p dangerouslySetInnerHTML={{ __html: compileMarkdown(text.substr(0, 300) + '... ')}}>{}</p> <button onClick={() => setMore(true)}>more</button> </> ) diff --git a/client/src/components/ui/LongText/markdown.scss b/client/src/components/ui/LongText/markdown.scss new file mode 100644 index 0000000..df3cd35 --- /dev/null +++ b/client/src/components/ui/LongText/markdown.scss @@ -0,0 +1,194 @@ + +.markdown { + font-size: 1rem; + line-height: 150%; + box-sizing: border-box; + padding: 0; + margin: 0; +} + +.md-header-1, .md-header-2 { + padding-bottom: 0.1rem; + border-bottom: 1px solid #00000015; +} + +.md-header-1 { + font-size: 2rem; + padding-left: 0.25rem; +} + +.md-header-2 { + font-size: 1.5rem; + padding-left: 0.15rem; +} + +.md-header-3 { + font-size: 1.25rem; +} + +.md-header-4 { + font-size: 1rem; +} + +.md-header-5 { + font-size: 0.85rem; +} + +.md-header-6 { + font-size: 0.65rem; +} + +.md-header-1, .md-header-2, .md-header-3, +.md-header-4, .md-header-5, .md-header-6 { + margin: 0.5rem 0rem; +} +.md-code pre { + padding: 1rem; + margin: 0; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.9rem; + width: max-content; +} + +.md-code { + padding: 0; + margin: 0; + border-radius: 4px; + width: 100%; + display: flex; + flex-flow: row nowrap; +} + +.md-code .md-code-content { + flex: 1 1 auto; +} + +.md-code .md-lines { + flex: 0 0 auto; + display: flex; + flex-flow: column; + padding: 1rem 0.5rem; + border-right: 1px solid #ffffff20; + user-select: none; + text-align: right; +} + +.md-code .md-lines .md-line { + font-family: 'IBM Plex Mono', monospace; + font-size: 0.9rem; +} + +.md-inline-code { + background: #00000010; + padding: 0.1rem 0.25rem; + border-radius: 4px; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.9rem; + display: inline-block; +} + +.md-quote { + border-left: 0.25rem solid #00000018; + padding-left: 1.25rem; +} + +.md-hrule { + border: 0.125rem solid #00000018; + height: 0; +} + +.md-ordered-list, .md-unordered-list, .md-footnote { + padding-left: 2.5rem; + margin: 0.5rem 0; +} + +.md-todo-list { + list-style: none; + padding-left: 1rem; +} + +.md-todo-list > li { + display: flex; + align-items: center; +} + +.md-todo-list > li > input { + margin-right: 0.5rem; +} + +.md-paragraph { + margin: 1rem 0; +} + +li > .md-paragraph { + margin: 0.5rem 0; +} + +li > span > .md-paragraph { + margin: 0.5rem 0; +} + +.md-link, .md-footnote-ref { + text-decoration: none; +} + +.md-footnote-ref { + font-size: 0.85rem; + vertical-align: top; +} + +.md-align-left { + text-align: left; +} + +.md-align-right { + text-align: right; +} + +.md-align-center { + text-align: center; +} + +.md-table, .md-table-row, .md-table-data, .md-table-header { + border-collapse: collapse; + border: 1px solid #00000018; +} + +.md-table-data, .md-table-header { + padding: 0.125rem 0.5rem; +} + +.md-table-header { + font-weight: 500; + background: #0000000a; +} + +.md-table-row:nth-child(odd) { + background: #00000005; +} + +.md-info-wrap { + display: flex; + flex-flow: column; + align-items: center; + margin: 1rem 0.5rem; +} + +.md-info-wrap > p { + margin: 0; +} + +.md-info { + margin-top: 0.25rem; + text-align: center; +} + +.md-inline-info { + display: inline-flex; + vertical-align: middle; +} + +.md-image { + vertical-align: middle; +} + diff --git a/client/src/components/ui/LongText/markdown.ts b/client/src/components/ui/LongText/markdown.ts new file mode 100644 index 0000000..002b54c --- /dev/null +++ b/client/src/components/ui/LongText/markdown.ts @@ -0,0 +1,664 @@ + +type TemplateValue = string | number | TemplateValue[]; + +function html(str: TemplateStringsArray, ...values: TemplateValue[]) { + return str.map((s, i) => { + const value = values[i]; + if (value instanceof Array) { + return s + value.flat(Infinity).map(el => el.toString()).join(''); + } else { + return s + (value?.toString() || ''); + } + }).join(''); +} + +interface Footnote { + id: number; +} + +interface Link { + link?: string; + title?: string; +} + +type LinkRegistry = Record<string, Footnote | Link>; + +function escapeHtml(markdown: string) { + return markdown + .replace(/"/g, html`"`) + .replace(/&/g, html`&`) + .replace(/'/g, html`'`) + .replace(/</g, html`<`) + .replace(/>/g, html`>`); +} + +function compileInlineConstructs(markdown: string, data: LinkRegistry): string { + let output = ''; + let offset = 0; + let last_copy = 0; + while (offset < markdown.length) { + if (markdown[offset] === '\\') { + output += markdown.substr(last_copy, offset - last_copy); + offset++; + output += markdown[offset]; + offset++; + last_copy = offset; + } else if (markdown.substr(offset, 2) === '![') { + output += markdown.substr(last_copy, offset - last_copy); + last_copy = offset; + offset += 2; + let depth = 0; + const start = offset; + while (offset < markdown.length && (markdown[offset] !== ']' || depth !== 0)) { + if (markdown[offset] === '\\') { + offset++; + } else if (markdown[offset] === '[') { + depth++; + } else if (markdown[offset] === ']') { + depth--; + } + offset++; + } + const end = offset; + offset++; + while (markdown[offset] === ' ' || markdown[offset] === '\t') { + offset++; + } + if (markdown[offset] === '(') { + offset++; + const link_start = offset; + while ( + offset < markdown.length + && markdown[offset] !== ')' + && markdown[offset] !== '=' + && markdown[offset] !== '"' + ) { + offset++; + } + const link_end = offset; + let width; + let height; + if (markdown[offset] === '=') { + offset++; + const start_width = offset; + while ( + markdown[offset] >= '0' && markdown[offset] <= '9' + || markdown[offset] === ' ' || markdown[offset] === '\t' + ) { + offset++; + } + if (offset != start_width) { + width = parseInt(markdown.substr(start_width, offset - start_width)); + } + if (markdown[offset] === 'x') { + offset++; + const start_height = offset; + while ( + markdown[offset] >= '0' && markdown[offset] <= '9' + || markdown[offset] === ' ' || markdown[offset] === '\t' + ) { + offset++; + } + if (offset != start_height) { + height = parseInt(markdown.substr(start_height, offset - start_height)); + } + } + } + while ( + offset < markdown.length + && markdown[offset] !== ')' + && markdown[offset] !== '"' + ) { + offset++; + } + if (markdown[offset] === '"') { + offset++; + const title_start = offset; + while (offset < markdown.length && markdown[offset] !== ')' && markdown[offset] !== '"') { + offset++; + } + const title_end = offset; + if (markdown[offset] === '"') { + while (offset < markdown.length && markdown[offset] !== ')') { + offset++; + } + } + offset++; + output += html`<img + class="markdown md-image" + src="${markdown.substr(link_start, link_end - link_start).trim()}" + title="${markdown.substr(title_start, title_end - title_start)}" + alt="${markdown.substr(start, end - start)}" + ${width ? `width="${width}"` : ''} + ${height ? `height="${height}"` : ''} + />`; + last_copy = offset; + } else { + offset++; + output += html`<img + class="markdown md-image" + src="${markdown.substr(link_start, link_end - link_start).trim()}" + alt="${markdown.substr(start, end - start)}" + ${width ? `width="${width}"` : ''} + ${height ? `height="${height}"` : ''} + />`; + last_copy = offset; + } + } + } else if (markdown[offset] === '[') { + output += markdown.substr(last_copy, offset - last_copy); + last_copy = offset; + offset++; + let depth = 0; + const start = offset; + while (offset < markdown.length && (markdown[offset] !== ']' || depth !== 0)) { + if (markdown[offset] === '\\') { + offset++; + } else if (markdown[offset] === '[') { + depth++; + } else if (markdown[offset] === ']') { + depth--; + } + offset++; + } + const end = offset; + offset++; + if (markdown[start] === '^') { + const name = markdown.substr(start, end - start).toLowerCase(); + output += html`<a + class="markdown md-footnote-ref" + href="#fn:${name}" + >${(data[name] as Footnote)?.id}</a>`; + last_copy = offset; + } else { + while (markdown[offset] === ' ' || markdown[offset] === '\t') { + offset++; + } + if (markdown[offset] === '(') { + offset++; + const link_start = offset; + while (offset < markdown.length && markdown[offset] !== ')' && markdown[offset] !== '"') { + offset++; + } + const link_end = offset; + if (markdown[offset] === '"') { + offset++; + const title_start = offset; + while (offset < markdown.length && markdown[offset] !== ')' && markdown[offset] !== '"') { + offset++; + } + const title_end = offset; + if (markdown[offset] === '"') { + while (offset < markdown.length && markdown[offset] !== ')') { + offset++; + } + } + offset++; + output += html`<a + class="markdown md-link" + href="${markdown.substr(link_start, link_end - link_start).trim()}" + title="${markdown.substr(title_start, title_end - title_start)}" + >${compileInlineConstructs(markdown.substr(start, end - start), data)}</a>`; + last_copy = offset; + } else { + offset++; + output += html`<a + class="markdown md-link" + href="${markdown.substr(link_start, link_end - link_start).trim()}" + >${compileInlineConstructs(markdown.substr(start, end - start), data)}</a>`; + last_copy = offset; + } + } else if (markdown[offset] === '[') { + offset++; + const name_start = offset; + while (offset < markdown.length && markdown[offset] !== ']') { + offset++; + } + const name_end = offset; + if (markdown[offset] === ']') { + offset++; + const name = markdown.substr(name_start, name_end - name_start).toLowerCase(); + const link = data[name] as Link; + output += html`<a + class="markdown md-link" + ${link?.link ? `href="${link?.link}"` : ''} + ${link?.title ? `title="${link?.title}"` : ''} + >${compileInlineConstructs(markdown.substr(start, end - start), data)}</a>`; + last_copy = offset; + } + } + } + } else if (markdown.substr(offset, 2) === '~~') { + output += markdown.substr(last_copy, offset - last_copy); + offset += 2; + const start = offset; + while (offset < markdown.length && markdown.substr(offset, 2) !== '~~') { + if (markdown[offset] === '\\') { + offset++; + } + offset++; + } + output += html`<del class="markdown md-strike">${compileInlineConstructs(markdown.substr(start, offset - start), data)}</del>`; + offset += 2; + last_copy = offset; + } else if (markdown[offset] === '*') { + output += markdown.substr(last_copy, offset - last_copy); + if (markdown[offset + 1] === '*') { + offset += 2; + let single = false; + const start = offset; + while (offset < markdown.length && (markdown.substr(offset, 2) !== '**' || single)) { + if (markdown[offset] === '\\') { + offset++; + } else if (markdown[offset] === '*') { + single = !single; + } + offset++; + } + output += html`<strong class="markdown md-bold">${compileInlineConstructs(markdown.substr(start, offset - start), data)}</strong>`; + offset += 2; + } else { + offset++; + const start = offset; + while (offset < markdown.length && markdown[offset] !== '*') { + if (markdown[offset] === '\\') { + offset++; + } + offset++; + } + output += html`<em class="markdown md-italic">${compileInlineConstructs(markdown.substr(start, offset - start), data)}</em>`; + offset++; + } + last_copy = offset; + } else if (markdown[offset] === '_') { + output += markdown.substr(last_copy, offset - last_copy); + if (markdown[offset + 1] === '_') { + offset += 2; + let single = false; + const start = offset; + while (offset < markdown.length && (markdown.substr(offset, 2) !== '__' || single)) { + if (markdown[offset] === '\\') { + offset++; + } else if (markdown[offset] === '_') { + single = !single; + } + offset++; + } + output += html`<strong class="markdown md-bold">${compileInlineConstructs(markdown.substr(start, offset - start), data)}</strong>`; + offset += 2; + } else { + offset++; + const start = offset; + while (offset < markdown.length && markdown[offset] !== '_') { + if (markdown[offset] === '\\') { + offset++; + } + offset++; + } + output += html`<em class="markdown md-italic">${compileInlineConstructs(markdown.substr(start, offset - start), data)}</em>`; + offset++; + } + last_copy = offset; + } else if (markdown[offset] === '`') { + output += markdown.substr(last_copy, offset - last_copy); + if (markdown[offset + 1] === '`') { + offset += 2; + const start = offset; + while (offset < markdown.length && markdown.substr(offset, 2) !== '``') { + if (markdown[offset] === '\\') { + offset++; + } + offset++; + } + output += html`<code class="markdown md-inline-code">${escapeHtml(markdown.substr(start, offset - start))}</code>`; + offset += 2; + } else { + offset++; + const start = offset; + while (offset < markdown.length && markdown[offset] !== '`') { + if (markdown[offset] === '\\') { + offset++; + } + offset++; + } + output += html`<code class="markdown md-inline-code">${escapeHtml(markdown.substr(start, offset - start))}</code>`; + offset++; + } + last_copy = offset; + } else { + offset++; + } + } + output += markdown.substr(last_copy, offset - last_copy); + return output; +} + +function linesToParagraph(lines: string[], paragraphs: string[], data: LinkRegistry) { + if (lines.length !== 0 && lines.join(' ').trim().length !== 0) { + paragraphs.push(html`<p class="markdown md-paragraph">${compileInlineConstructs(lines.join(' '), data)}</p>`); + } + lines.splice(0); +} + +function compileLines(lines: string[], data: LinkRegistry): string[] { + const lines_to_convert: string[] = []; + const converted_paragraphs: string[] = []; + while (lines.length !== 0) { + const line_orig = lines.shift() ?? ''; + const line = line_orig.trim(); + if (line.length === 0) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + } else if (line_orig.startsWith(' ') || line_orig.startsWith('\t')) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + let code = line_orig.substr(line_orig.startsWith(' ') ? 4 : 1) + '\n'; + let tmp_code = ''; + while (lines.length !== 0) { + const next_line = lines.shift() ?? ''; + const is_empty = next_line.trim().length === 0; + if (next_line.startsWith(' ') || next_line.startsWith('\t')) { + code += tmp_code + next_line.substr(next_line.startsWith(' ') ? 4 : 1) + '\n'; + tmp_code = ''; + } else { + if (!is_empty) { + lines.unshift(next_line); + break; + } else { + tmp_code += next_line + '\n'; + } + } + } + converted_paragraphs.push(html`<code class="markdown md-code"><pre class="markdown ">${escapeHtml(code)}</pre></code>`); + } else if (line.startsWith('#')) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + let header_num = 1; + while (line[header_num] === '#') { + header_num++; + } + if (line.endsWith('}')) { + const match = line.match(/\{#([^}]*)\}$/); + const text = compileInlineConstructs(line.substr(header_num, line.length - header_num - (match?.[0].length ?? 0)).trim(), data); + header_num = Math.min(header_num, 6); + converted_paragraphs.push(`<h${header_num} id="${match?.[1]}" class="markdown md-header-${header_num}">${text}</h${header_num}>`); + } else { + const text = compileInlineConstructs(line.substr(header_num).trim(), data); + header_num = Math.min(header_num, 6); + converted_paragraphs.push(`<h${header_num} class="markdown md-header-${header_num}">${text}</h${header_num}>`); + } + } else if (line.startsWith('===') && !line.match(/[^=]/) && lines_to_convert.length !== 0) { + converted_paragraphs.push(html`<h1 class="markdown md-header-1">${compileInlineConstructs(lines_to_convert.join(' '), data)}</h1>`); + lines_to_convert.splice(0); + } else if (line.startsWith('---') && !line.match(/[^-]/) && lines_to_convert.length !== 0) { + converted_paragraphs.push(html`<h2 class="markdown md-header-2">${compileInlineConstructs(lines_to_convert.join(' '), data)}</h2>`); + lines_to_convert.splice(0); + } else if ( + line.startsWith('---') && !line.match(/[^-]/) + || line.startsWith('***') && !line.match(/[^*]/) + || line.startsWith('___') && !line.match(/[^_]/) + ) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + converted_paragraphs.push(html`<hr class="markdown md-hrule"/>`); + } else if (line.match(/^\|?(([^`]|`.*?`)*\|)+([^`]|`.*?`)*\|?$/)) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + const table = [ line.split('|') ]; + while (lines.length !== 0 && lines[0].match(/^\|?(([^`]|`.*?`)*\|)+([^`]|`.*?`)*\|?$/)) { + const next_line = lines.shift()?.trim() ?? ''; + table.push(next_line.split('|')); + } + let alignment: string[] | undefined; + if (table[1]?.some(field => field.trim().match(/^:?\s*-{3,}\s*:?$/))) { + alignment = table[1] + .map(field => field.trim()) + .map(field => ( + field.endsWith(':') + ? field.startsWith(':') + ? 'md-align-center' + : 'md-align-right' + : 'md-align-left' + )); + } + const length = table.reduce((a, row) => Math.max(a, row.length), 0); + const has_first = table.some(row => row[0].trim()); + const has_last = table.some(row => row[length - 1]?.trim()); + converted_paragraphs.push(html` + <table class="markdown md-table">${table.filter((_, i) => !alignment || i !== 1).map((row, i) => html` + <tr class="markdown md-table-row">${row + .filter((_, j) => (has_first || j !== 0) && (has_last || j !== length - 1)) + .map((field, j) => ( + (i === 0 && alignment) + ? html`<th class="markdown md-table-header ${alignment?.[j] || ''}">${compileInlineConstructs(field, data)}</th>` + : html`<td class="markdown md-table-data ${alignment?.[j] || ''}">${compileInlineConstructs(field, data)}</td>` + )) + }</tr> + `)}</table> + `); + } else if (line.match(/^\s*([0-9]+[.)]|-\s?\[[Xx ]?\]|[+*-] )/)) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + const regex = /^\s*([0-9]+[.)]|[+*-] )/; + const listTypeAndOrder = function (line: string): [ RegExp, number ] { + const match = line.match(/^\s*([0-9]+[.)]|-\s?\[[Xx ]?\]|[+*-] )/); + if (match?.[0].includes(')')) { + return [ /^\s*[0-9]+[)]/, 1 ]; + } else if (match?.[0].includes('.')) { + return [ /^\s*[0-9]+[.]/, 1 ]; + } else if (match?.[0].includes('[')) { + return [ /^\s*-\s?\[[Xx ]?\]/, 2 ]; + } else if (match?.[0].includes('*')) { + return [ /^\s*[*] /, 0 ]; + } else if (match?.[0].includes('+')) { + return [ /^\s*[+] /, 0 ]; + } else if (match?.[0].includes('-')) { + return [ /^\s*[-] /, 0 ]; + } else { + return [ / /, 0 ]; + } + } + const generateList = function (): string { + if (ordered === 1) { + return html`<ol class="markdown md-ordered-list">${ + items.map(item => html`<li>${compileLines(item, data)}</li>`) + }</ol>`; + } else if (ordered === 0) { + return html`<ul class="markdown md-unordered-list">${ + items.map(item => html`<li>${compileLines(item, data)}</li>`) + }</ul>`; + } else if (ordered === 2) { + return html`<ul class="markdown md-todo-list">${ + items.map(item => { + const match = item[0].match(/^\s?\[([Xx ]?)\]/); + const checked = match?.[1]?.toLowerCase() === 'x' ? 'checked' : ''; + item[0] = item[0].replace(/^\s?\[([Xx ]?)\]/, ''); + return [html` + <li> + <input type="checkbox" name="_" disabled ${checked} /><span>${compileLines(item, data)}</span> + </li>` + ] + }) + }</ul>`; + } else { + return ''; + } + } + const nesting_stack : [ string[][], RegExp, number, number ][] = [ ]; + let [ listType, ordered ] = listTypeAndOrder(line); + let items = [ [ line.replace(regex, '') ] ]; + let last_indent = 0; + while (line_orig[last_indent] === ' ' || line_orig[last_indent] === '\t') { + last_indent++; + } + let was_empty = false; + while (lines.length !== 0) { + const next_line = lines.shift() ?? ''; + const is_empty = next_line.trim().length === 0; + if (next_line.match(regex)) { + let indent = 0; + while (next_line[indent] === ' ' || next_line[indent] === '\t') { + indent++; + } + if (indent > last_indent) { + nesting_stack.push([ items, listType, last_indent, ordered ]); + items = []; + [ listType, ordered ] = listTypeAndOrder(next_line); + } else if (indent < last_indent && nesting_stack.length !== 0) { + while (nesting_stack.length !== 0 && indent < last_indent) { + let sub_list = generateList() ?? ''; + [ items, listType, last_indent, ordered ] = nesting_stack.pop() ?? [ [], / /, 0, 0 ]; + items[items.length - 1].push(sub_list); + } + } + if (!next_line.match(listType)) { + if (nesting_stack.length !== 0) { + const sub_list = generateList() ?? ''; + [ items, listType, last_indent, ordered ] = nesting_stack.pop() ?? [ [], / /, 0, 0 ]; + items[items.length - 1].push(sub_list); + nesting_stack.push([ items, listType, last_indent, ordered ]); + items = []; + } else { + converted_paragraphs.push(generateList()); + items = []; + } + [ listType, ordered ] = listTypeAndOrder(next_line); + } + items.push([ next_line.replace(regex, '') ]); + last_indent = indent; + } else if (next_line.substr(last_indent).startsWith(' ') || next_line.substr(last_indent).startsWith('\t')) { + items[items.length - 1].push(next_line.substr(last_indent + (next_line.substr(last_indent).startsWith(' ') ? 4 : 1))); + } else { + if (!is_empty && was_empty) { + lines.unshift(next_line); + break; + } else { + items[items.length - 1].push(next_line); + } + } + was_empty = is_empty; + } + while (nesting_stack.length !== 0) { + let sub_list = generateList(); + [ items, listType, last_indent, ordered ] = nesting_stack.pop() ?? [ [], / /, 0, 0 ]; + items[items.length - 1].push(sub_list); + } + converted_paragraphs.push(generateList()); + } else if (line.startsWith('```') || line.startsWith('~~~')) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + let code = ''; + while (lines.length !== 0 && lines[0].trim() !== line.substr(0, 3)) { + const next_line = lines.shift(); + code += next_line + '\n'; + } + code = code.substr(0, code.length - 1); // Remove the trailing newline + lines.shift(); + code = escapeHtml(code); + converted_paragraphs.push(html` + <code class="markdown md-code"> + <span class="markdown md-lines">${code.split('\n').map((_, i) => html`<span class="markdown md-line">${i + 1}</span>`)}</span> + <span class="markdown md-code-content"><pre class="markdown ">${code}</pre></span> + </code> + `); + } else if (line.startsWith('>')) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + let quote_lines = [ line.substr(1) ]; + while (lines.length !== 0 && lines[0].trim().length !== 0) { + const next_line = lines.shift() ?? ''; + if (next_line.trim().startsWith('>')) { + quote_lines.push(next_line.trim().substr(1)); + } else { + quote_lines.push(next_line); + } + } + converted_paragraphs.push(html`<div class="markdown md-quote">${compileLines(quote_lines, data)}</div>`) + } else if (line.match(/^\[(.*)\]:/)) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + const items = []; + let start = -1; + let next_line = line; + while (next_line?.match(/^\[(\^.*)\]:(.*)/)) { + const match = next_line.trim().match(/^\[(.*)\]:(.*)/); + let fn_lines = [ match?.[2] ?? '' ]; + while (lines.length !== 0) { + const next_line = lines.shift() ?? ''; + const is_empty = next_line.trim().length === 0; + if (next_line.startsWith(' ') || next_line.startsWith('\t')) { + fn_lines.push(next_line.substr(next_line.startsWith(' ') ? 4 : 1)); + } else { + if (!is_empty) { + lines.unshift(next_line); + break; + } else { + fn_lines.push(next_line); + } + } + } + const name = match?.[1].toLowerCase() ?? ''; + if (start < 0) { + start = (data[name] as Footnote)?.id; + } + items.push(html`<li id="fn:${name}">${compileLines(fn_lines, data)}</li>`); + next_line = lines.shift() ?? ''; + } + if (items.length > 0) { + if (next_line) { + lines.unshift(next_line); + } + converted_paragraphs.push(html` + <ol class="markdown md-footnote" start="${start}"> + ${items} + </ol> + `); + } + } else if (line.startsWith('[') && line.endsWith(']')) { + linesToParagraph(lines_to_convert, converted_paragraphs, data); + let inline = false; + if (line[1] === '^') { + inline = true; + } + const to_wrap = converted_paragraphs.pop() ?? ''; + converted_paragraphs.push(html` + <figure class="markdown md-info-wrap ${inline ? 'md-inline-info' : ''}"> + ${to_wrap} + <figcaption class="markdown md-info">${compileInlineConstructs(line.substr(inline ? 2 : 1, line.length - (inline ? 3 : 2)), data)}</figcaption> + </figure> + `); + } else { + let actual_line = line; + if (line.endsWith('\\')) { + actual_line = line.substr(0, line.length - 1) + html`<br />`; + } else if (line_orig.endsWith(' ')) { + actual_line += html`<br />`; + } + lines_to_convert.push(actual_line); + } + } + linesToParagraph(lines_to_convert, converted_paragraphs, data); + return converted_paragraphs; +} + +function getFootnoteRefs(lines: string[]) { + const data: LinkRegistry = {}; + let footnote_id = 1; + for (const line_orig of lines) { + const line = line_orig.trim(); + const match = line.match(/^\[(.*)\]:(.*)/); + if (match) { + if (match[1].startsWith('^')) { + data[match[1].toLowerCase()] = { + id: footnote_id, + }; + footnote_id++; + } else { + const link = match[2].match(/([^('"]*)([('"](.*)[)'"])?/); + data[match[1].toLowerCase()] = { + link: link?.[1].trim(), + title: link?.[3], + }; + } + } + } + return data; +} + +export function compileMarkdown(markdown: string): string { + const lines = markdown.split('\n'); + const data = getFootnoteRefs(lines); + return compileLines(lines, data).join(''); +} + -- GitLab