diff options
| author | LMBishop <13875753+LMBishop@users.noreply.github.com> | 2021-11-20 17:46:20 +0000 |
|---|---|---|
| committer | LMBishop <13875753+LMBishop@users.noreply.github.com> | 2021-11-20 17:46:20 +0000 |
| commit | f4a3e9bf53c657c3e6b9330eb6ad644094f75e61 (patch) | |
| tree | 4cde00e1c6116281a79a2f5b0f3746a9bfc367a5 /app | |
Initial commit
Diffstat (limited to 'app')
| -rw-r--r-- | app/constants.mjs | 7 | ||||
| -rw-r--r-- | app/directory.mjs | 92 | ||||
| -rw-r--r-- | app/index.mjs | 94 | ||||
| -rw-r--r-- | app/static/css/globalstyles.css | 76 | ||||
| -rw-r--r-- | app/static/scripts/purge.js | 15 | ||||
| -rw-r--r-- | app/views/error.ejs | 16 | ||||
| -rw-r--r-- | app/views/index.ejs | 15 | ||||
| -rw-r--r-- | app/views/page.ejs | 17 | ||||
| -rw-r--r-- | app/views/partials/header.ejs | 4 | ||||
| -rw-r--r-- | app/views/purge.ejs | 21 | ||||
| -rw-r--r-- | app/views/rebuild.ejs | 17 | ||||
| -rw-r--r-- | app/wikiparser.mjs | 254 |
12 files changed, 628 insertions, 0 deletions
diff --git a/app/constants.mjs b/app/constants.mjs new file mode 100644 index 0000000..98b1b03 --- /dev/null +++ b/app/constants.mjs @@ -0,0 +1,7 @@ +'use strict'; + +export const SERVER_PORT = 3000; +export const PARSER_MAX_RECURSION = 20; +export const PAGES_DIR = 'pages'; +export const TEMPLATE_DIR = 'pages/tempates'; +export const IMAGES_DIR = 'pages/images'; diff --git a/app/directory.mjs b/app/directory.mjs new file mode 100644 index 0000000..8c0a36e --- /dev/null +++ b/app/directory.mjs @@ -0,0 +1,92 @@ +'use strict'; + +import { PAGES_DIR } from './constants.mjs'; +import { parse } from './wikiparser.mjs'; +import { readFileSync, readdirSync } from 'fs'; + +const pages = {}; +const metadata = {}; + +const PURGE_COOLDOWN_MIN = 10; + +export function pageFor(path) { + path = path.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + let page = pages[path]; + if (!page) { + return undefined; + } + + if (!page.html) { + buildPage(path); + return pages[path]; + } + + return page; +} + +export function buildPage(path) { + let data = readFileSync(`${PAGES_DIR}/${path}.wiki`, 'utf-8'); + let result = parse(data); + let title = result.metadata.displayTitle ?? 'Unnamed page'; + let content = `<h1>${title}</h1>${result.html}`; + + let page = { + html: content, + buildTime: result.metadata.buildTime, + hidden: result.metadata.hidden, + displayTitle: title + }; + pages[path] = page; +} + +export function rebuild() { + for (var page in pages) { + delete pages[page]; + } + + readdirSync(PAGES_DIR).forEach(file => { + if (!file.endsWith('.wiki')) { + return; + } + file = file.replace('.wiki', ''); + buildPage(file); + }); + metadata.fileTreeBuildTime = new Date(); +} + +export function exists(path) { + return !!pages[path]; +} + +export function rawDataFor(path) { + return pages[path]; +} + +export function purge(path) { + let page = pages[path]; + if (page) { + if (page.buildTime.getTime() + PURGE_COOLDOWN_MIN * 60 * 1000 > Date.now()) { + return false; + } else { + pages[path] = {}; + buildPage(path); + return true; + } + } + return false; +} + +export function getPages() { + return pages; +} + +export function getNavbar(current = '') { + let navbar = ''; + for (const path of Object.keys(pages)) { + if (pages[path].hidden) { + continue; + } + navbar = navbar + `<div class="navbar-element"><a href="/${path}"${current == path ? ' class="highlight"' : ''}>${pages[path].displayTitle}</a></div>`; + } + return navbar; +} diff --git a/app/index.mjs b/app/index.mjs new file mode 100644 index 0000000..08bd8cb --- /dev/null +++ b/app/index.mjs @@ -0,0 +1,94 @@ +'use strict'; + +import { SERVER_PORT } from './constants.mjs'; +import * as directory from './directory.mjs'; +import express from 'express'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const app = express(); + +directory.rebuild(); + +app.use(express.static(__dirname + '/static')); +app.set('view engine', 'ejs'); +app.set('views', __dirname + '/views'); + +app.get('/:page?', (req, res) => { + let path = req.params.page ?? 'index'; + let page = directory.pageFor(path); + + if (!page) { + error(res, 404); + return; + } + + res.render('page.ejs', { + navbar: directory.getNavbar(path), + path: path, + content: page.html, + title: page.displayTitle, + buildTime: page.buildTime.toString() + }); +}); + +app.get('/special/purge/:page?', (req, res) => { + let path = req.params.page ?? 'index'; + let page = directory.rawDataFor(path); + + if (!page) { + error(res, 404); + return; + } + + res.render('purge.ejs', { + navbar: directory.getNavbar(), + page: path, + buildTime: page.buildTime?.toString() ?? 'never', + buildTimeRelative: Math.round((Date.now() - page.buildTime?.getTime()) / 1000 / 60) + }); +}); + +app.get('/special/purge/:page/confirm', (req, res) => { + let path = req.params.page; + let page = directory.rawDataFor(path); + + if (!page) { + error(res, 404); + return; + } + + if (directory.purge(path)) { + res.status(200).send(); + } else { + res.status(429).send(); + } +}); + +app.get('/special/rebuild', (req, res) => { + res.render('rebuild.ejs', { + navbar: directory.getNavbar() + }); +}); + +app.get('/special/rebuild/confirm', (req, res) => { + if (directory.rebuild()[0]) { + res.status(200).send(); + } else { + res.status(429).send(); + } +}); + +app.listen(SERVER_PORT, () => { + console.log(`App listening on ${SERVER_PORT}`); +}); + +function error(res, code) { + res.render('error.ejs', { + code: code, + navbar: directory.getNavbar() + }); +} diff --git a/app/static/css/globalstyles.css b/app/static/css/globalstyles.css new file mode 100644 index 0000000..3d030ab --- /dev/null +++ b/app/static/css/globalstyles.css @@ -0,0 +1,76 @@ +@import url('https://fonts.googleapis.com/css2?family=Cousine:ital,wght@0,400;0,700;1,400;1,700&display=swap'); + +html, body { + border: 0; + margin: 0; + background-color: #000; + color: #eee; + font-family: 'Cousine', monospace, sans-serif; + line-height: 1.3; +} + +#navbar { + background-color: #222; + width: 100%; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: stretch; + gap: 10px; +} + +.navbar-element { + background-color: #222; + display: flex; + height: 30px; + text-align: center; + line-height: 30px; + padding: 5px; +} + +.navbar-element:hover { + background-color: #888; + transition: 0.2s; + cursor: pointer; +} + +.navbar-element > a { + color: #fff; + text-decoration: none; +} + +.navbar-element > .highlight { + color: #ffff87; +} + +#main-container { + max-width: 1100px; + margin: 0 auto; +} + +#content { + max-width: calc(1100px - 10px); + margin: 0 auto; +} + +a { + color: #ffff87; + text-decoration: underline; +} + +.highlight { + color: #ffff87; +} + +.footer { + font-size: 10px; +} + +.redlink { + color: #ff4136; +} + +.box { + border: solid 1px #fff; + padding: 10px; +}
\ No newline at end of file diff --git a/app/static/scripts/purge.js b/app/static/scripts/purge.js new file mode 100644 index 0000000..5ee34f0 --- /dev/null +++ b/app/static/scripts/purge.js @@ -0,0 +1,15 @@ +$(() => { + $('#confirm').click(() => { + let page = $('#confirm').data('page'); + $.ajax({ + type: 'GET', + url: `/special/purge/${page}/confirm`, + success: () => { + $('#response').html('<div class=\'box\'>Successfully purged page.</div>'); + }, + error: () => { + $('#response').html('<div class=\'box\'>Could not purge page. Try again later.</div>'); + } + }); + }); +}); diff --git a/app/views/error.ejs b/app/views/error.ejs new file mode 100644 index 0000000..87296bc --- /dev/null +++ b/app/views/error.ejs @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> +<head> + <title>Error: <%= code %></title> + <link rel="stylesheet" href="/css/globalstyles.css"> +</head> +<body> + <div id="main-container"> + <%- include('partials/header') %> + <div id="content"> + <h1>An error occurred (<%= code %>)</h1> + <p>Go <a href="/">home</a>?</p> + </div> + </div> +</body> +</html> diff --git a/app/views/index.ejs b/app/views/index.ejs new file mode 100644 index 0000000..ae21964 --- /dev/null +++ b/app/views/index.ejs @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> +<head> + <title><%= title %></title> + <link rel="stylesheet" href="/css/globalstyles.css"> +</head> +<body> + <div id="main-container"> + <%- include('partials/header') %> + <div id="content"> + <%- page %> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/app/views/page.ejs b/app/views/page.ejs new file mode 100644 index 0000000..331611a --- /dev/null +++ b/app/views/page.ejs @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<head> + <title><%= title %></title> + <link rel="stylesheet" href="/css/globalstyles.css"> +</head> +<body> + <div id="main-container"> + <%- include('partials/header') %> + <div id="content"> + <%- content %> + <hr> + <span class=footer><a href="https://github.com/LMBishop/website">GitHub</a> | Page built: <%= buildTime %> | <a href="/special/purge/<%= path %>">Purge this page</a></span> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/app/views/partials/header.ejs b/app/views/partials/header.ejs new file mode 100644 index 0000000..25f5b2b --- /dev/null +++ b/app/views/partials/header.ejs @@ -0,0 +1,4 @@ +<h1>Leonardo Bishop</h1> +<div id="navbar"> + <%- navbar %> +</div>
\ No newline at end of file diff --git a/app/views/purge.ejs b/app/views/purge.ejs new file mode 100644 index 0000000..f36e482 --- /dev/null +++ b/app/views/purge.ejs @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html> +<head> + <title>Purge page</title> + <link rel="stylesheet" href="/css/globalstyles.css"> + <script src="https://code.jquery.com/jquery-3.6.0.min.js" ntegrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> + <script src="/scripts/purge.js"></script> +</head> +<body> + <div id="main-container"> + <%- include('partials/header') %> + <div id="content"> + <h1>Purge page</h1> + <span id="response"></span> + <p>Are you sure you wish to purge the page <span class="highlight"><%= page %></span>?</p> + <p>The last build time for this page was <span class="highlight"><%= buildTime %></span> (<span class="highlight"><%= buildTimeRelative %></span> minutes ago).</p> + <button id="confirm" data-page="<%= page %>">Confirm</button> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/app/views/rebuild.ejs b/app/views/rebuild.ejs new file mode 100644 index 0000000..e2943f1 --- /dev/null +++ b/app/views/rebuild.ejs @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<head> + <title>Rebuild</title> + <link rel="stylesheet" href="/css/globalstyles.css"> +</head> +<body> + <div id="main-container"> + <%- include('partials/header') %> + <div id="content"> + <h1>Rebuild</h1> + <p>Are you sure you wish to rebuild the page directory?</p> + <button>Confirm</button> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/app/wikiparser.mjs b/app/wikiparser.mjs new file mode 100644 index 0000000..c48dca3 --- /dev/null +++ b/app/wikiparser.mjs @@ -0,0 +1,254 @@ +'use strict'; + +import { PARSER_MAX_RECURSION, TEMPLATE_DIR, IMAGES_DIR } from './constants.mjs'; +import * as fs from 'fs'; + +const re = (regex, flag = 'mgi') => { + return RegExp(regex.replace(/ /g, '').replace(/\|\|.+?\|\|/g, ''), flag); +}; +const r = String.raw; +const arg = r`\s*([^|}]+?)\s*`; + +export function parse(data) { + const vars = {}; + const metadata = {}; + let nowikis = []; + let nowikiCount = 0; + let rawExtLinkCount = 0; + let refCount = 0; + let refs = []; + + let outText = data; + + for (let l = 0, last = ''; l < PARSER_MAX_RECURSION; l++) { + if (last === outText) break; last = outText; + + outText = outText + + // Nowiki: <nowiki></nowiki> + .replace(re(r`<nowiki> ([^]+?) </nowiki>`), (_, m) => `%NOWIKI#${nowikis.push(m), nowikiCount++}%`) + + // Sanitise unacceptable HTML + .replace(re(r`<(/?) \s* (?= script|link|meta|iframe|frameset|object|embed|applet|form|input|button|textarea )`), '<$1') + .replace(re(r`(?<= <[^>]+ ) (\bon(\w+))`), 'data-$2') + + // Comments: <!-- --> + .replace(/<!--[^]+?-->/g, '') + + // Lines: ---- + .replace(/^-{4,}/gm, '<hr>') + + // Metadata: displayTitle, __NOTOC__, etc + .replace(re(r`{{ \s* displayTitle: ([^}]+) }}`), (_, title) => (metadata.displayTitle = title, '')) + .replace(re(r`__NOINDEX__`), () => (metadata.noindex = true, '')) + .replace(re(r`__NOTOC__`), () => (metadata.notoc = true, '')) + .replace(re(r`__FORCETOC__`), () => (metadata.toc = true, '')) + .replace(re(r`__TOC__`), () => (metadata.toc = true, '<toc></toc>')) + .replace(re(r`__HIDDEN__`), () => (metadata.hidden = true, '')) + + // Magic words: {{!}}, {{reflist}}, etc + .replace(re(r`{{ \s* ! \s* }}`), '|') + .replace(re(r`{{ \s* = \s* }}`), '=') + .replace(re(r`{{ \s* [Rr]eflist \s* }}`), '<references/>') + + // String functions: {{lc:}}, {{ucfirst:}}, {{len:}}, etc + .replace(re(r`{{ \s* #? urlencode: ${arg} }}`), (_, m) => encodeURI(m)) + .replace(re(r`{{ \s* #? urldecode: ${arg} }}`), (_, m) => decodeURI(m)) + .replace(re(r`{{ \s* #? lc: ${arg} }}`), (_, m) => m.toLowerCase()) + .replace(re(r`{{ \s* #? uc: ${arg} }}`), (_, m) => m.toUpperCase()) + .replace(re(r`{{ \s* #? lcfirst: ${arg} }}`), (_, m) => m[0].toLowerCase() + m.substr(1)) + .replace(re(r`{{ \s* #? ucfirst: ${arg} }}`), (_, m) => m[0].toUpperCase() + m.substr(1)) + .replace(re(r`{{ \s* #? len: ${arg} }}`), (_, m) => m.length) + .replace(re(r`{{ \s* #? pos: ${arg} \|${arg} (?: \s*\|${arg} )? }}`), (_, find, str, n = 0) => find.substr(n).indexOf(str)) + .replace(re(r`{{ \s* #? sub: ${arg} \|${arg} (?:\|${arg})? }}`), (_, str, from, len) => str.substr(+from - 1, +len)) + .replace(re(r`{{ \s* #? padleft: ${arg} \|${arg} \|${arg} }}`), (_, str, n, char) => str.padStart(+n, char)) + .replace(re(r`{{ \s* #? padright: ${arg} \|${arg} \|${arg} }}`), (_, str, n, char) => str.padEnd(+n, char)) + .replace(re(r`{{ \s* #? replace: ${arg} \|${arg} \|${arg} }}`), (_, str, find, rep) => str.split(find).join(rep)) + .replace(re(r`{{ \s* #? explode: ${arg} \|${arg} \|${arg} }}`), (_, str, delim, pos) => str.split(delim)[+pos]) + + // Parser functions: {{#if:}}, {{#switch:}}, etc + .replace(re(r`{{ \s* (#\w+) \s* : \s* ( [^{}]+ ) \s* }} ( ?!} )`), (_, name, content) => { + if (/{{\s*#/.test(content)) return _; + const args = content.trim().split(/\s*\|\s*/); + switch (name) { + case '#if': + return (args[0] ? args[1] : args[2]) || ''; + case '#ifeq': + return (args[0] === args[1] ? args[2] : args[3]) || ''; + case '#vardefine': + vars[args[0]] = args[1] || ''; + return ''; + case '#var': + if (re(r`{{ \s* #vardefine \s* : \s* ${args[0]}`).test(outText)) return _; // wait until var is set + return vars[args[0]] || args[1] || ''; + case '#switch': + return args.slice(1) + .map(arg => arg.split(/\s*=\s*/)) + .filter(duo => args[0] === duo[0].replace('#default', args[0]))[0][1]; + case '#time': + case '#date': + case '#datetime': + return dateFormat(args[1] ? new Date(args[1]) : new Date(), args[0]); + } + }) + + // Templates: {{template}} + .replace(re(r`{{ \s* ([^#}|]+?) (\|[^}]+)? }} (?!})`), (_, title, params = '') => { + if (/{{/.test(params)) return _; + const page = TEMPLATE_DIR + '/' + title.trim().replace(/ /g, '_'); + + // Retrieve template content + let content = ''; + try { + content = fs.readFileSync(page + '.wiki', 'utf8' ); + } + catch { + return `<a class="internal-link redlink" title="${title}" href="${page}">${title}</a>`; + } + + // Remove non-template sections + content = content + .replace(/<noinclude>.*?<\/noinclude>/gs, '') + .replace(/.*<(includeonly|onlyinclude)>|<\/(includeonly|onlyinclude)>.*/gs, ''); + + // Substitite arguments + const argMatch = (arg) => re(r`{{{ \s* ${arg} (?:\|([^}]*))? \s* }}}`); + let args = params.split('|').slice(1); + for (let i in args) { + let parts = args[i].split('='); + let [arg, val] = parts[1] ? [parts[0], ...parts.slice(1)] : [(+i + 1) + '', parts[0]]; + content = content.replace(argMatch(arg), (_, m) => val || m || ''); + } + for (let i = 1; i <= 10; i++) { + content = content.replace(argMatch(arg), '$2'); + } + + return content; + }) + + // Images: [[File:Image.png|options|caption]] + .replace(re(r`\[\[ (?:File|Image): (.+?) (\|.+?)? \]\]`), (_, file, params) => { + if (/{{/.test(params)) return _; + const path = IMAGES_DIR + '/' + file.trim().replace(/ /g, '_'); + let caption = ''; + let imageData = {}; + let imageArgs = params.split('|').map((arg) => arg.replace(/"/g, '"')); + for (const param of imageArgs) { + if (['left', 'right', 'center', 'none'].includes(param)) { + imageData.float = param; + } + if (['baseline', 'sub', 'super', 'top', 'text-bottom', 'middle', 'bottom', 'text-bottom'].includes(param)) { + imageData.align = param; + } + else if (['border', 'frameless', 'frame', 'framed', 'thumb', 'thumbnail'].includes(param)) { + imageData.type = { framed: 'frame', thumbnail: 'thumb' }[param] || param; + if (imageData.type === 'thumb') imageData.hasCaption = true; + } + else if (param.endsWith('px')) { + param.replace(/(?:(\w+)?(x))?(\w+)px/, (_, size1, auto, size2) => { + if (size1) Object.assign(imageData, { width: size1, height: size2 }); + else if (auto) Object.assign(imageData, { width: 'auto', height: size2 }); + else Object.assign(imageData, { width: size2, height: 'auto' }); + return ''; + }); + } + else if (param.startsWith('upright=')) { + imageData.width = +param.replace('upright=', '') * 300; + } + else if (param.startsWith('link=')) { + imageData.link = param.replace('link=', ''); + } + else if (param.startsWith('alt=')) { + imageData.alt = param.replace('alt=', ''); + } + else if (param.startsWith('style=')) { + imageData.style = param.replace('style=', ''); + } + else if (param.startsWith('class=')) { + imageData.class = param.replace('class=', ''); + } + else { + caption = param; + } + } + let content = ` + <figure + class="${imageData.class || ''} image-container image-${imageData.type || 'default'}" + style="float:${imageData.float || 'none'};vertical-align:${imageData.align || 'unset'};${imageData.style || ''}" + > + <img + src="${path}" + alt="${imageData.alt || file}" + width="${imageData.width || 300}" + height="${imageData.height || 300}" + > + ${imageData.hasCaption ? `<figcaption>${caption}</figcaption>` : ''} + </figure> + `; + if (imageData.link) content = `<a href="/${imageData.link}" title="${imageData.link}">${content}</a>`; + return content; + }) + + // Markup: '''bold''' and '''italic''' + .replace(re(r`''' ([^']+?) '''`), '<b>$1</b>') + .replace(re(r`'' ([^']+?) ''`), '<i>$1</i>') + + // Headings: ==heading== + .replace(re(r`^ (=+) \s* (.+?) \s* \1 \s* $`), (_, lvl, txt) => `<h${lvl.length} id="${encodeURI(txt.replace(/ /g, '_'))}">${txt}</h${lvl.length}>`) + + // Internal links: [[Page]] and [[Page|Text]] + .replace(re(r`\[\[ ([^\]|]+?) \]\]`), '<a class="internal-link" title="$1" href="$1">$1</a>') + .replace(re(r`\[\[ ([^\]|]+?) \| ([^\]]+?) \]\]`), '<a class="internal-link" title="$1" href="/$1">$2</a>') + .replace(re(r`(</a>)([a-z]+)`), '$2$1') + + // External links: [href Page] and just [href] + .replace(re(r`\[ ((?:\w+:)?\/\/ [^\s\]]+) (\s [^\]]+?)? \]`), (_, href, txt) => `<a class="external-link" href="${href}">${txt || '[' + (++rawExtLinkCount) + ']'}</a>`) + + // Bulleted list: *item + .replace(re(r`^ (\*+) (.+?) $`), (_, lvl, txt) => `${'<ul>'.repeat(lvl.length)}<li>${txt}</li>${'</ul>'.repeat(lvl.length)}`) + .replace(re(r`</ul> (\s*?) <ul>`), '$1') + + // Numbered list: #item + .replace(re(r`^ (#+) (.+?) $`), (_, lvl, txt) => `${'<ol>'.repeat(lvl.length)}<li>${txt}</li>${'</ol>'.repeat(lvl.length)}`) + .replace(re(r`</ol> (\s*?) <ol>`), '$1') + + // Definition list: ;head, :item + .replace(re(r`^ ; (.+) $`), '<dl><dt>$1</dt></dl>') + .replace(re(r`^ (:+) (.+?) $`), (_, lvl, txt) => `${'<dl>'.repeat(lvl.length)}<dd>${txt}</dd>${'</dl>'.repeat(lvl.length)}`) + .replace(re(r`</dl> (\s*?) <dl>`), '$1') + + // Tables: {|, |+, !, |-, |, |} + .replace(re(r`^ \{\| (.*?) $`), (_, attrs) => `<table ${attrs}><tr>`) + .replace(re(r`^ ! ([^]+?) (?= \n^[!|] )`), (_, content) => `<th>${content}</th>`) + .replace(re(r`^ \|\+ (.*?) $`), (_, content) => `<caption>${content}</caption>`) + .replace(re(r`^ \|[^-+}] ([^]*?) (?= \n^[!|] )`), (_, content) => `<td>${content}</td>`) + .replace(re(r`^ \|- (.*?) $`), (_, attrs) => `</tr><tr ${attrs}>`) + .replace(re(r`^ \|\}`), '</tr></table>') + + // References: <ref></ref>, <references/> + .replace(re(r`<ref> (.+?) </ref>`), (_, text) => { + refs.push(text); + refCount++; + return `<sup><a id="cite-${refCount}" class="ref" href="#ref-${refCount}">[${refCount}]</a></sup>`; + }) + .replace(re(r`<references \s* /?>`), '<ol>' + refs.map((ref, i) => + `<li id="ref-${+i + 1}"> <a href="#cite-${+i + 1}">↑</a> ${ref} </li>`).join('\n') + '</ol>' + ) + + // Nonstandard: ``code`` and ```code blocks``` + .replace(re(r` \`\`\` ([^\`]+?) \`\`\` `), '<pre>$1</pre>') + .replace(re(r` \`\` ([^\`]+?) \`\` `), '<code>$1</code>') + + // Spacing + .replace(/(\r?\n){2}/g, '\n</p><p>\n') + + // Restore nowiki contents + .replace(/%NOWIKI#(\d+)%/g, (_, n) => htmlEscape(nowikis[n])); + } + metadata.buildTime = new Date(); + + let result = {}; + result.html = outText; + result.metadata = metadata; + return result; +} |
