4 * A bunch of shorthand to put together common parts of an HTML page.
7 const { lazy
} = require('@squeep/lazy-property');
11 * Set up expected fields for how we handle error reporting
13 * @param {object} ctx context
15 const initContext
= (ctx
) => {
17 ctx
.notifications
= [];
21 * Some fields may have values outside normal dates, handle them here.
22 * @param {Date|number} date potential Date or epoch milliseconds
23 * @param {string} otherwise returned if date is not a date
24 * @returns {string} date or otherwise
26 const dateOrNot
= (date
, otherwise
) => {
30 if (typeof date
=== 'number') {
31 date
= new Date(date
);
33 const dateMs
= date
.getTime();
34 if (!Number
.isFinite(dateMs
)
38 return date
.toString();
43 * Why is rendering a Date as a string this complicated?
44 * We handle the infinities because pg-promise might provide those in
45 * lieu of a Date object from timestamp fields.
46 * @param {Date|number|string} date Date, epoch milliseconds, or date string
47 * @param {string=} pInf returned if date is +Infinity
48 * @param {string=} nInf returned if date is -Infinity
49 * @param {string=} otherwise returned if date is something else
50 * @returns {string} rendered date
52 const dateFormat
= (date
, pInf
= 'Never', nInf
= 'Forever', otherwise
= '') => {
53 const isDatableType
= ['number', 'string'].includes(typeof date
);
61 || Number
.isNaN(date
.valueOf())
62 || (!(date
instanceof Date
) && !isDatableType
)) {
67 date
= new Date(date
);
69 const parts
= dateFormat
._dtf
.formatToParts(date
);
70 return parts
.map((p
) => p
.value
).join('');
72 lazy(dateFormat
, '_dtf', () => {
73 const dateTimeFormatOptions
= {
77 return new Intl
.DateTimeFormat(undefined, dateTimeFormatOptions
);
82 * Wrap a Date in a <time> element block.
83 * @param {Date} date date
84 * @param {object} options options
85 * @param {string=} options.title title attr for time element
86 * @returns {string} time element
88 const timeElement
= (date
, options
= {}) => {
96 ...(title
&& { title
}),
97 ...(date
instanceof Date
&& { datetime: date
.toISOString() }),
101 elementAttributes(attributes
),
103 dateFormat(date
, pInf
, nInf
, otherwise
),
111 * @param {number} seconds duration
112 * @returns {string} readable duration
114 const secondsToPeriod
= (seconds
) => {
118 const nextResult
= (factor
, label
) => {
119 const r
= factor
? value
% factor : value
;
121 result
.push(`${r} ${label}${r != 1 ? 's' : ''}`);
123 value
= factor
? Math
.floor(value
/ factor
) : value
;
126 nextResult(60, 'second');
127 nextResult(60, 'minute');
128 nextResult(24, 'hour');
129 nextResult(30, 'day');
130 nextResult(undefined, 'month');
133 return result
.join(' ');
138 * Return array of strings prefixed with tabs.
139 * @param {number} indent depth
140 * @param {string[]} list items to indent by depth
141 * @returns {string[]} indented items
143 const indented
= (indent
, list
) => {
144 const spacer
= '\t'.repeat(indent
);
145 return list
.map((l
) => `${spacer}${l}`);
150 * Render the preamble <head> for an HTML page.
151 * @param {number} pagePathLevel number of paths below root this page is
152 * @param {object} ctx context
153 * @param {object} options options
154 * @param {string[]=} options.headElements element strings to add to html head element
155 * @param {string=} options.pageTitle title of page
156 * @returns {string} populated head element
158 function htmlHead(pagePathLevel
, ctx
, options
) {
159 const rootPathPfx
= '../'.repeat(pagePathLevel
);
165 \t\t<meta charset="utf-8">
166 \t\t<meta name="viewport" content="width=device-width,initial-scale=1">
167 \t\t<link rel="stylesheet" href="${rootPathPfx}static/theme.css">
168 \t\t<link rel="stylesheet" href="${rootPathPfx}static/custom.css">
169 ${headElements.map((e) => '\t\t' + e).join('\n')}
170 \t\t<title>${pageTitle}</title>
176 * Render the main content of an HTML page.
177 * @param {number} pagePathLevel number of subdirs under root this page is
178 * @param {object} ctx context
179 * @param {object} options options
180 * @param {object} options.bodyAttributes attributes to set on body element
181 * @param {string[]} main main content strings
182 * @returns {string} body element
184 function htmlBody(pagePathLevel
, ctx
, options
, main
= []) {
188 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.
190 \t<body${elementAttributes(bodyAttributes)}>${firefoxFix}
191 ${htmlHeader(pagePathLevel, ctx, options)}
192 ${htmlMessages(ctx, options)}
196 ${htmlFooter(ctx, options)}
202 * Render a navigation link for the header section.
203 * @param {object} nav navigation object
204 * @param {string} nav.href href of link
205 * @param {string} nav.class class for anchor element
206 * @param {string} nav.text text of link
207 * @returns {string} a element
209 function renderNavLink(nav
) {
210 const aClass
= nav
.class ? ` class="${nav.class}"` : '';
211 return `<a href="${nav.href}"${aClass}>${nav.text}</a>`;
216 * Render the navigation section of the page header.
217 * @param {object} ctx context
218 * @param {object} options options
219 * @param {object[]=} options.navLinks array of navlink objects
220 * @returns {string} nav element
222 function htmlNav(ctx
, options
) {
224 const spacer
= '\t'.repeat(indent
);
228 return navLinks
.length
? `${spacer}<nav>
229 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
230 ${spacer}</nav>` : '';
235 * Render the banner and navigation header.
236 * @param {number} pagePathLevel number of subdirs under root this page is
237 * @param {object} ctx context
238 * @param {object} options options
239 * @param {string[]=} options.logoUrl url to logo image
240 * @param {string[]=} options.logoAlt alt for logo image
241 * @param {string[]=} options.pageTitle page title
242 * @returns {string} header element
244 function htmlHeader(pagePathLevel
, ctx
, options
) {
245 const rootPathPfx
= '../'.repeat(pagePathLevel
);
251 const logoImg
= logoUrl
? `<img src="${rootPathPfx}${logoUrl}" alt="logo" class="${logoAlt}">` : '';
253 \t\t\t<h1>${logoImg}${pageTitle}</h1>
254 ${htmlNav(ctx, options)}
260 * Render the bottom boilerplate.
261 * @param {object} ctx context
262 * @param {object} options options
263 * @param {string[]} options.footerEntries footer entries
264 * @returns {string} footer element
266 function htmlFooter(ctx
, options
) {
268 const spacer
= '\t'.repeat(indent
);
273 return footerEntries
.length
? `${spacer}<footer>
274 ${OL(footerEntries, indent + 1)}
275 ${spacer}</footer>` : '';
280 * Convert an object into element attributes.
281 * @param {object} attributes attributes
282 * @returns {string} formatted attributes
284 function elementAttributes(attributes
) {
285 const attr
= Object
.entries(attributes
).map(([name
, value
]) => {
286 const v
= value
? `="${value}"` : '';
287 return `${name}${v}`;
289 return attr
? ' ' + attr : '';
294 * Wrap an item in a list item element.
295 * @param {string} item text of item
296 * @param {number} indent indent level
297 * @param {object} attributes attributes for li element
298 * @returns {string} li element
300 function LI(item
, indent
= 0, attributes
= {}) {
301 const spacer
= '\t'.repeat(indent
);
302 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
307 * @typedef {(item: any, index: number, array: any[]) => object} ItemAttributeGenerator
311 * Wrap an array of items in a list container element.
312 * @param {string} element element type, e.g. OL, UL
313 * @param {number} indent indent level
314 * @param {object} attributes attributes for list element
315 * @param {string[]} items list entry elements
316 * @param {ItemAttributeGenerator} itemAttributeGenerator function which returns attributes for a given list entry element
317 * @returns {string} list type element
319 function listContainer(element
, indent
, attributes
, items
, itemAttributeGenerator
) {
320 const spacer
= '\t'.repeat(indent
);
321 return `${spacer}<${element}${elementAttributes(attributes)}>
322 ${items.map((item, index, array) => LI(item, indent + 1, itemAttributeGenerator(item, index, array))).join('\n')}
323 ${spacer}</${element}>`;
327 // eslint-disable-next-line jsdoc/require-returns-check
329 * @param {any} item item
330 * @param {number} index item index
331 * @param {Array<any>} array items
332 * @returns {object=} attribute map
334 function _defaultItemAttributeGenerator(item
, index
, array
) { // eslint-disable-line no-unused-vars
340 * Wrap a list of items in an unordered list.
341 * @param {string[]} items list item elements
342 * @param {number} indent indent level
343 * @param {object} attributes ul element attributes
344 * @param {ItemAttributeGenerator} itemAttributeGenerator function which returns attributes for a given list entry element
345 * @returns {string} ul element
347 function UL(items
, indent
= 0, attributes
= {}, itemAttributeGenerator
= _defaultItemAttributeGenerator
) {
348 return listContainer('ul', indent
, attributes
, items
, itemAttributeGenerator
);
353 * Wrap a list of items in an ordered list.
354 * @param {string[]} items list item elements
355 * @param {number} indent indent level
356 * @param {object} attributes ol element attributes
357 * @param {ItemAttributeGenerator} itemAttributeGenerator function which returns attributes for a given liste entry element
358 * @returns {string} ol element
360 function OL(items
, indent
= 0, attributes
= {}, itemAttributeGenerator
= _defaultItemAttributeGenerator
) {
361 return listContainer('ol', indent
, attributes
, items
, itemAttributeGenerator
);
366 * Show any error or notice messages from context.
367 * @param {object} ctx context
368 * @param {string[]=} ctx.errors errors to render
369 * @param {string[]=} ctx.notifications notifications to render
370 * @param {object} options options
371 * @param {string=} options.errorHeading additional heading when rendering errors
372 * @param {string[]=} options.errorContent additional text when rendering errors
373 * @param {string=} options.notificationHeading additional heading when rendering notifications
374 * @param {string[]=} options.notificationContent additional text when rendering notifications
375 * @returns {string} h2 element and section element
377 function htmlMessages(ctx
, options
) {
378 const errorHeading
= options
?.errorHeading
? `
379 \t<h2>${options.errorHeading}</h2>` : '';
380 const errorContent
= options
?.errorContent
?.length
? '\n' + options
.errorContent
.map(((content
) => `\t${content}`)).join('\n') : '';
381 const notificationHeading
= options
?.notificationHeading
? `\n\t<h2>${options.notificationHeading}</h2>` : '';
382 const notificationContent
= options
?.notificationContent
?.length
? '\n' + options
.notificationContent
.map(((content
) => `\t${content}`)).join('\n') : '';
383 const errors
= ctx
?.errors
?.length
? `
384 <section class="error">${errorHeading}${errorContent}
387 const notifications
= ctx
?.notifications
?.length
? `
388 <section class="notification">${notificationHeading}${notificationContent}
389 ${UL(ctx.notifications, 1)}
391 return [errors
, notifications
].join('\n');
396 * Render all parts of an HTML page.
397 * @param {number} pagePathLevel relative path-distance to base
398 * @param {object} ctx context
399 * @param {string[]=} ctx.errors errors
400 * @param {string[]=} ctx.notifications notifications
401 * @param {object} options options
402 * @param {string=} options.pageTitle page title
403 * @param {string=} options.logoUrl logo url
404 * @param {string=} options.logoAlt logo alt
405 * @param {object[]=} options.bodyAttributes body attributes
406 * @param {string[]=} options.headElements head element elements
407 * @param {object[]=} options.navLinks nav objects
408 * @param {string[]=} options.footerEntries footers
409 * @param {string=} options.errorHeading errors heading
410 * @param {string[]=} options.errorContent errors text
411 * @param {string=} options.notificationHeading notifications heading
412 * @param {string[]=} options.notificationContent notifications text
413 * @param {string[]} main array of contents strings
414 * @returns {string} html page
416 function htmlPage(pagePathLevel
, ctx
, options
, main
= []) {
420 htmlHead(pagePathLevel
, ctx
, options
),
421 htmlBody(pagePathLevel
, ctx
, options
, main
),