e48cdb4fc5e59e17e5179b35a0339f357ba657be
[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. Adds user logout nav link automatically.
362 * @param {Number} pagePathLevel - relative path-distance to base
363 * @param {Object} ctx
364 * @param {Object=} ctx.session
365 * @param {String=} ctx.session.authenticatedIdentifier
366 * @param {String=} ctx.session.authenticatedProfile
367 * @param {String[]=} ctx.errors
368 * @param {String[]=} ctx.notifications
369 * @param {Object} options
370 * @param {String=} options.pageTitle
371 * @param {String=} options.logoUrl
372 * @param {String=} options.logoAlt
373 * @param {Object[]=} options.bodyAttributes
374 * @param {String[]=} options.headElements
375 * @param {Object[]=} options.navLinks
376 * @param {String[]=} options.footerEntries
377 * @param {String=} options.errorHeading
378 * @param {String[]=} options.errorContent
379 * @param {String=} options.notificationHeading
380 * @param {String[]=} options.notificationContent
381 * @param {String[]} main - contents
382 * @returns {String}
383 */
384 function htmlPage(pagePathLevel, ctx, options, main = []) {
385 const user = ctx?.session?.authenticatedProfile || ctx?.session?.authenticatedIdentifier;
386 if (user) {
387 if (!options.navLinks) {
388 options.navLinks = [];
389 }
390 const logoutRedirect = ctx?.url ? `?r=${encodeURIComponent(ctx.url)}` : '';
391 const adminPath = (pagePathLevel > 0) ? `${'../'.repeat(pagePathLevel - 1)}` : 'admin/';
392 options.navLinks.push({
393 text: 'Account',
394 href: `${adminPath}settings`,
395 }, {
396 text: `Logout (${user})`,
397 href: `${adminPath}logout${logoutRedirect}`,
398 });
399 }
400
401 return [
402 '<!DOCTYPE html>',
403 '<html lang="en">',
404 htmlHead(pagePathLevel, ctx, options),
405 htmlBody(pagePathLevel, ctx, options, main),
406 '</html>',
407 ].join('\n');
408 }
409
410
411 module.exports = {
412 initContext,
413 dateOrNot,
414 dateFormat,
415 timeElement,
416 secondsToPeriod,
417 htmlHead,
418 htmlBody,
419 htmlNav,
420 htmlHeader,
421 htmlFooter,
422 htmlMessages,
423 indented,
424 renderNavLink,
425 LI,
426 UL,
427 OL,
428 htmlPage,
429 };