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