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