diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/content/dependencies/generateAdditionalFilesList.js | 67 | ||||
-rw-r--r-- | src/content/dependencies/generateAlbumAdditionalFilesList.js | 24 | ||||
-rw-r--r-- | src/content/dependencies/generateAlbumInfoPage.js | 16 | ||||
-rw-r--r-- | src/content/dependencies/generateAlbumInfoPageContent.js | 27 | ||||
-rw-r--r-- | src/content/dependencies/generateContentHeading.js | 26 | ||||
-rw-r--r-- | src/content/dependencies/generateCoverArtwork.js | 49 | ||||
-rw-r--r-- | src/content/dependencies/generatePageLayout.js | 65 | ||||
-rw-r--r-- | src/content/dependencies/image.js | 76 | ||||
-rw-r--r-- | src/content/dependencies/index.js | 10 | ||||
-rw-r--r-- | src/content/dependencies/linkAlbumAdditionalFile.js | 6 | ||||
-rw-r--r-- | src/content/dependencies/linkTemplate.js | 50 | ||||
-rw-r--r-- | src/content/dependencies/linkThing.js | 42 | ||||
-rw-r--r-- | src/data/things/homepage-layout.js | 4 | ||||
-rw-r--r-- | src/data/things/validators.js | 42 | ||||
-rwxr-xr-x | src/upd8.js | 1 | ||||
-rw-r--r-- | src/util/html.js | 469 | ||||
-rw-r--r-- | src/write/build-modes/live-dev-server.js | 19 |
17 files changed, 690 insertions, 303 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js index c51435a4..eb9fc8b0 100644 --- a/src/content/dependencies/generateAdditionalFilesList.js +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -18,15 +18,61 @@ export default { html, language, }) { - return html.template(slot => - slot('additionalFileLinks', ([fileLinks]) => - slot('additionalFileSizes', ([fileSizes]) => { - if (!fileSizes) { + const fileKeys = data.additionalFiles.flatMap(({files}) => files); + const validateFileMapping = (v, validateValue) => { + return value => { + v.isObject(value); + + // It's OK to skip values for files, but if keys are provided for files + // which don't exist, that's an error. + + const unexpectedKeys = + Object.keys(value).filter(key => !fileKeys.includes(key)) + + if (!empty(unexpectedKeys)) { + throw new TypeError(`Unexpected file keys: ${unexpectedKeys.join(', ')}`); + } + + const valueErrors = []; + for (const [fileKey, fileValue] of Object.entries(value)) { + if (fileValue === null) { + continue; + } + + try { + validateValue(fileValue); + } catch (error) { + error.message = `(${fileKey}) ` + error.message; + valueErrors.push(error); + } + } + + if (!empty(valueErrors)) { + throw new AggregateError(valueErrors, `Errors validating values`); + } + }; + }; + + return html.template({ + annotation: 'generateAdditionalFilesList', + + slots: { + fileLinks: { + validate: v => validateFileMapping(v, v.isHTML), + }, + + fileSizes: { + validate: v => validateFileMapping(v, v.isWholeNumber), + }, + }, + + content(slots) { + if (!slots.fileSizes) { return html.blank(); } const filesWithLinks = new Set( - Object.entries(fileLinks) + Object.entries(slots.fileLinks) .filter(([key, value]) => value) .map(([key]) => key)); @@ -60,15 +106,16 @@ export default { html.tag('ul', files.map(file => html.tag('li', - (fileSizes[file] + (slots.fileSizes[file] ? language.$('releaseInfo.additionalFiles.file.withSize', { - file: fileLinks[file], - size: language.formatFileSize(fileSizes[file]), + file: slots.fileLinks[file], + size: language.formatFileSize(slots.fileSizes[file]), }) : language.$('releaseInfo.additionalFiles.file', { - file: fileLinks[file], + file: slots.fileLinks[file], })))))), ])); - }))); + }, + }); }, }; diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js index d45fb583..04e6a5f1 100644 --- a/src/content/dependencies/generateAlbumAdditionalFilesList.js +++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js @@ -40,16 +40,18 @@ export default { urls, }) { return relations.additionalFilesList - .slot('additionalFileLinks', relations.additionalFileLinks) - .slot('additionalFileSizes', - Object.fromEntries(data.fileLocations.map(file => [ - file, - (data.showFileSizes - ? getSizeOfAdditionalFile( - urls - .from('media.root') - .to('media.albumAdditionalFile', data.albumDirectory, file)) - : 0), - ]))); + .slots({ + additionalFileLinks: relations.additionalFileLinks, + additionalFileSizes: + Object.fromEntries(data.fileLocations.map(file => [ + file, + (data.showFileSizes + ? getSizeOfAdditionalFile( + urls + .from('media.root') + .to('media.albumAdditionalFile', data.albumDirectory, file)) + : 0), + ])), + }); }, }; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index dcd8589c..f0a23259 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -36,8 +36,6 @@ export default { generate(data, relations, { language, }) { - // page.title = language.$('albumPage.title', {album: data.name}); - // page.themeColor = data.color; // page.styleRules = [ @@ -45,12 +43,14 @@ export default { // relations.colorStyleRules, // ]; - // page.socialEmbed = relations.socialEmbed; - return relations.layout - .slot('title', language.$('albumPage.title', {album: data.name})) - .slot('cover', relations.content.cover) - .slot('mainContent', relations.content.main.content) - .slot('socialEmbed', relations.socialEmbed); + .slots({ + title: language.$('albumPage.title', {album: data.name}), + + cover: relations.content.cover, + mainContent: relations.content.main.content, + + // socialEmbed: relations.socialEmbed, + }); }, }; diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js index a17a33f1..fd66f6b0 100644 --- a/src/content/dependencies/generateAlbumInfoPageContent.js +++ b/src/content/dependencies/generateAlbumInfoPageContent.js @@ -117,8 +117,10 @@ export default { const content = {}; content.cover = relations.cover - .slot('path', ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension]) - .slot('alt', language.$('misc.alt.trackCover')); + .slots({ + path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension], + alt: language.$('misc.alt.trackCover') + }); content.main = { headingMode: 'sticky', @@ -213,20 +215,25 @@ export default { relations.additionalFilesList && [ relations.additionalFilesHeading - .slot('id', 'additional-files') - .slot('title', - language.$('releaseInfo.additionalFiles.heading', { - additionalFiles: - language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), - })), + .slots({ + id: 'additional-files', + + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), relations.additionalFilesList, ], data.artistCommentary && [ relations.artistCommentaryHeading - .slot('id', 'artist-commentary') - .slot('title', language.$('releaseInfo.artistCommentary')), + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), html.tag('blockquote', transformMultiline(data.artistCommentary)), diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js index baa52080..f5e4bd00 100644 --- a/src/content/dependencies/generateContentHeading.js +++ b/src/content/dependencies/generateContentHeading.js @@ -4,13 +4,23 @@ export default { ], generate({html}) { - return html.template(slot => - html.tag('p', - { - class: 'content-heading', - id: slot('id'), - tabindex: '0', - }, - slot('title'))); + return html.template({ + annotation: 'generateContentHeading', + + slots: { + title: {type: 'html'}, + id: {type: 'string'}, + }, + + content(slots) { + return html.tag('p', + { + class: 'content-heading', + id: slots.id, + tabindex: '0', + }, + slots.content); + }, + }); } } diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index 62fc3566..2d18fed3 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -23,21 +23,38 @@ export default { }, generate(relations, {html, language}) { - return html.template(slot => - html.tag('div', {id: 'cover-art-container'}, [ - relations.image - .slot('path', slot('path')) - .slot('alt', slot('alt')) - .slot('thumb', 'medium') - .slot('id', 'cover-art') - .slot('link', true) - .slot('square', true), - - !empty(relations.tagLinks) && - html.tag('p', - language.$('releaseInfo.artTags.inline', { - tags: language.formatUnitList(relations.tagLinks), - })), - ])); + return html.template({ + annotation: 'generateCoverArtwork', + + slots: { + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + alt: { + type: 'string', + }, + }, + + content(slots) { + return html.tag('div', {id: 'cover-art-container'}, [ + relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'medium', + id: 'cover-art', + link: true, + square: true, + }), + + !empty(relations.tagLinks) && + html.tag('p', + language.$('releaseInfo.artTags.inline', { + tags: language.formatUnitList(relations.tagLinks), + })), + ]); + }, + }); }, }; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index b27d487b..f36a7bb5 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -1,5 +1,3 @@ -import {empty} from '../../util/sugar.js'; - export default { extraDependencies: [ 'html', @@ -13,41 +11,63 @@ export default { language, to, }) { - return html.template(slot => - slot('title', ([...title]) => - slot('headingMode', ([headingMode = 'static']) => { + return html.template({ + annotation: 'generatePageLayout', + + slots: { + title: {type: 'html'}, + cover: {type: 'html'}, + + mainContent: {type: 'html'}, + socialEmbed: {type: 'html'}, + + headingMode: { + validate: v => v.is('sticky', 'static'), + default: 'static', + }, + + mainClasses: { + validate: v => v.arrayOf(v.isString), + default: [], + }, + }, + + content(slots) { let titleHTML = null; - if (!empty(title)) { - if (headingMode === 'sticky') { - /* - generateStickyHeadingContainer({ - coverSrc: cover.src, - coverAlt: cover.alt, - coverArtTags: cover.artTags, - title, - }) - */ - } else if (headingMode === 'static') { - titleHTML = html.tag('h1', title); + if (!html.isBlank(slots.title)) { + switch (slots.headingMode) { + case 'sticky': + /* + generateStickyHeadingContainer({ + coverSrc: cover.src, + coverAlt: cover.alt, + coverArtTags: cover.artTags, + title, + }) + */ + break; + case 'static': + titleHTML = html.tag('h1', slots.title); + break; } } const mainHTML = html.tag('main', { id: 'content', - class: slot('mainClass'), + class: slots.mainClasses, }, [ titleHTML, - slot('cover'), + slots.cover, html.tag('div', { [html.onlyIfContent]: true, class: 'main-content-container', }, - slot('mainContent')), + slots.mainContent), ]); const layoutHTML = [ @@ -135,7 +155,7 @@ export default { */ - // slot('socialEmbed'), + // slots.socialEmbed, html.tag('link', { rel: 'stylesheet', @@ -176,6 +196,7 @@ export default { ]); return documentHTML; - }))); + }, + }); }, }; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index 1f904377..1960fb0a 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -31,39 +31,60 @@ export default { thumb, to, }) { - return html.template(slot => - slot('src', ([src]) => - slot('path', ([...path]) => - slot('thumb', ([thumbKey = '']) => - slot('link', ([link = false]) => - slot('lazy', ([lazy = false]) => - slot('square', ([willSquare = false]) => { + return html.template({ + annotation: 'image', + + slots: { + src: { + type: 'string', + }, + + path: { + validate: v => v.validateArrayItems(v.isString), + }, + + thumb: {type: 'string'}, + + link: {type: 'boolean', default: false}, + lazy: {type: 'boolean', default: false}, + square: {type: 'boolean', default: false}, + + id: {type: 'string'}, + alt: {type: 'string'}, + width: {type: 'number'}, + height: {type: 'number'}, + + missingSourceContent: {type: 'html'}, + }, + + content(slots) { let originalSrc; - if (src) { - originalSrc = src; - } else if (!empty(path)) { - originalSrc = to(...path); + if (slots.src) { + originalSrc = slots.src; + } else if (!empty(slots.path)) { + originalSrc = to(...slots.path); } else { originalSrc = ''; } const thumbSrc = originalSrc && - (thumbKey - ? thumb[thumbKey](originalSrc) + (slots.thumb + ? thumb[slots.thumb](originalSrc) : originalSrc); - const willLink = typeof link === 'string' || link; + const willLink = typeof slots.link === 'string' || slots.link; const willReveal = originalSrc && !empty(data.contentWarnings); + const willSquare = slots.square; - const idOnImg = willLink ? null : slot('id'); - const idOnLink = willLink ? slot('id') : null; + const idOnImg = willLink ? null : slots.id; + const idOnLink = willLink ? slots.id : null; if (!originalSrc) { return prepare( html.tag('div', {class: 'image-text-area'}, - slot('missingSourceContent'))); + slots.missingSourceContent)); } let fileSize = null; @@ -90,13 +111,11 @@ export default { ]; } - const className = slot('class'); const imgAttributes = { id: idOnImg, - class: className, - alt: slot('alt'), - width: slot('width'), - height: slot('height'), + alt: slots.alt, + width: slots.width, + height: slots.height, 'data-original-size': fileSize, }; @@ -108,14 +127,14 @@ export default { src: thumbSrc, })); - if (lazy) { + if (slots.lazy) { return html.tags([ html.tag('noscript', nonlazyHTML), prepare( html.tag('img', { ...imgAttributes, - class: [className, 'lazy'], + class: 'lazy', 'data-original': thumbSrc, }), true), @@ -166,8 +185,8 @@ export default { ], href: - (typeof link === 'string' - ? link + (typeof slots.link === 'string' + ? slots.link : originalSrc), }, wrapped); @@ -175,6 +194,7 @@ export default { return wrapped; } - }))))))); - }, + }, + }); + } }; diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index c2d88f64..f82062f7 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -1,6 +1,7 @@ import chokidar from 'chokidar'; import EventEmitter from 'events'; import * as path from 'path'; +import {ESLint} from 'eslint'; import {fileURLToPath} from 'url'; import contentFunction from '../../content-function.js'; @@ -35,6 +36,8 @@ export function watchContentDependencies({ close, }); + const eslint = new ESLint(); + // Watch adjacent files const metaPath = fileURLToPath(import.meta.url); const metaDirname = path.dirname(metaPath); @@ -129,6 +132,13 @@ export function watchContentDependencies({ let error = null; main: { + const eslintResults = await eslint.lintFiles([filePath]); + const eslintFormatter = await eslint.loadFormatter('stylish'); + const eslintResultText = eslintFormatter.format(eslintResults); + if (eslintResultText.trim().length) { + console.log(eslintResultText); + } + let spec; try { spec = (await import(cachebust(filePath))).default; diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js index d1cca914..27c0ba9c 100644 --- a/src/content/dependencies/linkAlbumAdditionalFile.js +++ b/src/content/dependencies/linkAlbumAdditionalFile.js @@ -18,7 +18,9 @@ export default { generate(data, relations) { return relations.linkTemplate - .slot('path', ['media.albumAdditionalFile', data.albumDirectory, data.file]) - .slot('content', data.file); + .slots({ + path: ['media.albumAdditionalFile', data.albumDirectory, data.file], + content: data.file, + }); }, }; diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js index acac99be..b87f3180 100644 --- a/src/content/dependencies/linkTemplate.js +++ b/src/content/dependencies/linkTemplate.js @@ -14,15 +14,25 @@ export default { html, to, }) { - return html.template(slot => - slot('color', ([color]) => - slot('hash', ([hash]) => - slot('href', ([href]) => - slot('path', ([...path]) => { + return html.template({ + annotation: 'linkTemplate', + + slots: { + href: {type: 'string'}, + path: {validate: v => v.validateArrayItems(v.isString)}, + hash: {type: 'string'}, + + attributes: {validate: v => v.isAttributes}, + color: {validate: v => v.isColor}, + content: {type: 'html'}, + }, + + content(slots) { + let href = slots.href; let style; - if (!href && !empty(path)) { - href = to(...path); + if (!href && !empty(slots.path)) { + href = to(...slots.path); } if (appendIndexHTML) { @@ -34,23 +44,23 @@ export default { } } - if (hash) { - href += (hash.startsWith('#') ? '' : '#') + hash; + if (slots.hash) { + href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash; } - if (color) { - const {primary, dim} = getColors(color); + if (slots.color) { + const {primary, dim} = getColors(slots.color); style = `--primary-color: ${primary}; --dim-color: ${dim}`; } - return slot('attributes', ([attributes]) => - html.tag('a', - { - ...attributes ?? {}, - href, - style, - }, - slot('content'))); - }))))); + return html.tag('a', + { + ...slots.attributes ?? {}, + href, + style, + }, + slots.content); + }, + }); }, } diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js index ebff6761..70c86fc4 100644 --- a/src/content/dependencies/linkThing.js +++ b/src/content/dependencies/linkThing.js @@ -1,5 +1,3 @@ -import {empty} from '../../util/sugar.js'; - export default { contentDependencies: [ 'linkTemplate', @@ -30,22 +28,40 @@ export default { generate(data, relations, {html}) { const path = [data.pathKey, data.directory]; - return html.template(slot => - slot('content', ([...content]) => - slot('preferShortName', ([preferShortName]) => { - if (empty(content)) { + return html.template({ + annotation: 'linkThing', + + slots: { + content: relations.linkTemplate.getSlotDescription('content'), + preferShortName: {type: 'boolean', default: false}, + + color: relations.linkTemplate.getSlotDescription('color'), + attributes: relations.linkTemplate.getSlotDescription('attributes'), + hash: relations.linkTemplate.getSlotDescription('hash'), + }, + + content(slots) { + let content = slots.content; + + if (html.isBlank(content)) { content = - (preferShortName + (slots.preferShortName ? data.nameShort ?? data.name : data.name); } + const color = slots.color ?? data.color ?? null; + return relations.linkTemplate - .slot('path', path) - .slot('color', slot('color', data.color)) - .slot('attributes', slot('attributes', {})) - .slot('hash', slot('hash')) - .slot('content', content); - }))); + .slots({ + path, + content, + color, + + attributes: slots.attributes, + hash: slots.hash, + }); + }, + }); }, } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index c18e8110..a79dd77a 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -68,10 +68,10 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { Group, validators: { + is, isCountingNumber, isString, validateArrayItems, - validateFromConstants, }, } = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), @@ -95,7 +95,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { flags: {update: true, expose: true}, update: { - validate: validateFromConstants('grid', 'carousel'), + validate: is('grid', 'carousel'), }, expose: { diff --git a/src/data/things/validators.js b/src/data/things/validators.js index b26de86b..14092102 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -138,6 +138,34 @@ export function isArray(value) { return true; } +// This one's shaped a bit different from other "is" functions. +// More like validate functions, it returns a function. +export function is(...values) { + if (Array.isArray(values)) { + values = new Set(values); + } + + if (values.size === 1) { + const expected = Array.from(values)[0]; + + return (value) => { + if (value !== expected) { + throw new TypeError(`Expected ${expected}, got ${value}`); + } + + return true; + }; + } + + return (value) => { + if (!values.has(value)) { + throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); + } + + return true; + }; +} + function validateArrayItemsHelper(itemValidator) { return (item, index) => { try { @@ -167,18 +195,12 @@ export function validateArrayItems(itemValidator) { }; } -export function validateInstanceOf(constructor) { - return (object) => isInstance(object, constructor); +export function arrayOf(itemValidator) { + return validateArrayItems(itemValidator); } -export function validateFromConstants(...values) { - return (value) => { - if (!values.includes(value)) { - throw new TypeError(`Expected one of ${values.join(', ')}`); - } - - return true; - }; +export function validateInstanceOf(constructor) { + return (object) => isInstance(object, constructor); } // Wiki data (primitives & non-primitives) diff --git a/src/upd8.js b/src/upd8.js index 9fff67cc..ed54ec47 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -772,6 +772,7 @@ async function main() { developersComment, getSizeOfAdditionalFile, getSizeOfImageFile, + niceShowAggregate, }); } diff --git a/src/util/html.js b/src/util/html.js index 4a0c08e7..4735c9dc 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -1,5 +1,8 @@ // Some really simple functions for formatting HTML content. +import * as commonValidators from '../data/things/validators.js'; +import {empty} from './sugar.js'; + // COMPREHENSIVE! // https://html.spec.whatwg.org/multipage/syntax.html#void-elements export const selfClosingTags = [ @@ -38,19 +41,15 @@ export const joinChildren = Symbol(); // or when there are multiple children. export const noEdgeWhitespace = Symbol(); -export function blank() { - return []; -} - // Note: This is only guaranteed to return true for blanks (as returned by -// html.blank()) and false for Tags and Slots (regardless of contents or +// html.blank()) and false for Tags and Templates (regardless of contents or // other properties). Don't depend on this to match any other values. export function isBlank(value) { - if (value instanceof Tag) { + if (isTag(value)) { return false; } - if (value instanceof Slot) { + if (isTemplate(value)) { return false; } @@ -61,6 +60,102 @@ export function isBlank(value) { return value.length === 0; } +export function isTag(value) { + return value instanceof Tag; +} + +export function isTemplate(value) { + return value instanceof Template; +} + +export function isHTML(value) { + if (typeof value === 'string') { + return true; + } + + if (value === null || value === undefined) { + return true; + } + + if (isBlank(value) || isTag(value) || isTemplate(value)) { + return true; + } + + if (Array.isArray(value)) { + if (value.every(isHTML)) { + return true; + } + } + + return false; +} + +export function isAttributes(value) { + if (typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + if (value === null) { + return false; + } + + if (isTag(value) || isTemplate(value)) { + return false; + } + + // TODO: Validate attribute values (just the general shape) + + return true; +} + +export const validators = { + // TODO: Move above implementations here and detail errors + + isBlank(value) { + if (!isBlank(value)) { + throw new TypeError(`Expected html.blank()`); + } + + return true; + }, + + isTag(value) { + if (!isTag(value)) { + throw new TypeError(`Expected HTML tag`); + } + + return true; + }, + + isTemplate(value) { + if (!isTemplate(value)) { + throw new TypeError(`Expected HTML template`); + } + + return true; + }, + + isHTML(value) { + if (!isHTML(value)) { + throw new TypeError(`Expected HTML content`); + } + + return true; + }, + + isAttributes(value) { + if (!isAttributes(value)) { + throw new TypeError(`Expected HTML attributes`); + } + + return true; + }, +}; + +export function blank() { + return []; +} + export function tag(tagName, ...args) { let content; let attributes; @@ -69,8 +164,7 @@ export function tag(tagName, ...args) { typeof args[0] === 'object' && !(Array.isArray(args[0]) || args[0] instanceof Tag || - args[0] instanceof Template || - args[0] instanceof Slot) + args[0] instanceof Template) ) { attributes = args[0]; content = args[1]; @@ -345,18 +439,10 @@ export class Attributes { toString() { return Object.entries(this.attributes) .map(([key, val]) => { - if (val instanceof Slot) { - const content = val.toString(); - return [key, content, !!content]; - } else { - return [key, val]; - } - }) - .map(([key, val, keepSlot]) => { if (typeof val === 'undefined' || val === null) return [key, val, false]; else if (typeof val === 'string') - return [key, val, keepSlot ?? true]; + return [key, val, true]; else if (typeof val === 'boolean') return [key, val, val]; else if (typeof val === 'number') @@ -390,176 +476,283 @@ export class Attributes { } } -export function template(getContent) { - return new Template(getContent); +export function template(description) { + return new Template(description); } export class Template { - #tag = new Tag(); - - #slotContents = {}; - #slotTraces = {}; + #description = {}; + #slotValues = {}; - constructor(getContent) { - this.#prepareContent(getContent); + constructor(description) { + Template.validateDescription(description); + this.#description = description; } - #prepareContent(getContent) { - const slotFunction = (slotName, defaultValue) => { - return new Slot(this, slotName, defaultValue); - }; + static validateDescription(description) { + if (description === null) { + return; + } - this.#tag.content = getContent(slotFunction); - } + if (typeof description !== 'object') { + throw new TypeError(`Expected object or null, got ${typeof description}`); + } - slot(slotName, content) { - this.setSlot(slotName, content); - return this; - } + const topErrors = []; - setSlot(slotName, content) { - if (Array.isArray(content)) { - this.#slotContents[slotName] = content; - } else { - this.#slotContents[slotName] = [content]; + if (!('content' in description)) { + topErrors.push(new TypeError(`Expected description.content`)); + } else if (typeof description.content !== 'function') { + topErrors.push(new TypeError(`Expected description.content to be function`)); } - this.#slotTraces[slotName] = getTopOfCallerTrace(); - } - getSlot(slotName) { - if (this.#slotContents[slotName]) { - const contents = this.#slotContents[slotName] - .map(item => - (item === null || item === undefined - ? item - : item.valueOf())); - return new Tag(null, null, contents).valueOf(); - } else { - return blank(); + if ('annotation' in description) { + if (typeof description.annotation !== 'string') { + topErrors.push(new TypeError(`Expected annotation to be string`)); + } } - } - // Dragons. - getSlotTrace(slotName) { - return this.#slotTraces[slotName] ?? ''; + const slotErrors = []; + + if ('slots' in description) validateSlots: { + if (typeof description.slots !== 'object') { + slotErrors.push(new TypeError(`Expected description.slots to be object`)); + break validateSlots; + } + + for (const [key, value] of Object.entries(description.slots)) { + if (typeof value !== 'object' || value === null) { + slotErrors.push(new TypeError(`Expected slot description (of ${key}) to be object`)); + continue; + } + + if ('default' in value) validateDefault: { + if (value.default === undefined || value.default === null) { + slotErrors.push(new TypeError(`Leave slot default (of ${key}) unspecified instead of undefined or null`)); + break validateDefault; + } + + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = `Error validating slot "${key}" default value: ${error.message}`; + slotErrors.push(error); + } + } + + if ('validate' in value && 'type' in value) { + slotErrors.push(new TypeError(`Don't specify both slot validate and type (of ${key})`)); + } else if (!('validate' in value || 'type' in value)) { + slotErrors.push(new TypeError(`Expected either slot validate or type (of ${key})`)); + } else if ('validate' in value) { + if (typeof value.validate !== 'function') { + slotErrors.push(new TypeError(`Expected slot validate of (${key}) to be function`)); + } + } else if ('type' in value) { + const acceptableSlotTypes = [ + 'string', + 'number', + 'bigint', + 'boolean', + 'symbol', + 'html', + ]; + + if (value.type === 'function') { + slotErrors.push(new TypeError(`Functions shouldn't be provided to slots (${key})`)); + } + + if (value.type === 'object') { + slotErrors.push(new TypeError(`Provide validate function instead of type: object (${key})`)); + } + + if (!acceptableSlotTypes.includes(value.type)) { + slotErrors.push(new TypeError(`Expected slot type (of ${key}) to be one of ${acceptableSlotTypes.join(', ')}`)); + } + } + } + } + + if (!empty(slotErrors)) { + topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`)); + } + + if (!empty(topErrors)) { + throw new AggregateError(topErrors, + (description.annotation + ? `Errors validating template "${description.annotation}" description` + : `Errors validating template description`)); + } + + return true; } - set content(_value) { - throw new Error(`Template content can't be changed after constructed`); + slot(slotName, value) { + this.setSlot(slotName, value); + return this; } - get content() { - return this.#tag.content; + slots(slotNamesToValues) { + this.setSlots(slotNamesToValues); + return this; } - toString() { - return this.content.toString(); + setSlot(slotName, value) { + const description = this.#getSlotDescriptionOrError(slotName); + + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = + (this.description.annotation + ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}` + : `Error validating template slot "${slotName}" value: ${error.message}`); + throw error; + } + + this.#slotValues[slotName] = value; } -} -function getTopOfCallerTrace() { - const error = new Error(); - return error.stack.split('\n') - .find(line => line.includes('at ') && !line.includes('/util/html.js')) - .replace('at ', '') - .trim(); -} + setSlots(slotNamesToValues) { + if ( + typeof slotNamesToValues !== 'object' || + Array.isArray(slotNamesToValues) || + slotNamesToValues === null + ) { + throw new TypeError(`Expected object mapping of slot names to values`); + } -export class Slot { - #defaultTag = new Tag(); - #handleContent = null; + const slotErrors = []; - #stackIdentifier = ''; - #stackTrace = ''; + for (const [slotName, value] of Object.entries(slotNamesToValues)) { + const description = this.#getSlotDescriptionNoError(slotName); + if (!description) { + slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`)); + continue; + } - constructor(template, slotName, defaultContentOrHandleContent) { - if (!template) { - throw new Error(`Expected template`); + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = `(${slotName}) ${error.message}`; + slotErrors.push(error); + } } - if (typeof slotName !== 'string') { - throw new Error(`Expected slotName to be string, got ${slotName}`); + if (!empty(slotErrors)) { + throw new AggregateError(slotErrors, + (this.description.annotation + ? `Error validating template "${this.description.annotation}" slots` + : `Error validating template slots`)); } - this.template = template; - this.slotName = slotName; + Object.assign(this.#slotValues, slotNamesToValues); + } - this.#setupStackMutation(); + static validateSlotValueAgainstDescription(value, description) { + if (value === undefined) { + throw new TypeError(`Specify value as null or don't specify at all`); + } - if (typeof defaultContentOrHandleContent === 'function') { - this.#handleContent = defaultContentOrHandleContent; - } else { - this.defaultContent = defaultContentOrHandleContent; + // Null is always an acceptable slot value. + if (value !== null) { + if ('validate' in description) { + description.validate({ + ...commonValidators, + ...validators, + })(value); + } + + if ('type' in description) { + const {type} = description; + if (type === 'html') { + if (!isHTML(value)) { + throw new TypeError(`Slot expects html (tag, template or blank), got ${value}`); + } + } else { + if (typeof value !== type) { + throw new TypeError(`Slot expects ${type}, got ${value}`); + } + } + } } + + return true; } - #setupStackMutation() { - // Here be dragons. + getSlotValue(slotName) { + const description = this.#getSlotDescriptionOrError(slotName); + const providedValue = this.#slotValues[slotName] ?? null; + + if (description.type === 'html') { + if (!providedValue) { + return blank(); + } + + return providedValue; + } - this.#stackIdentifier = `Slot.valueOf:${Math.floor(10000000 * Math.random())}`; + if (providedValue) { + return providedValue; + } - this.valueOf = () => this.constructor.prototype.valueOf.apply(this); - Object.defineProperty(this.valueOf, 'name', { - value: this.#stackIdentifier, - }); + if ('default' in description) { + return description.default; + } - this.#stackTrace = getTopOfCallerTrace(); + return null; } - #mutateStack(error) { - // Splice the line marked with #stackIdentifier with a more descriptive message, - // and erase the line above as well because it's the trace for the constructor's - // valueOf(). - const lines = error.stack.split('\n'); - const index = lines.findIndex(line => line.includes(`at ${this.#stackIdentifier}`)) - const setTrace = this.template.getSlotTrace(this.slotName); - lines.splice( - index - 1, 2, - `at Slot("${this.slotName}") (from ${this.#stackTrace})`, - (setTrace - ? `at …set from ${setTrace}` - : `at …left unset`)); - error.stack = lines.join('\n'); + getSlotDescription(slotName) { + return this.#getSlotDescriptionOrError(slotName); } - set defaultContent(value) { - this.#defaultTag.content = value; + #getSlotDescriptionNoError(slotName) { + if (this.#description.slots) { + if (Object.hasOwn(this.#description.slots, slotName)) { + return this.#description.slots[slotName]; + } + } + + return null; } - get defaultContent() { - return this.#defaultTag.content; + #getSlotDescriptionOrError(slotName) { + const description = this.#getSlotDescriptionNoError(slotName); + + if (!description) { + throw new TypeError( + (this.description.annotation + ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot` + : `Template doesn't have a "${slotName}" slot`)); + } + + return description; } - set content(value) { - // Content is stored on the template rather than the slot itself so that - // a given slot name can be reused (i.e. two slots can share a name and - // will be filled with the same value). - this.template.setSlot(this.slotName, value); + set content(_value) { + throw new Error(`Template content can't be changed after constructed`); } get content() { - const contentTag = this.template.getSlot(this.slotName); - return contentTag?.content ?? this.#defaultTag.content; + const slots = {}; + + for (const slotName of Object.keys(this.description.slots ?? {})) { + slots[slotName] = this.getSlotValue(slotName); + } + + return this.description.content(slots); } - toString() { - return this.valueOf().toString(); + set description(_value) { + throw new Error(`Template description can't be changed after constructed`); } - valueOf() { - try { - if (this.#handleContent) { - const result = this.#handleContent(this.content); - if (result === null || result === undefined) { - throw new Error(`Expected function for slot ${this.slotName} to return a value, got ${result}`); - } - return result.valueOf(); - } else { - return this.content.valueOf(); - } - } catch (error) { - this.#mutateStack(error); - throw error; - } + get description() { + return this.#description; + } + + toString() { + return this.content.toString(); } } diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 93980929..129ac9ab 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -75,7 +75,16 @@ export async function go({ developersComment, getSizeOfAdditionalFile, getSizeOfImageFile, + niceShowAggregate, }) { + const showError = (error) => { + if (error instanceof AggregateError && niceShowAggregate) { + niceShowAggregate(error); + } else { + console.error(error); + } + }; + const host = cliOptions['host'] ?? defaultHost; const port = parseInt(cliOptions['port'] ?? defaultPort); @@ -160,7 +169,7 @@ export async function go({ response.writeHead(500, contentTypeJSON); response.end({error: `Internal error serializing wiki JSON`}); console.error(`${requestHead} [500] /data.json`); - console.error(error); + showError(error); } return; } @@ -203,7 +212,7 @@ export async function go({ response.writeHead(500, contentTypePlain); response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`); console.error(`${requestHead} [500] ${pathname}`); - console.error(error); + showError(error); } return; } @@ -256,7 +265,7 @@ export async function go({ response.writeHead(500, contentTypePlain); response.end(`Failed during file-to-response pipeline`); console.error(`${requestHead} [500] ${pathname}`); - console.error(error); + showError(error); } return; } @@ -463,7 +472,7 @@ export async function go({ response.writeHead(500, contentTypePlain); response.end(`Error generating page, view server log for details\n`); console.error(`${requestHead} [500] ${pathname}`); - console.error(error); + showError(error); } }); @@ -479,7 +488,7 @@ export async function go({ }, 10_000); } else { console.error(`Server error detected (code: ${error.code})`); - console.error(error); + showError(error); } }); |