aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
authorLMBishop <13875753+LMBishop@users.noreply.github.com>2021-11-20 17:46:20 +0000
committerLMBishop <13875753+LMBishop@users.noreply.github.com>2021-11-20 17:46:20 +0000
commitf4a3e9bf53c657c3e6b9330eb6ad644094f75e61 (patch)
tree4cde00e1c6116281a79a2f5b0f3746a9bfc367a5 /app
Initial commit
Diffstat (limited to 'app')
-rw-r--r--app/constants.mjs7
-rw-r--r--app/directory.mjs92
-rw-r--r--app/index.mjs94
-rw-r--r--app/static/css/globalstyles.css76
-rw-r--r--app/static/scripts/purge.js15
-rw-r--r--app/views/error.ejs16
-rw-r--r--app/views/index.ejs15
-rw-r--r--app/views/page.ejs17
-rw-r--r--app/views/partials/header.ejs4
-rw-r--r--app/views/purge.ejs21
-rw-r--r--app/views/rebuild.ejs17
-rw-r--r--app/wikiparser.mjs254
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 )`), '&lt;$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* }}`), '&vert;')
+ .replace(re(r`{{ \s* = \s* }}`), '&equals;')
+ .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, '&quot;'));
+ 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;
+}