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
15 const initContext
= (ctx
) => {
17 ctx
.notifications
= [];
21 * Some fields may have values outside normal dates, handle them here.
22 * @param {Date|Number} date
23 * @param {String} otherwise
25 const dateOrNot
= (date
, otherwise
) => {
29 if (typeof date
=== 'number') {
30 date
= new Date(date
);
32 const dateMs
= date
.getTime();
33 if (!Number
.isFinite(dateMs
)
37 return date
.toString();
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
50 const dateFormat
= (date
, pInf
= 'Never', nInf
= 'Forever', otherwise
= '') => {
51 const isDatableType
= ['number', 'string'].includes(typeof date
);
59 || Number
.isNaN(date
.valueOf())
60 || (!(date
instanceof Date
) && !isDatableType
)) {
65 date
= new Date(date
);
67 const parts
= dateFormat
._dtf
.formatToParts(date
);
68 return parts
.map((p
) => p
.value
).join('');
70 lazy(dateFormat
, '_dtf', () => {
71 const dateTimeFormatOptions
= {
75 return new Intl
.DateTimeFormat(undefined, dateTimeFormatOptions
);
80 * Wrap a Date in a <time> block.
82 * @param {Object} options
83 * @param {String=} options.title
85 const timeElement
= (date
, options
= {}) => {
93 ...(title
&& { title
}),
94 ...(date
instanceof Date
&& { datetime: date
.toISOString() }),
98 elementAttributes(attributes
),
100 dateFormat(date
, pInf
, nInf
, otherwise
),
108 * @param {Number} seconds
111 const secondsToPeriod
= (seconds
) => {
115 const nextResult
= (factor
, label
) => {
116 const r
= factor
? value
% factor : value
;
118 result
.push(`${r} ${label}${r != 1 ? 's' : ''}`);
120 value
= factor
? Math
.floor(value
/ factor
) : value
;
123 nextResult(60, 'second');
124 nextResult(60, 'minute');
125 nextResult(24, 'hour');
126 nextResult(30, 'day');
127 nextResult(undefined, 'month');
130 return result
.join(' ');
135 * Return array of strings prefixed with tabs.
136 * @param {Number} indent
137 * @param {String[]} list
139 const indented
= (indent
, list
) => {
140 const spacer
= '\t'.repeat(indent
);
141 return list
.map((l
) => `${spacer}${l}`);
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
154 function htmlHead(pagePathLevel
, ctx
, options
) {
155 const rootPathPfx
= '../'.repeat(pagePathLevel
);
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>
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
180 function htmlBody(pagePathLevel
, ctx
, options
, main
= []) {
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.
186 \t<body${elementAttributes(bodyAttributes)}>${firefoxFix}
187 ${htmlHeader(pagePathLevel, ctx, options)}
188 ${htmlMessages(ctx, options)}
192 ${htmlFooter(ctx, options)}
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
205 function renderNavLink(nav
) {
206 const aClass
= nav
.class ? ` class="${nav.class}"` : '';
207 return `<a href="${nav.href}"${aClass}>${nav.text}</a>`;
212 * Render the navigation section of the page header.
213 * @param {Object} ctx
214 * @param {Object} options
215 * @param {Object[]=} options.navLinks
218 function htmlNav(ctx
, options
) {
220 const spacer
= '\t'.repeat(indent
);
224 return navLinks
.length
? `${spacer}<nav>
225 ${OL(navLinks.map((link) => renderNavLink(link)), indent + 1)}
226 ${spacer}</nav>` : '';
231 * Render the banner and navigation header.
232 * @param {Number} pagePathLevel
233 * @param {Object} ctx
234 * @param {Object} options
235 * @param {String[]=} options.logoUrl
236 * @param {String[]=} options.logoAlt
237 * @param {String[]=} options.pageTitle
240 function htmlHeader(pagePathLevel
, ctx
, options
) {
241 const rootPathPfx
= '../'.repeat(pagePathLevel
);
247 const logoImg
= logoUrl
? `<img src="${rootPathPfx}${logoUrl}" alt="logo" class="${logoAlt}">` : '';
249 \t\t\t<h1>${logoImg}${pageTitle}</h1>
250 ${htmlNav(ctx, options)}
256 * Render the bottom boilerplate.
257 * @param {Object} ctx
258 * @param {Object} options
259 * @param {String[]} options.footerEntries
262 function htmlFooter(ctx
, options
) {
264 const spacer
= '\t'.repeat(indent
);
269 return footerEntries
.length
? `${spacer}<footer>
270 ${OL(footerEntries, indent + 1)}
271 ${spacer}</footer>` : '';
276 * Convert an object into element attributes.
277 * @param {Object} attributes
280 function elementAttributes(attributes
) {
281 const attr
= Object
.entries(attributes
).map(([name
, value
]) => {
282 const v
= value
? `="${value}"` : '';
283 return `${name}${v}`;
285 return attr
? ' ' + attr : '';
290 * Wrap an item in a list item element.
291 * @param {String} item
292 * @param {Number} indent
293 * @param {Object} attributes
296 function LI(item
, indent
= 0, attributes
= {}) {
297 const spacer
= '\t'.repeat(indent
);
298 return `${spacer}<li${elementAttributes(attributes)}>${item}</li>`;
303 * Wrap an array of items in a list container element.
304 * @param {String} element
305 * @param {Number} indent
306 * @param {Object} attributes
307 * @param {String[]} items
308 * @param {(item, index, array) => {Object}} itemAttributeGenerator
311 function listContainer(element
, indent
, attributes
, items
, itemAttributeGenerator
) {
312 const spacer
= '\t'.repeat(indent
);
313 return `${spacer}<${element}${elementAttributes(attributes)}>
314 ${items.map((item, index, array) => LI(item, indent + 1, itemAttributeGenerator(item, index, array))).join('\n')}
315 ${spacer}</${element}>`;
320 * Wrap a list of items in an unordered list.
321 * @param {String[]} items
322 * @param {Number} indent
323 * @param {Object} attributes
324 * @param {(item, index, array) => Object} itemAttributeGenerator
327 function UL(items
, indent
= 0, attributes
= {}, itemAttributeGenerator
= () => {}) {
328 return listContainer('ul', indent
, attributes
, items
, itemAttributeGenerator
);
333 * Wrap a list of items in an ordered list.
334 * @param {String[]} items
335 * @param {Number} indent
336 * @param {Object} attributes
337 * @param {(item, index, array) => Object} itemAttributeGenerator
341 function OL(items
, indent
= 0, attributes
= {}, itemAttributeGenerator
= () => {}) {
342 return listContainer('ol', indent
, attributes
, items
, itemAttributeGenerator
);
347 * Show any error or notice messages from context.
348 * @param {Object} ctx
349 * @param {String[]=} ctx.errors
350 * @param {String[]=} ctx.notifications
351 * @param {Object} options
352 * @param {String=} options.errorHeading
353 * @param {String[]=} options.errorContent
354 * @param {String=} options.notificationHeading
355 * @param {String[]=} options.notificationContent
357 function htmlMessages(ctx
, options
) {
358 const errorHeading
= options
?.errorHeading
? `
359 \t<h2>${options.errorHeading}</h2>` : '';
360 const errorContent
= options
?.errorContent
?.length
? '\n' + options
.errorContent
.map(((content
) => `\t${content}`)).join('\n') : '';
361 const notificationHeading
= options
?.notificationHeading
? `\n\t<h2>${options.notificationHeading}</h2>` : '';
362 const notificationContent
= options
?.notificationContent
?.length
? '\n' + options
.notificationContent
.map(((content
) => `\t${content}`)).join('\n') : '';
363 const errors
= ctx
?.errors
?.length
? `
364 <section class="error">${errorHeading}${errorContent}
367 const notifications
= ctx
?.notifications
?.length
? `
368 <section class="notification">${notificationHeading}${notificationContent}
369 ${UL(ctx.notifications, 1)}
371 return [errors
, notifications
].join('\n');
376 * Render all parts of an HTML page.
377 * @param {Number} pagePathLevel - relative path-distance to base
378 * @param {Object} ctx
379 * @param {String[]=} ctx.errors
380 * @param {String[]=} ctx.notifications
381 * @param {Object} options
382 * @param {String=} options.pageTitle
383 * @param {String=} options.logoUrl
384 * @param {String=} options.logoAlt
385 * @param {Object[]=} options.bodyAttributes
386 * @param {String[]=} options.headElements
387 * @param {Object[]=} options.navLinks
388 * @param {String[]=} options.footerEntries
389 * @param {String=} options.errorHeading
390 * @param {String[]=} options.errorContent
391 * @param {String=} options.notificationHeading
392 * @param {String[]=} options.notificationContent
393 * @param {String[]} main - contents
396 function htmlPage(pagePathLevel
, ctx
, options
, main
= []) {
400 htmlHead(pagePathLevel
, ctx
, options
),
401 htmlBody(pagePathLevel
, ctx
, options
, main
),