fix naming of dependency
[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 * Render a duration.
70 * @param {Number} seconds
71 * @returns {String}
72 */
73 const secondsToPeriod = (seconds) => {
74 let value = seconds;
75 const result = [];
76
77 const nextResult = (factor, label) => {
78 const r = factor ? value % factor : value;
79 if (r) {
80 result.push(`${r} ${label}${r != 1 ? 's' : ''}`);
81 }
82 value = factor ? Math.floor(value / factor) : value;
83 }
84
85 nextResult(60, 'second');
86 nextResult(60, 'minute');
87 nextResult(24, 'hour');
88 nextResult(30, 'day');
89 nextResult(undefined, 'month');
90
91 result.reverse();
92 return result.join(' ');
93 };
94
95
96 /**
97 * Render the preamble <head> for an HTML page.
98 * @param {Number} pagePathLevel number of paths below root this page is
99 * @param {Object} ctx
100 * @param {Object} options
101 * @param {String[]=} options.headElements
102 * @param {String=} options.pageTitle
103 * @returns {String}
104 */
105 function htmlHead(pagePathLevel, ctx, options) {
106 const rootPathPfx = '../'.repeat(pagePathLevel);
107 const {
108 headElements = [],
109 pageTitle = '',
110 } = options;
111 return `\t<head>
112 \t\t<meta charset="utf-8">
113 \t\t<meta name="viewport" content="width=device-width,initial-scale=1">
114 \t\t<link rel="stylesheet" href="${rootPathPfx}static/theme.css" title="Default">
115 \t\t<link rel="stylesheet" href="${rootPathPfx}static/custom.css" title="Site Specific">
116 ${headElements.map((e) => '\t\t' + e).join('\n')}
117 \t\t<title>${pageTitle}</title>
118 \t</head>`;
119 }
120
121
122 /**
123 * Render the main content of an HTML page.
124 * @param {Number} pagePathLevel
125 * @param {Object} ctx
126 * @param {Object} options
127 * @param {Object} options.bodyAttributes
128 * @param {String[]} main
129 * @returns {String}
130 */
131 function htmlBody(pagePathLevel, ctx, options, main = []) {
132 const {
133 bodyAttributes = {},
134 } = options;
135 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.
136 return `
137 \t<body${elementAttributes(bodyAttributes)}>${firefoxFix}
138 ${htmlHeader(pagePathLevel, ctx, options)}
139 ${htmlMessages(ctx, options)}
140 \t\t<main>
141 ${main.join('\n')}
142 \t\t</main>
143 ${htmlFooter(ctx, options)}
144 \t</body>`;
145 }
146
147
148 /**
149 * Render a navigation link for the header section.
150 * @param {Object} nav
151 * @param {String} nav.href
152 * @param {String} nav.class
153 * @param {String} nav.text
154 * @returns {String}
155 */
156 function renderNavLink(nav) {
157 return `<a href="${nav.href}"${nav.class ? (' class="' + nav.class + '"') : ''}>${nav.text}</a>`;
158 }
159
160
161 /**
162 * Render the navigation section of the page header.
163 * @param {Object} ctx
164 * @param {Object} options
165 * @param {Object[]=} options.navLinks
166 * @returns {String}
167 */
168 function htmlNav(ctx, options) {
169 const indent = 3;
170 const spacer = '\t'.repeat(indent);
171 const {
172 navLinks = [],
173 } = options;
174 return navLinks.length ? `${spacer}<nav>
175 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
176 ${spacer}</nav>` : '';
177 }
178
179
180 /**
181 * Render the banner and navigation header.
182 * @param {Number} pagePathLevel
183 * @param {Object} ctx
184 * @param {Object} options
185 * @param {String[]=} options.logoUrl
186 * @param {String[]=} options.pageTitle
187 * @returns {String}
188 */
189 function htmlHeader(pagePathLevel, ctx, options) {
190 const rootPathPfx = '../'.repeat(pagePathLevel);
191 const {
192 logoUrl = '',
193 pageTitle = '',
194 } = options;
195 const logoImg = logoUrl ? `<img src="${rootPathPfx}${logoUrl}" class="logo">` : '';
196 return `\t\t<header>
197 \t\t\t<h1>${logoImg}${pageTitle}</h1>
198 ${htmlNav(ctx, options)}
199 \t\t</header>`;
200 }
201
202
203 /**
204 * Render the bottom boilerplate.
205 * @param {Object} ctx
206 * @param {Object} options
207 * @param {String[]} options.footerEntries
208 * @returns {String}
209 */
210 function htmlFooter(ctx, options) {
211 const indent = 2;
212 const spacer = '\t'.repeat(indent);
213 const {
214 footerEntries = [],
215 } = options;
216
217 return footerEntries.length ? `${spacer}<footer>
218 ${OL(footerEntries, indent + 1)}
219 ${spacer}</footer>` : '';
220 }
221
222
223 /**
224 * Convert an object into element attributes.
225 * @param {Object} attributes
226 * @returns {String}
227 */
228 function elementAttributes(attributes) {
229 const attr = Object.entries(attributes).map(([name, value]) => `${name}="${value}"`).join(' ');
230 return attr ? ' ' + attr : '';
231 }
232
233
234 /**
235 * Wrap an item in a list item element.
236 * @param {String} item
237 * @param {Number} indent
238 * @param {Object} attributes
239 * @returns {String}
240 */
241 function LI(item, indent = 0, attributes = {}) {
242 const spacer = '\t'.repeat(indent);
243 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
244 }
245
246
247 /**
248 * Wrap a list of items in an unordered list.
249 * @param {String[]} items
250 * @param {Number} indent
251 * @param {Object} attributes
252 * @param {(item) => Object} itemAttributeGenerator
253 * @returns {String}
254 */
255 function UL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
256 const spacer = '\t'.repeat(indent);
257 return `${spacer}<ul${elementAttributes(attributes)}>
258 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
259 ${spacer}</ul>`;
260 }
261
262
263 /**
264 * Wrap a list of items in an ordered list.
265 * @param {String[]} items
266 * @param {Number} indent
267 * @param {Object} attributes
268 * @param {(item) => Object} itemAttributeGenerator
269 * @returns {String}
270 */
271
272 function OL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
273 const spacer = '\t'.repeat(indent);
274 return `${spacer}<ol${elementAttributes(attributes)}>
275 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
276 ${spacer}</ol>`;
277 }
278
279
280 /**
281 * Show any error or notice messages from context.
282 * @param {Object} ctx
283 * @param {String[]=} ctx.errors
284 * @param {String[]=} ctx.notifications
285 * @param {Object} options
286 * @param {String=} options.errorHeading
287 * @param {String[]=} options.errorContent
288 * @param {String=} options.notificationHeading
289 * @param {String[]=} options.notificationContent
290 */
291 function htmlMessages(ctx, options) {
292 const errorHeading = options && options.errorHeading ? `
293 \t<h2>${options.errorHeading}</h2>` : '';
294 const errorContent = options && options.errorContent && options.errorContent.length ? '\n' + options.errorContent.map(((content) => `\t${content}`)).join('\n') : '';
295 const notificationHeading = options && options.notificationHeading ? `\n\t<h2>${options.notificationHeading}</h2>` : '';
296 const notificationContent = options && options.notificationContent && options.notificationContent.length ? '\n' + options.notificationContent.map(((content) => `\t${content}`)).join('\n') : '';
297 const errors = ctx && ctx.errors && ctx.errors.length ? `
298 <section class="error">${errorHeading}${errorContent}
299 ${UL(ctx.errors, 1)}
300 </section>` : '';
301 const notifications = ctx && ctx.notifications && ctx.notifications.length ? `
302 <section class="notification">${notificationHeading}${notificationContent}
303 ${UL(ctx.notifications, 1)}
304 </section>` : '';
305 return [errors, notifications].join('\n');
306 }
307
308
309 /**
310 * Render all parts of an HTML page. Adds user logout nav link automatically.
311 * @param {Number} pagePathLevel - relative path-distance to base
312 * @param {Object} ctx
313 * @param {Object=} ctx.session
314 * @param {String=} ctx.session.authenticatedIdentifier
315 * @param {String=} ctx.session.authenticatedProfile
316 * @param {String[]=} ctx.errors
317 * @param {String[]=} ctx.notifications
318 * @param {Object} options
319 * @param {String=} options.pageTitle
320 * @param {String=} options.logoUrl
321 * @param {Object[]=} options.bodyAttributes
322 * @param {String[]=} options.headElements
323 * @param {Object[]=} options.navLinks
324 * @param {String[]=} options.footerEntries
325 * @param {String=} options.errorHeading
326 * @param {String[]=} options.errorContent
327 * @param {String=} options.notificationHeading
328 * @param {String[]=} options.notificationContent
329 * @param {String[]} main - contents
330 * @returns {String}
331 */
332 function htmlPage(pagePathLevel, ctx, options, main = []) {
333 const user = (ctx && ctx.session && ctx.session.authenticatedProfile) || (ctx && ctx.session && ctx.session.authenticatedIdentifier);
334 if (user) {
335 if (!options.navLinks) {
336 options.navLinks = [];
337 }
338 const logoutRedirect = ctx && ctx.url ? `?r=${encodeURIComponent(ctx.url)}` : '';
339 const logoutPath = (pagePathLevel > 0) ? `${'../'.repeat(pagePathLevel - 1)}` : 'admin/';
340 options.navLinks.push({
341 text: `Logout (${user})`,
342 href: `${logoutPath}logout${logoutRedirect}`,
343 });
344 }
345
346 return [
347 '<!DOCTYPE html>',
348 '<html lang="en">',
349 htmlHead(pagePathLevel, ctx, options),
350 htmlBody(pagePathLevel, ctx, options, main),
351 '</html>',
352 ].join('\n');
353 }
354
355
356 module.exports = {
357 dateOrNot,
358 dateFormat,
359 secondsToPeriod,
360 htmlHead,
361 htmlBody,
362 htmlNav,
363 htmlHeader,
364 htmlFooter,
365 htmlMessages,
366 renderNavLink,
367 LI,
368 UL,
369 OL,
370 htmlPage,
371 };