fix custom css loading
[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.pageTitle
236 * @returns {String}
237 */
238 function htmlHeader(pagePathLevel, ctx, options) {
239 const rootPathPfx = '../'.repeat(pagePathLevel);
240 const {
241 logoUrl = '',
242 pageTitle = '',
243 } = options;
244 const logoImg = logoUrl ? `<img src="${rootPathPfx}${logoUrl}" class="logo">` : '';
245 return `\t\t<header>
246 \t\t\t<h1>${logoImg}${pageTitle}</h1>
247 ${htmlNav(ctx, options)}
248 \t\t</header>`;
249 }
250
251
252 /**
253 * Render the bottom boilerplate.
254 * @param {Object} ctx
255 * @param {Object} options
256 * @param {String[]} options.footerEntries
257 * @returns {String}
258 */
259 function htmlFooter(ctx, options) {
260 const indent = 2;
261 const spacer = '\t'.repeat(indent);
262 const {
263 footerEntries = [],
264 } = options;
265
266 return footerEntries.length ? `${spacer}<footer>
267 ${OL(footerEntries, indent + 1)}
268 ${spacer}</footer>` : '';
269 }
270
271
272 /**
273 * Convert an object into element attributes.
274 * @param {Object} attributes
275 * @returns {String}
276 */
277 function elementAttributes(attributes) {
278 const attr = Object.entries(attributes).map(([name, value]) => `${name}="${value}"`).join(' ');
279 return attr ? ' ' + attr : '';
280 }
281
282
283 /**
284 * Wrap an item in a list item element.
285 * @param {String} item
286 * @param {Number} indent
287 * @param {Object} attributes
288 * @returns {String}
289 */
290 function LI(item, indent = 0, attributes = {}) {
291 const spacer = '\t'.repeat(indent);
292 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
293 }
294
295
296 /**
297 * Wrap a list of items in an unordered list.
298 * @param {String[]} items
299 * @param {Number} indent
300 * @param {Object} attributes
301 * @param {(item) => Object} itemAttributeGenerator
302 * @returns {String}
303 */
304 function UL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
305 const spacer = '\t'.repeat(indent);
306 return `${spacer}<ul${elementAttributes(attributes)}>
307 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
308 ${spacer}</ul>`;
309 }
310
311
312 /**
313 * Wrap a list of items in an ordered list.
314 * @param {String[]} items
315 * @param {Number} indent
316 * @param {Object} attributes
317 * @param {(item) => Object} itemAttributeGenerator
318 * @returns {String}
319 */
320
321 function OL(items, indent = 0, attributes = {}, itemAttributeGenerator = () => {}) {
322 const spacer = '\t'.repeat(indent);
323 return `${spacer}<ol${elementAttributes(attributes)}>
324 ${items.map((item) => LI(item, indent + 1, itemAttributeGenerator(item))).join('\n')}
325 ${spacer}</ol>`;
326 }
327
328
329 /**
330 * Show any error or notice messages from context.
331 * @param {Object} ctx
332 * @param {String[]=} ctx.errors
333 * @param {String[]=} ctx.notifications
334 * @param {Object} options
335 * @param {String=} options.errorHeading
336 * @param {String[]=} options.errorContent
337 * @param {String=} options.notificationHeading
338 * @param {String[]=} options.notificationContent
339 */
340 function htmlMessages(ctx, options) {
341 const errorHeading = options && options.errorHeading ? `
342 \t<h2>${options.errorHeading}</h2>` : '';
343 const errorContent = options && options.errorContent && options.errorContent.length ? '\n' + options.errorContent.map(((content) => `\t${content}`)).join('\n') : '';
344 const notificationHeading = options && options.notificationHeading ? `\n\t<h2>${options.notificationHeading}</h2>` : '';
345 const notificationContent = options && options.notificationContent && options.notificationContent.length ? '\n' + options.notificationContent.map(((content) => `\t${content}`)).join('\n') : '';
346 const errors = ctx && ctx.errors && ctx.errors.length ? `
347 <section class="error">${errorHeading}${errorContent}
348 ${UL(ctx.errors, 1)}
349 </section>` : '';
350 const notifications = ctx && ctx.notifications && ctx.notifications.length ? `
351 <section class="notification">${notificationHeading}${notificationContent}
352 ${UL(ctx.notifications, 1)}
353 </section>` : '';
354 return [errors, notifications].join('\n');
355 }
356
357
358 /**
359 * Render all parts of an HTML page. Adds user logout nav link automatically.
360 * @param {Number} pagePathLevel - relative path-distance to base
361 * @param {Object} ctx
362 * @param {Object=} ctx.session
363 * @param {String=} ctx.session.authenticatedIdentifier
364 * @param {String=} ctx.session.authenticatedProfile
365 * @param {String[]=} ctx.errors
366 * @param {String[]=} ctx.notifications
367 * @param {Object} options
368 * @param {String=} options.pageTitle
369 * @param {String=} options.logoUrl
370 * @param {Object[]=} options.bodyAttributes
371 * @param {String[]=} options.headElements
372 * @param {Object[]=} options.navLinks
373 * @param {String[]=} options.footerEntries
374 * @param {String=} options.errorHeading
375 * @param {String[]=} options.errorContent
376 * @param {String=} options.notificationHeading
377 * @param {String[]=} options.notificationContent
378 * @param {String[]} main - contents
379 * @returns {String}
380 */
381 function htmlPage(pagePathLevel, ctx, options, main = []) {
382 const user = (ctx && ctx.session && ctx.session.authenticatedProfile) || (ctx && ctx.session && ctx.session.authenticatedIdentifier);
383 if (user) {
384 if (!options.navLinks) {
385 options.navLinks = [];
386 }
387 const logoutRedirect = ctx && ctx.url ? `?r=${encodeURIComponent(ctx.url)}` : '';
388 const logoutPath = (pagePathLevel > 0) ? `${'../'.repeat(pagePathLevel - 1)}` : 'admin/';
389 options.navLinks.push({
390 text: `Logout (${user})`,
391 href: `${logoutPath}logout${logoutRedirect}`,
392 });
393 }
394
395 return [
396 '<!DOCTYPE html>',
397 '<html lang="en">',
398 htmlHead(pagePathLevel, ctx, options),
399 htmlBody(pagePathLevel, ctx, options, main),
400 '</html>',
401 ].join('\n');
402 }
403
404
405 module.exports = {
406 initContext,
407 dateOrNot,
408 dateFormat,
409 timeElement,
410 secondsToPeriod,
411 htmlHead,
412 htmlBody,
413 htmlNav,
414 htmlHeader,
415 htmlFooter,
416 htmlMessages,
417 indented,
418 renderNavLink,
419 LI,
420 UL,
421 OL,
422 htmlPage,
423 };