initial commit
[squeep-html-template-helper] / lib / template-helper.js
diff --git a/lib/template-helper.js b/lib/template-helper.js
new file mode 100644 (file)
index 0000000..00937a0
--- /dev/null
@@ -0,0 +1,328 @@
+'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,
+};