--- /dev/null
+'use strict';
+
+/**
+ * A bunch of shorthand to put together common parts of an HTML page.
+ */
+
+/**
+ * Some fields may have values outside normal dates, handle them here.
+ * @param {Date} date
+ * @param {String} otherwise
+ */
+const dateOrNot = (date, otherwise) => {
+ if (!date) {
+ return otherwise;
+ }
+ if (typeof date === 'number') {
+ date = new Date(date);
+ }
+ const dateMs = date.getTime();
+ if (!Number.isFinite(dateMs)
+ || dateMs == 0) {
+ return otherwise;
+ }
+ return date.toString();
+};
+
+
+/**
+ * Render a duration.
+ * @param {Number} seconds
+ * @returns {String}
+ */
+const secondsToPeriod = (seconds) => {
+ let value = seconds;
+ const result = [];
+
+ const nextResult = (factor, label) => {
+ const r = factor ? value % factor : value;
+ if (r) {
+ result.push(`${r} ${label}${r != 1 ? 's' : ''}`);
+ }
+ value = factor ? Math.floor(value / factor) : value;
+ }
+
+ nextResult(60, 'second');
+ nextResult(60, 'minute');
+ nextResult(24, 'hour');
+ nextResult(30, 'day');
+ nextResult(undefined, 'month');
+
+ result.reverse();
+ return result.join(' ');
+};
+
+
+/**
+ * Render the preamble <head> for an HTML page.
+ * @param {Number} pagePathLevel number of paths below root this page is
+ * @param {Object} ctx
+ * @param {Object} options
+ * @param {String[]=} options.headElements
+ * @param {String=} options.pageTitle
+ * @returns {String}
+ */
+function htmlHead(pagePathLevel, ctx, options) {
+ const rootPathPfx = '../'.repeat(pagePathLevel);
+ const {
+ headElements = [],
+ pageTitle = '',
+ } = options;
+ return `\t<head>
+\t\t<meta charset="utf-8">
+\t\t<link rel="stylesheet" href="${rootPathPfx}static/theme.css">
+\t\t<link rel="stylesheet" href="${rootPathPfx}static/custom.css">
+${headElements.map((e) => '\t\t' + e).join('\n')}
+\t\t<title>${pageTitle}</title>
+\t</head>`;
+}
+
+
+/**
+ * Render the main content of an HTML page.
+ * @param {Number} pagePathLevel
+ * @param {Object} ctx
+ * @param {Object} options
+ * @param {Object} options.bodyAttributes
+ * @param {String[]} main
+ * @returns {String}
+ */
+function htmlBody(pagePathLevel, ctx, options, main = []) {
+ const {
+ bodyAttributes = {},
+ } = options;
+ return `
+\t<body${elementAttributes(bodyAttributes)}>
+${htmlHeader(pagePathLevel, ctx, options)}
+${htmlMessages(ctx, options)}
+\t\t<main>
+${main.join('\n')}
+\t\t</main>
+${htmlFooter(ctx, options)}
+\t</body>`;
+}
+
+
+/**
+ * Render a navigation link for the header section.
+ * @param {Object} nav
+ * @param {String} nav.href
+ * @param {String} nav.class
+ * @param {String} nav.text
+ * @returns {String}
+ */
+function renderNavLink(nav) {
+ return `<a href="${nav.href}"${nav.class ? (' class="' + nav.class + '"') : ''}>${nav.text}</a>`;
+}
+
+
+/**
+ * Render the navigation section of the page header.
+ * @param {Object} ctx
+ * @param {Object} options
+ * @param {Object[]=} options.navLinks
+ * @returns {String}
+ */
+function htmlNav(ctx, options) {
+ const indent = 3;
+ const spacer = '\t'.repeat(indent);
+ const {
+ navLinks = [],
+ } = options;
+ return navLinks.length ? `${spacer}<nav>
+${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
+${spacer}</nav>` : '';
+}
+
+
+/**
+ * Render the banner and navigation header.
+ * @param {Number} pagePathLevel
+ * @param {Object} ctx
+ * @param {Object} options
+ * @param {String[]=} options.logoUrl
+ * @param {String[]=} options.pageTitle
+ * @returns {String}
+ */
+function htmlHeader(pagePathLevel, ctx, options) {
+ const rootPathPfx = '../'.repeat(Math.max(pagePathLevel - 1, 0));
+ const {
+ logoUrl = '',
+ pageTitle = '',
+ } = options;
+ const logoImg = logoUrl ? `<img src="${rootPathPfx}${logoUrl}" class="logo">` : '';
+ return `\t\t<header>
+\t\t\t<h1>${logoImg}${pageTitle}</h1>
+${htmlNav(ctx, options)}
+\t\t</header>`;
+}
+
+
+/**
+ * Render the bottom boilerplate.
+ * @param {Object} ctx
+ * @param {Object} options
+ * @param {String[]} options.footerEntries
+ * @returns {String}
+ */
+function htmlFooter(ctx, options) {
+ const indent = 2;
+ const spacer = '\t'.repeat(indent);
+ const {
+ footerEntries = [],
+ } = options;
+
+ return footerEntries.length ? `${spacer}<footer>
+${OL(footerEntries, indent + 1)}
+${spacer}</footer>` : '';
+}
+
+
+/**
+ * Convert an object into element attributes.
+ * @param {Object} attributes
+ * @returns {String}
+ */
+function elementAttributes(attributes) {
+ const attr = Object.entries(attributes).map(([name, value]) => `${name}="${value}"`).join(' ');
+ return attr ? ' ' + attr : '';
+}
+
+
+/**
+ * Wrap an item in a list item element.
+ * @param {String} item
+ * @param {Number} indent
+ * @param {Object} attributes
+ * @returns {String}
+ */
+function LI(item, indent = 0, attributes = {}) {
+ const spacer = '\t'.repeat(indent);
+ return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
+}
+
+
+/**
+ * Wrap a list of items in an unordered list.
+ * @param {String[]} items
+ * @param {Number} indent
+ * @param {Object} attributes
+ * @param {(item) => Object} itemAttributeGenerator
+ * @returns {String}
+ */
+function UL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
+ const spacer = '\t'.repeat(indent);
+ return `${spacer}<ul${elementAttributes(attributes)}>
+${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
+${spacer}</ul>`;
+}
+
+
+/**
+ * Wrap a list of items in an ordered list.
+ * @param {String[]} items
+ * @param {Number} indent
+ * @param {Object} attributes
+ * @param {(item) => Object} itemAttributeGenerator
+ * @returns {String}
+ */
+
+function OL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
+ const spacer = '\t'.repeat(indent);
+ return `${spacer}<ol${elementAttributes(attributes)}>
+${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
+${spacer}</ol>`;
+}
+
+
+/**
+ * Show any error or notice messages from context.
+ * @param {Object} ctx
+ * @param {String[]=} ctx.errors
+ * @param {String[]=} ctx.notifications
+ * @param {Object} options
+ * @param {String=} options.errorHeading
+ * @param {String[]=} options.errorContent
+ * @param {String=} options.notificationHeading
+ * @param {String[]=} options.notificationContent
+ */
+function htmlMessages(ctx, options) {
+ const errorHeading = options && options.errorHeading ? `
+\t<h2>${options.errorHeading}</h2>` : '';
+ const errorContent = options && options.errorContent && options.errorContent.length ? '\n' + options.errorContent.map(((content) => `\t${content}`)).join('\n') : '';
+ const notificationHeading = options && options.notificationHeading ? `\n\t<h2>${options.notificationHeading}</h2>` : '';
+ const notificationContent = options && options.notificationContent && options.notificationContent.length ? '\n' + options.notificationContent.map(((content) => `\t${content}`)).join('\n') : '';
+ const errors = ctx && ctx.errors && ctx.errors.length ? `
+<section class="error">${errorHeading}${errorContent}
+${UL(ctx.errors, 1)}
+</section>` : '';
+ const notifications = ctx && ctx.notifications && ctx.notifications.length ? `
+<section class="notification">${notificationHeading}${notificationContent}
+${UL(ctx.notifications, 1)}
+</section>` : '';
+ return [errors, notifications].join('\n');
+}
+
+
+/**
+ * Render all parts of an HTML page. Adds user logout nav link automatically.
+ * @param {Number} pagePathLevel - relative path-distance to base
+ * @param {Object} ctx
+ * @param {Object=} ctx.session
+ * @param {String=} ctx.session.authenticatedIdentifier
+ * @param {String=} ctx.session.authenticatedProfile
+ * @param {String[]=} ctx.errors
+ * @param {String[]=} ctx.notifications
+ * @param {Object} options
+ * @param {String=} options.pageTitle
+ * @param {String=} options.logoUrl
+ * @param {Object[]=} options.bodyAttributes
+ * @param {String[]=} options.headElements
+ * @param {Object[]=} options.navLinks
+ * @param {String[]=} options.footerEntries
+ * @param {String=} options.errorHeading
+ * @param {String=} options.errorContent
+ * @param {String=} options.notificationHeading
+ * @param {String=} options.notificationContent
+ * @param {String[]} main - contents
+ * @returns {String}
+ */
+function htmlPage(pagePathLevel, ctx, options, main = []) {
+ const user = (ctx && ctx.session && ctx.session.authenticatedProfile) || (ctx && ctx.session && ctx.session.authenticatedIdentifier);
+ if (user) {
+ if (!options.navLinks) {
+ options.navLinks = [];
+ }
+ const logoutRedirect = ctx && ctx.url ? `?r=${encodeURIComponent(ctx.url)}` : '';
+ const logoutPath = (pagePathLevel > 0) ? `${'../'.repeat(pagePathLevel - 1)}` : 'admin/';
+ options.navLinks.push({
+ text: `Logout (${user})`,
+ href: `${logoutPath}logout${logoutRedirect}`,
+ });
+ }
+
+ return [
+ '<!DOCTYPE html>',
+ '<html lang="en">',
+ htmlHead(pagePathLevel, ctx, options),
+ htmlBody(pagePathLevel, ctx, options, main),
+ '</html>',
+ ].join('\n');
+}
+
+
+module.exports = {
+ dateOrNot,
+ secondsToPeriod,
+ htmlHead,
+ htmlBody,
+ htmlNav,
+ htmlHeader,
+ htmlFooter,
+ htmlMessages,
+ renderNavLink,
+ LI,
+ UL,
+ OL,
+ htmlPage,
+};