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
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
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 const aClass = nav.class ? ` class="${nav.class}"` : '';
207 return `<a href="${nav.href}"${aClass}>${nav.text}</a>`;
208 }
209
210
211 /**
212 * Render the navigation section of the page header.
213 * @param {Object} ctx
214 * @param {Object} options
215 * @param {Object[]=} options.navLinks
216 * @returns {String}
217 */
218 function htmlNav(ctx, options) {
219 const indent = 3;
220 const spacer = '\t'.repeat(indent);
221 const {
222 navLinks = [],
223 } = options;
224 return navLinks.length ? `${spacer}<nav>
225 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
226 ${spacer}</nav>` : '';
227 }
228
229
230 /**
231 * Render the banner and navigation header.
232 * @param {Number} pagePathLevel
233 * @param {Object} ctx
234 * @param {Object} options
235 * @param {String[]=} options.logoUrl
236 * @param {String[]=} options.logoAlt
237 * @param {String[]=} options.pageTitle
238 * @returns {String}
239 */
240 function htmlHeader(pagePathLevel, ctx, options) {
241 const rootPathPfx = '../'.repeat(pagePathLevel);
242 const {
243 logoUrl = '',
244 logoAlt = 'logo',
245 pageTitle = '',
246 } = options;
247 const logoImg = logoUrl ? `<img src="${rootPathPfx}${logoUrl}" alt="logo" class="${logoAlt}">` : '';
248 return `\t\t<header>
249 \t\t\t<h1>${logoImg}${pageTitle}</h1>
250 ${htmlNav(ctx, options)}
251 \t\t</header>`;
252 }
253
254
255 /**
256 * Render the bottom boilerplate.
257 * @param {Object} ctx
258 * @param {Object} options
259 * @param {String[]} options.footerEntries
260 * @returns {String}
261 */
262 function htmlFooter(ctx, options) {
263 const indent = 2;
264 const spacer = '\t'.repeat(indent);
265 const {
266 footerEntries = [],
267 } = options;
268
269 return footerEntries.length ? `${spacer}<footer>
270 ${OL(footerEntries, indent + 1)}
271 ${spacer}</footer>` : '';
272 }
273
274
275 /**
276 * Convert an object into element attributes.
277 * @param {Object} attributes
278 * @returns {String}
279 */
280 function elementAttributes(attributes) {
281 const attr = Object.entries(attributes).map(([name, value]) => {
282 const v = value ? `="${value}"` : '';
283 return `${name}${v}`;
284 }).join(' ');
285 return attr ? ' ' + attr : '';
286 }
287
288
289 /**
290 * Wrap an item in a list item element.
291 * @param {String} item
292 * @param {Number} indent
293 * @param {Object} attributes
294 * @returns {String}
295 */
296 function LI(item, indent = 0, attributes = {}) {
297 const spacer = '\t'.repeat(indent);
298 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
299 }
300
301
302 /**
303 * Wrap an array of items in a list container element.
304 * @param {String} element
305 * @param {Number} indent
306 * @param {Object} attributes
307 * @param {String[]} items
308 * @param {(item, index, array) => {Object}} itemAttributeGenerator
309 * @returns {String}
310 */
311 function listContainer(element, indent, attributes, items, itemAttributeGenerator) {
312 const spacer = '\t'.repeat(indent);
313 return `${spacer}<${element}${elementAttributes(attributes)}>
314 ${items.map((item, index, array) => LI(item, indent + 1, itemAttributeGenerator(item, index, array))).join('\n')}
315 ${spacer}</${element}>`;
316 }
317
318
319 /**
320 * Wrap a list of items in an unordered list.
321 * @param {String[]} items
322 * @param {Number} indent
323 * @param {Object} attributes
324 * @param {(item, index, array) => Object} itemAttributeGenerator
325 * @returns {String}
326 */
327 function UL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
328 return listContainer('ul', indent, attributes, items, itemAttributeGenerator);
329 }
330
331
332 /**
333 * Wrap a list of items in an ordered list.
334 * @param {String[]} items
335 * @param {Number} indent
336 * @param {Object} attributes
337 * @param {(item, index, array) => Object} itemAttributeGenerator
338 * @returns {String}
339 */
340
341 function OL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
342 return listContainer('ol', indent, attributes, items, itemAttributeGenerator);
343 }
344
345
346 /**
347 * Show any error or notice messages from context.
348 * @param {Object} ctx
349 * @param {String[]=} ctx.errors
350 * @param {String[]=} ctx.notifications
351 * @param {Object} options
352 * @param {String=} options.errorHeading
353 * @param {String[]=} options.errorContent
354 * @param {String=} options.notificationHeading
355 * @param {String[]=} options.notificationContent
356 */
357 function htmlMessages(ctx, options) {
358 const errorHeading = options?.errorHeading ? `
359 \t<h2>${options.errorHeading}</h2>` : '';
360 const errorContent = options?.errorContent?.length ? '\n' + options.errorContent.map(((content) => `\t${content}`)).join('\n') : '';
361 const notificationHeading = options?.notificationHeading ? `\n\t<h2>${options.notificationHeading}</h2>` : '';
362 const notificationContent = options?.notificationContent?.length ? '\n' + options.notificationContent.map(((content) => `\t${content}`)).join('\n') : '';
363 const errors = ctx?.errors?.length ? `
364 <section class="error">${errorHeading}${errorContent}
365 ${UL(ctx.errors, 1)}
366 </section>` : '';
367 const notifications = ctx?.notifications?.length ? `
368 <section class="notification">${notificationHeading}${notificationContent}
369 ${UL(ctx.notifications, 1)}
370 </section>` : '';
371 return [errors, notifications].join('\n');
372 }
373
374
375 /**
376 * Render all parts of an HTML page.
377 * @param {Number} pagePathLevel - relative path-distance to base
378 * @param {Object} ctx
379 * @param {String[]=} ctx.errors
380 * @param {String[]=} ctx.notifications
381 * @param {Object} options
382 * @param {String=} options.pageTitle
383 * @param {String=} options.logoUrl
384 * @param {String=} options.logoAlt
385 * @param {Object[]=} options.bodyAttributes
386 * @param {String[]=} options.headElements
387 * @param {Object[]=} options.navLinks
388 * @param {String[]=} options.footerEntries
389 * @param {String=} options.errorHeading
390 * @param {String[]=} options.errorContent
391 * @param {String=} options.notificationHeading
392 * @param {String[]=} options.notificationContent
393 * @param {String[]} main - contents
394 * @returns {String}
395 */
396 function htmlPage(pagePathLevel, ctx, options, main = []) {
397 return [
398 '<!DOCTYPE html>',
399 '<html lang="en">',
400 htmlHead(pagePathLevel, ctx, options),
401 htmlBody(pagePathLevel, ctx, options, main),
402 '</html>',
403 ].join('\n');
404 }
405
406
407 module.exports = {
408 initContext,
409 dateOrNot,
410 dateFormat,
411 timeElement,
412 secondsToPeriod,
413 htmlHead,
414 htmlBody,
415 htmlNav,
416 htmlHeader,
417 htmlFooter,
418 htmlMessages,
419 indented,
420 renderNavLink,
421 LI,
422 listContainer,
423 UL,
424 OL,
425 htmlPage,
426 elementAttributes,
427 };