remove hard-coded navLinks entirely, let other modules populate them
[squeep-html-template-helper] / lib / template-helper.js
1 'use strict';
2
3 /**
4 * A bunch of shorthand to put together common parts of an HTML page.
5 */
6
7 const { lazy } = require('@squeep/lazy-property');
8
9
10 /**
11 * Set up expected fields for how we handle error reporting
12 * and whatnot.
13 * @param {Object} ctx
14 */
15 const initContext = (ctx) => {
16 ctx.errors = [];
17 ctx.notifications = [];
18 };
19
20 /**
21 * Some fields may have values outside normal dates, handle them here.
22 * @param {Date} date
23 * @param {String} otherwise
24 */
25 const dateOrNot = (date, otherwise) => {
26 if (!date) {
27 return otherwise;
28 }
29 if (typeof date === 'number') {
30 date = new Date(date);
31 }
32 const dateMs = date.getTime();
33 if (!Number.isFinite(dateMs)
34 || dateMs == 0) {
35 return otherwise;
36 }
37 return date.toString();
38 };
39
40
41 /**
42 * Why is rendering a Date as a string this complicated?
43 * We handle the infinities because pg-promise might provide those in
44 * lieu of a Date object from timestamp fields.
45 * @param {Date|Number|String} date
46 * @param {String=} pInf
47 * @param {String=} nInf
48 * @param {String=} otherwise
49 */
50 const dateFormat = (date, pInf = 'Never', nInf = 'Forever', otherwise = '') => {
51 const isDatableType = ['number', 'string'].includes(typeof date);
52 switch (date) {
53 case Infinity:
54 return pInf;
55 case -Infinity:
56 return nInf;
57 default:
58 if (!date
59 || Number.isNaN(date.valueOf())
60 || (!(date instanceof Date) && !isDatableType)) {
61 return otherwise;
62 }
63 }
64 if (isDatableType) {
65 date = new Date(date);
66 }
67 const parts = dateFormat._dtf.formatToParts(date);
68 return parts.map((p) => p.value).join('');
69 };
70 lazy(dateFormat, '_dtf', () => {
71 const dateTimeFormatOptions = {
72 dateStyle: 'medium',
73 timeStyle: 'long',
74 };
75 return new Intl.DateTimeFormat(undefined, dateTimeFormatOptions);
76 });
77
78
79 /**
80 * Wrap a Date in a <time> block.
81 * @param {Date} date
82 * @param {Object} options
83 * @param {String=} options.title
84 */
85 const timeElement = (date, options = {}) => {
86 const {
87 title,
88 pInf,
89 nInf,
90 otherwise,
91 } = options;
92 const attributes = {
93 ...(title && { title }),
94 ...(date instanceof Date && { datetime: date.toISOString() }),
95 };
96 return [
97 '<time',
98 elementAttributes(attributes),
99 '>',
100 dateFormat(date, pInf, nInf, otherwise),
101 '</time>',
102 ].join('');
103 };
104
105
106 /**
107 * Render a duration.
108 * @param {Number} seconds
109 * @returns {String}
110 */
111 const secondsToPeriod = (seconds) => {
112 let value = seconds;
113 const result = [];
114
115 const nextResult = (factor, label) => {
116 const r = factor ? value % factor : value;
117 if (r) {
118 result.push(`${r} ${label}${r != 1 ? 's' : ''}`);
119 }
120 value = factor ? Math.floor(value / factor) : value;
121 };
122
123 nextResult(60, 'second');
124 nextResult(60, 'minute');
125 nextResult(24, 'hour');
126 nextResult(30, 'day');
127 nextResult(undefined, 'month');
128
129 result.reverse();
130 return result.join(' ');
131 };
132
133
134 /**
135 * Return array of strings prefixed with tabs.
136 * @param {Number} indent
137 * @param {String[]} list
138 */
139 const indented = (indent, list) => {
140 const spacer = '\t'.repeat(indent);
141 return list.map((l) => `${spacer}${l}`);
142 };
143
144
145 /**
146 * Render the preamble <head> for an HTML page.
147 * @param {Number} pagePathLevel number of paths below root this page is
148 * @param {Object} ctx
149 * @param {Object} options
150 * @param {String[]=} options.headElements
151 * @param {String=} options.pageTitle
152 * @returns {String}
153 */
154 function htmlHead(pagePathLevel, ctx, options) {
155 const rootPathPfx = '../'.repeat(pagePathLevel);
156 const {
157 headElements = [],
158 pageTitle = '',
159 } = options;
160 return `\t<head>
161 \t\t<meta charset="utf-8">
162 \t\t<meta name="viewport" content="width=device-width,initial-scale=1">
163 \t\t<link rel="stylesheet" href="${rootPathPfx}static/theme.css">
164 \t\t<link rel="stylesheet" href="${rootPathPfx}static/custom.css">
165 ${headElements.map((e) => '\t\t' + e).join('\n')}
166 \t\t<title>${pageTitle}</title>
167 \t</head>`;
168 }
169
170
171 /**
172 * Render the main content of an HTML page.
173 * @param {Number} pagePathLevel
174 * @param {Object} ctx
175 * @param {Object} options
176 * @param {Object} options.bodyAttributes
177 * @param {String[]} main
178 * @returns {String}
179 */
180 function htmlBody(pagePathLevel, ctx, options, main = []) {
181 const {
182 bodyAttributes = {},
183 } = options;
184 const firefoxFix = '\n<script>0</script>'; // This fixes a layout rendering flash on load in Firefox; do not know why this works, but it does.
185 return `
186 \t<body${elementAttributes(bodyAttributes)}>${firefoxFix}
187 ${htmlHeader(pagePathLevel, ctx, options)}
188 ${htmlMessages(ctx, options)}
189 \t\t<main>
190 ${main.join('\n')}
191 \t\t</main>
192 ${htmlFooter(ctx, options)}
193 \t</body>`;
194 }
195
196
197 /**
198 * Render a navigation link for the header section.
199 * @param {Object} nav
200 * @param {String} nav.href
201 * @param {String} nav.class
202 * @param {String} nav.text
203 * @returns {String}
204 */
205 function renderNavLink(nav) {
206 return `<a href="${nav.href}"${nav.class ? (' class="' + nav.class + '"') : ''}>${nav.text}</a>`;
207 }
208
209
210 /**
211 * Render the navigation section of the page header.
212 * @param {Object} ctx
213 * @param {Object} options
214 * @param {Object[]=} options.navLinks
215 * @returns {String}
216 */
217 function htmlNav(ctx, options) {
218 const indent = 3;
219 const spacer = '\t'.repeat(indent);
220 const {
221 navLinks = [],
222 } = options;
223 return navLinks.length ? `${spacer}<nav>
224 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
225 ${spacer}</nav>` : '';
226 }
227
228
229 /**
230 * Render the banner and navigation header.
231 * @param {Number} pagePathLevel
232 * @param {Object} ctx
233 * @param {Object} options
234 * @param {String[]=} options.logoUrl
235 * @param {String[]=} options.logoAlt
236 * @param {String[]=} options.pageTitle
237 * @returns {String}
238 */
239 function htmlHeader(pagePathLevel, ctx, options) {
240 const rootPathPfx = '../'.repeat(pagePathLevel);
241 const {
242 logoUrl = '',
243 logoAlt = 'logo',
244 pageTitle = '',
245 } = options;
246 const logoImg = logoUrl ? `<img src="${rootPathPfx}${logoUrl}" alt="logo" class="${logoAlt}">` : '';
247 return `\t\t<header>
248 \t\t\t<h1>${logoImg}${pageTitle}</h1>
249 ${htmlNav(ctx, options)}
250 \t\t</header>`;
251 }
252
253
254 /**
255 * Render the bottom boilerplate.
256 * @param {Object} ctx
257 * @param {Object} options
258 * @param {String[]} options.footerEntries
259 * @returns {String}
260 */
261 function htmlFooter(ctx, options) {
262 const indent = 2;
263 const spacer = '\t'.repeat(indent);
264 const {
265 footerEntries = [],
266 } = options;
267
268 return footerEntries.length ? `${spacer}<footer>
269 ${OL(footerEntries, indent + 1)}
270 ${spacer}</footer>` : '';
271 }
272
273
274 /**
275 * Convert an object into element attributes.
276 * @param {Object} attributes
277 * @returns {String}
278 */
279 function elementAttributes(attributes) {
280 const attr = Object.entries(attributes).map(([name, value]) => `${name}="${value}"`).join(' ');
281 return attr ? ' ' + attr : '';
282 }
283
284
285 /**
286 * Wrap an item in a list item element.
287 * @param {String} item
288 * @param {Number} indent
289 * @param {Object} attributes
290 * @returns {String}
291 */
292 function LI(item, indent = 0, attributes = {}) {
293 const spacer = '\t'.repeat(indent);
294 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
295 }
296
297
298 /**
299 * Wrap a list of items in an unordered list.
300 * @param {String[]} items
301 * @param {Number} indent
302 * @param {Object} attributes
303 * @param {(item) => Object} itemAttributeGenerator
304 * @returns {String}
305 */
306 function UL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
307 const spacer = '\t'.repeat(indent);
308 return `${spacer}<ul${elementAttributes(attributes)}>
309 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
310 ${spacer}</ul>`;
311 }
312
313
314 /**
315 * Wrap a list of items in an ordered list.
316 * @param {String[]} items
317 * @param {Number} indent
318 * @param {Object} attributes
319 * @param {(item) => Object} itemAttributeGenerator
320 * @returns {String}
321 */
322
323 function OL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
324 const spacer = '\t'.repeat(indent);
325 return `${spacer}<ol${elementAttributes(attributes)}>
326 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
327 ${spacer}</ol>`;
328 }
329
330
331 /**
332 * Show any error or notice messages from context.
333 * @param {Object} ctx
334 * @param {String[]=} ctx.errors
335 * @param {String[]=} ctx.notifications
336 * @param {Object} options
337 * @param {String=} options.errorHeading
338 * @param {String[]=} options.errorContent
339 * @param {String=} options.notificationHeading
340 * @param {String[]=} options.notificationContent
341 */
342 function htmlMessages(ctx, options) {
343 const errorHeading = options?.errorHeading ? `
344 \t<h2>${options.errorHeading}</h2>` : '';
345 const errorContent = options?.errorContent?.length ? '\n' + options.errorContent.map(((content) => `\t${content}`)).join('\n') : '';
346 const notificationHeading = options?.notificationHeading ? `\n\t<h2>${options.notificationHeading}</h2>` : '';
347 const notificationContent = options?.notificationContent?.length ? '\n' + options.notificationContent.map(((content) => `\t${content}`)).join('\n') : '';
348 const errors = ctx?.errors?.length ? `
349 <section class="error">${errorHeading}${errorContent}
350 ${UL(ctx.errors, 1)}
351 </section>` : '';
352 const notifications = ctx?.notifications?.length ? `
353 <section class="notification">${notificationHeading}${notificationContent}
354 ${UL(ctx.notifications, 1)}
355 </section>` : '';
356 return [errors, notifications].join('\n');
357 }
358
359
360 /**
361 * Render all parts of an HTML page.
362 * @param {Number} pagePathLevel - relative path-distance to base
363 * @param {Object} ctx
364 * @param {String[]=} ctx.errors
365 * @param {String[]=} ctx.notifications
366 * @param {Object} options
367 * @param {String=} options.pageTitle
368 * @param {String=} options.logoUrl
369 * @param {String=} options.logoAlt
370 * @param {Object[]=} options.bodyAttributes
371 * @param {String[]=} options.headElements
372 * @param {Object[]=} options.navLinks
373 * @param {String[]=} options.footerEntries
374 * @param {String=} options.errorHeading
375 * @param {String[]=} options.errorContent
376 * @param {String=} options.notificationHeading
377 * @param {String[]=} options.notificationContent
378 * @param {String[]} main - contents
379 * @returns {String}
380 */
381 function htmlPage(pagePathLevel, ctx, options, main = []) {
382 return [
383 '<!DOCTYPE html>',
384 '<html lang="en">',
385 htmlHead(pagePathLevel, ctx, options),
386 htmlBody(pagePathLevel, ctx, options, main),
387 '</html>',
388 ].join('\n');
389 }
390
391
392 module.exports = {
393 initContext,
394 dateOrNot,
395 dateFormat,
396 timeElement,
397 secondsToPeriod,
398 htmlHead,
399 htmlBody,
400 htmlNav,
401 htmlHeader,
402 htmlFooter,
403 htmlMessages,
404 indented,
405 renderNavLink,
406 LI,
407 UL,
408 OL,
409 htmlPage,
410 };