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