« get me outta code hell

html: drastically simplify template/slot system - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-04-08 16:54:39 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-04-08 16:54:39 -0300
commit776abf8d697716902692f357c6f179c1e681369f (patch)
treea75e7f63ffc13121c3c08f1f78c10bc883b4d4e1 /src
parent0cbfa8c1b70080c7ec4eb352902cf76f8ef30fcf (diff)
html: drastically simplify template/slot system
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js67
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js24
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js16
-rw-r--r--src/content/dependencies/generateAlbumInfoPageContent.js27
-rw-r--r--src/content/dependencies/generateContentHeading.js26
-rw-r--r--src/content/dependencies/generateCoverArtwork.js49
-rw-r--r--src/content/dependencies/generatePageLayout.js65
-rw-r--r--src/content/dependencies/image.js76
-rw-r--r--src/content/dependencies/index.js10
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js6
-rw-r--r--src/content/dependencies/linkTemplate.js50
-rw-r--r--src/content/dependencies/linkThing.js42
-rw-r--r--src/data/things/homepage-layout.js4
-rw-r--r--src/data/things/validators.js42
-rwxr-xr-xsrc/upd8.js1
-rw-r--r--src/util/html.js469
-rw-r--r--src/write/build-modes/live-dev-server.js19
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);
     }
   });