bump package version to 1.6.1
[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 context
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|number} date potential Date or epoch milliseconds
23 * @param {string} otherwise returned if date is not a date
24 * @returns {string} date or otherwise
25 */
26 const dateOrNot = (date, otherwise) => {
27 if (!date) {
28 return otherwise;
29 }
30 if (typeof date === 'number') {
31 date = new Date(date);
32 }
33 const dateMs = date.getTime();
34 if (!Number.isFinite(dateMs)
35 || dateMs == 0) {
36 return otherwise;
37 }
38 return date.toString();
39 };
40
41
42 /**
43 * Why is rendering a Date as a string this complicated?
44 * We handle the infinities because pg-promise might provide those in
45 * lieu of a Date object from timestamp fields.
46 * @param {Date|number|string} date Date, epoch milliseconds, or date string
47 * @param {string=} pInf returned if date is +Infinity
48 * @param {string=} nInf returned if date is -Infinity
49 * @param {string=} otherwise returned if date is something else
50 * @returns {string} rendered date
51 */
52 const dateFormat = (date, pInf = 'Never', nInf = 'Forever', otherwise = '') => {
53 const isDatableType = ['number', 'string'].includes(typeof date);
54 switch (date) {
55 case Infinity:
56 return pInf;
57 case -Infinity:
58 return nInf;
59 default:
60 if (!date
61 || Number.isNaN(date.valueOf())
62 || (!(date instanceof Date) && !isDatableType)) {
63 return otherwise;
64 }
65 }
66 if (isDatableType) {
67 date = new Date(date);
68 }
69 const parts = dateFormat._dtf.formatToParts(date);
70 return parts.map((p) => p.value).join('');
71 };
72 lazy(dateFormat, '_dtf', () => {
73 const dateTimeFormatOptions = {
74 dateStyle: 'medium',
75 timeStyle: 'long',
76 };
77 return new Intl.DateTimeFormat(undefined, dateTimeFormatOptions);
78 });
79
80
81 /**
82 * Wrap a Date in a <time> element block.
83 * @param {Date} date date
84 * @param {object} options options
85 * @param {string=} options.title title attr for time element
86 * @returns {string} time element
87 */
88 const timeElement = (date, options = {}) => {
89 const {
90 title,
91 pInf,
92 nInf,
93 otherwise,
94 } = options;
95 const attributes = {
96 ...(title && { title }),
97 ...(date instanceof Date && { datetime: date.toISOString() }),
98 };
99 return [
100 '<time',
101 elementAttributes(attributes),
102 '>',
103 dateFormat(date, pInf, nInf, otherwise),
104 '</time>',
105 ].join('');
106 };
107
108
109 /**
110 * Render a duration.
111 * @param {number} seconds duration
112 * @returns {string} readable duration
113 */
114 const secondsToPeriod = (seconds) => {
115 let value = seconds;
116 const result = [];
117
118 const nextResult = (factor, label) => {
119 const r = factor ? value % factor : value;
120 if (r) {
121 result.push(`${r} ${label}${r != 1 ? 's' : ''}`);
122 }
123 value = factor ? Math.floor(value / factor) : value;
124 };
125
126 nextResult(60, 'second');
127 nextResult(60, 'minute');
128 nextResult(24, 'hour');
129 nextResult(30, 'day');
130 nextResult(undefined, 'month');
131
132 result.reverse();
133 return result.join(' ');
134 };
135
136
137 /**
138 * Return array of strings prefixed with tabs.
139 * @param {number} indent depth
140 * @param {string[]} list items to indent by depth
141 * @returns {string[]} indented items
142 */
143 const indented = (indent, list) => {
144 const spacer = '\t'.repeat(indent);
145 return list.map((l) => `${spacer}${l}`);
146 };
147
148
149 /**
150 * Render the preamble <head> for an HTML page.
151 * @param {number} pagePathLevel number of paths below root this page is
152 * @param {object} ctx context
153 * @param {object} options options
154 * @param {string[]=} options.headElements element strings to add to html head element
155 * @param {string=} options.pageTitle title of page
156 * @returns {string} populated head element
157 */
158 function htmlHead(pagePathLevel, ctx, options) {
159 const rootPathPfx = '../'.repeat(pagePathLevel);
160 const {
161 headElements = [],
162 pageTitle = '',
163 } = options;
164 return `\t<head>
165 \t\t<meta charset="utf-8">
166 \t\t<meta name="viewport" content="width=device-width,initial-scale=1">
167 \t\t<link rel="stylesheet" href="${rootPathPfx}static/theme.css">
168 \t\t<link rel="stylesheet" href="${rootPathPfx}static/custom.css">
169 ${headElements.map((e) => '\t\t' + e).join('\n')}
170 \t\t<title>${pageTitle}</title>
171 \t</head>`;
172 }
173
174
175 /**
176 * Render the main content of an HTML page.
177 * @param {number} pagePathLevel number of subdirs under root this page is
178 * @param {object} ctx context
179 * @param {object} options options
180 * @param {object} options.bodyAttributes attributes to set on body element
181 * @param {string[]} main main content strings
182 * @returns {string} body element
183 */
184 function htmlBody(pagePathLevel, ctx, options, main = []) {
185 const {
186 bodyAttributes = {},
187 } = options;
188 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.
189 return `
190 \t<body${elementAttributes(bodyAttributes)}>${firefoxFix}
191 ${htmlHeader(pagePathLevel, ctx, options)}
192 ${htmlMessages(ctx, options)}
193 \t\t<main>
194 ${main.join('\n')}
195 \t\t</main>
196 ${htmlFooter(ctx, options)}
197 \t</body>`;
198 }
199
200
201 /**
202 * Render a navigation link for the header section.
203 * @param {object} nav navigation object
204 * @param {string} nav.href href of link
205 * @param {string} nav.class class for anchor element
206 * @param {string} nav.text text of link
207 * @returns {string} a element
208 */
209 function renderNavLink(nav) {
210 const aClass = nav.class ? ` class="${nav.class}"` : '';
211 return `<a href="${nav.href}"${aClass}>${nav.text}</a>`;
212 }
213
214
215 /**
216 * Render the navigation section of the page header.
217 * @param {object} ctx context
218 * @param {object} options options
219 * @param {object[]=} options.navLinks array of navlink objects
220 * @returns {string} nav element
221 */
222 function htmlNav(ctx, options) {
223 const indent = 3;
224 const spacer = '\t'.repeat(indent);
225 const {
226 navLinks = [],
227 } = options;
228 return navLinks.length ? `${spacer}<nav>
229 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
230 ${spacer}</nav>` : '';
231 }
232
233
234 /**
235 * Render the banner and navigation header.
236 * @param {number} pagePathLevel number of subdirs under root this page is
237 * @param {object} ctx context
238 * @param {object} options options
239 * @param {string[]=} options.logoUrl url to logo image
240 * @param {string[]=} options.logoAlt alt for logo image
241 * @param {string[]=} options.pageTitle page title
242 * @returns {string} header element
243 */
244 function htmlHeader(pagePathLevel, ctx, options) {
245 const rootPathPfx = '../'.repeat(pagePathLevel);
246 const {
247 logoUrl = '',
248 logoAlt = 'logo',
249 pageTitle = '',
250 } = options;
251 const logoImg = logoUrl ? `<img src="${rootPathPfx}${logoUrl}" alt="logo" class="${logoAlt}">` : '';
252 return `\t\t<header>
253 \t\t\t<h1>${logoImg}${pageTitle}</h1>
254 ${htmlNav(ctx, options)}
255 \t\t</header>`;
256 }
257
258
259 /**
260 * Render the bottom boilerplate.
261 * @param {object} ctx context
262 * @param {object} options options
263 * @param {string[]} options.footerEntries footer entries
264 * @returns {string} footer element
265 */
266 function htmlFooter(ctx, options) {
267 const indent = 2;
268 const spacer = '\t'.repeat(indent);
269 const {
270 footerEntries = [],
271 } = options;
272
273 return footerEntries.length ? `${spacer}<footer>
274 ${OL(footerEntries, indent + 1)}
275 ${spacer}</footer>` : '';
276 }
277
278
279 /**
280 * Convert an object into element attributes.
281 * @param {object} attributes attributes
282 * @returns {string} formatted attributes
283 */
284 function elementAttributes(attributes) {
285 const attr = Object.entries(attributes).map(([name, value]) => {
286 const v = value ? `="${value}"` : '';
287 return `${name}${v}`;
288 }).join(' ');
289 return attr ? ' ' + attr : '';
290 }
291
292
293 /**
294 * Wrap an item in a list item element.
295 * @param {string} item text of item
296 * @param {number} indent indent level
297 * @param {object} attributes attributes for li element
298 * @returns {string} li element
299 */
300 function LI(item, indent = 0, attributes = {}) {
301 const spacer = '\t'.repeat(indent);
302 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
303 }
304
305
306 /**
307 * @typedef {(item: any, index: number, array: any[]) => object} ItemAttributeGenerator
308 */
309
310 /**
311 * Wrap an array of items in a list container element.
312 * @param {string} element element type, e.g. OL, UL
313 * @param {number} indent indent level
314 * @param {object} attributes attributes for list element
315 * @param {string[]} items list entry elements
316 * @param {ItemAttributeGenerator} itemAttributeGenerator function which returns attributes for a given list entry element
317 * @returns {string} list type element
318 */
319 function listContainer(element, indent, attributes, items, itemAttributeGenerator) {
320 const spacer = '\t'.repeat(indent);
321 return `${spacer}<${element}${elementAttributes(attributes)}>
322 ${items.map((item, index, array) => LI(item, indent + 1, itemAttributeGenerator(item, index, array))).join('\n')}
323 ${spacer}</${element}>`;
324 }
325
326
327 // eslint-disable-next-line jsdoc/require-returns-check
328 /**
329 * @param {any} item item
330 * @param {number} index item index
331 * @param {Array<any>} array items
332 * @returns {object=} attribute map
333 */
334 function _defaultItemAttributeGenerator(item, index, array) { // eslint-disable-line no-unused-vars
335 return;
336 }
337
338
339 /**
340 * Wrap a list of items in an unordered list.
341 * @param {string[]} items list item elements
342 * @param {number} indent indent level
343 * @param {object} attributes ul element attributes
344 * @param {ItemAttributeGenerator} itemAttributeGenerator function which returns attributes for a given list entry element
345 * @returns {string} ul element
346 */
347 function UL(items, indent = 0, attributes = {}, itemAttributeGenerator = _defaultItemAttributeGenerator) {
348 return listContainer('ul', indent, attributes, items, itemAttributeGenerator);
349 }
350
351
352 /**
353 * Wrap a list of items in an ordered list.
354 * @param {string[]} items list item elements
355 * @param {number} indent indent level
356 * @param {object} attributes ol element attributes
357 * @param {ItemAttributeGenerator} itemAttributeGenerator function which returns attributes for a given liste entry element
358 * @returns {string} ol element
359 */
360 function OL(items, indent = 0, attributes = {}, itemAttributeGenerator = _defaultItemAttributeGenerator) {
361 return listContainer('ol', indent, attributes, items, itemAttributeGenerator);
362 }
363
364
365 /**
366 * Show any error or notice messages from context.
367 * @param {object} ctx context
368 * @param {string[]=} ctx.errors errors to render
369 * @param {string[]=} ctx.notifications notifications to render
370 * @param {object} options options
371 * @param {string=} options.errorHeading additional heading when rendering errors
372 * @param {string[]=} options.errorContent additional text when rendering errors
373 * @param {string=} options.notificationHeading additional heading when rendering notifications
374 * @param {string[]=} options.notificationContent additional text when rendering notifications
375 * @returns {string} h2 element and section element
376 */
377 function htmlMessages(ctx, options) {
378 const errorHeading = options?.errorHeading ? `
379 \t<h2>${options.errorHeading}</h2>` : '';
380 const errorContent = options?.errorContent?.length ? '\n' + options.errorContent.map(((content) => `\t${content}`)).join('\n') : '';
381 const notificationHeading = options?.notificationHeading ? `\n\t<h2>${options.notificationHeading}</h2>` : '';
382 const notificationContent = options?.notificationContent?.length ? '\n' + options.notificationContent.map(((content) => `\t${content}`)).join('\n') : '';
383 const errors = ctx?.errors?.length ? `
384 <section class="error">${errorHeading}${errorContent}
385 ${UL(ctx.errors, 1)}
386 </section>` : '';
387 const notifications = ctx?.notifications?.length ? `
388 <section class="notification">${notificationHeading}${notificationContent}
389 ${UL(ctx.notifications, 1)}
390 </section>` : '';
391 return [errors, notifications].join('\n');
392 }
393
394
395 /**
396 * Render all parts of an HTML page.
397 * @param {number} pagePathLevel relative path-distance to base
398 * @param {object} ctx context
399 * @param {string[]=} ctx.errors errors
400 * @param {string[]=} ctx.notifications notifications
401 * @param {object} options options
402 * @param {string=} options.pageTitle page title
403 * @param {string=} options.logoUrl logo url
404 * @param {string=} options.logoAlt logo alt
405 * @param {object[]=} options.bodyAttributes body attributes
406 * @param {string[]=} options.headElements head element elements
407 * @param {object[]=} options.navLinks nav objects
408 * @param {string[]=} options.footerEntries footers
409 * @param {string=} options.errorHeading errors heading
410 * @param {string[]=} options.errorContent errors text
411 * @param {string=} options.notificationHeading notifications heading
412 * @param {string[]=} options.notificationContent notifications text
413 * @param {string[]} main array of contents strings
414 * @returns {string} html page
415 */
416 function htmlPage(pagePathLevel, ctx, options, main = []) {
417 return [
418 '<!DOCTYPE html>',
419 '<html lang="en">',
420 htmlHead(pagePathLevel, ctx, options),
421 htmlBody(pagePathLevel, ctx, options, main),
422 '</html>',
423 ].join('\n');
424 }
425
426
427 module.exports = {
428 initContext,
429 dateOrNot,
430 dateFormat,
431 timeElement,
432 secondsToPeriod,
433 htmlHead,
434 htmlBody,
435 htmlNav,
436 htmlHeader,
437 htmlFooter,
438 htmlMessages,
439 indented,
440 renderNavLink,
441 LI,
442 listContainer,
443 UL,
444 OL,
445 htmlPage,
446 elementAttributes,
447 };