« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json12
-rw-r--r--package.json1
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js103
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js78
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js17
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js2
-rw-r--r--src/content/dependencies/generateNewsEntryReadAnotherLinks.js10
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js27
-rw-r--r--src/content/dependencies/transformContent.js4
-rw-r--r--src/data/language.js13
-rw-r--r--src/data/things/language.js39
-rw-r--r--src/html.js334
12 files changed, 402 insertions, 238 deletions
diff --git a/package-lock.json b/package-lock.json
index 3a4d23b4..7cd27198 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
                 "msgpackr": "1.11.1",
                 "rimraf": "5.0.7",
                 "striptags": "4.0.0-alpha.4",
+                "word-count": "0.3.1",
                 "word-wrap": "1.2.5"
             },
             "bin": {
@@ -5862,6 +5863,12 @@
                 "url": "https://github.com/chalk/strip-ansi?sponsor=1"
             }
         },
+        "node_modules/word-count": {
+            "version": "0.3.1",
+            "resolved": "https://registry.npmjs.org/word-count/-/word-count-0.3.1.tgz",
+            "integrity": "sha512-tTDKyKX1yy8FZWUw64TOgnMeLeXPZBA+ZunaCCpFRrGFeLSwSdEgSsFI/5DIJkiUfM7CkCREFPkZd9U4eLXgqA==",
+            "license": "MIT"
+        },
         "node_modules/word-wrap": {
             "version": "1.2.5",
             "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -9949,6 +9956,11 @@
                 }
             }
         },
+        "word-count": {
+            "version": "0.3.1",
+            "resolved": "https://registry.npmjs.org/word-count/-/word-count-0.3.1.tgz",
+            "integrity": "sha512-tTDKyKX1yy8FZWUw64TOgnMeLeXPZBA+ZunaCCpFRrGFeLSwSdEgSsFI/5DIJkiUfM7CkCREFPkZd9U4eLXgqA=="
+        },
         "word-wrap": {
             "version": "1.2.5",
             "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/package.json b/package.json
index c0567f31..bce87a58 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,7 @@
         "msgpackr": "1.11.1",
         "rimraf": "5.0.7",
         "striptags": "4.0.0-alpha.4",
+        "word-count": "0.3.1",
         "word-wrap": "1.2.5"
     },
     "license": "MIT",
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
index 2250ded3..d006374a 100644
--- a/src/content/dependencies/generateAbsoluteDatetimestamp.js
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -1,8 +1,12 @@
 export default {
-  data: (date) =>
-    ({date}),
+  data: (date, contextDate) => ({
+    date,
 
-  relations: (relation) => ({
+    contextDate:
+      contextDate ?? null,
+  }),
+
+  relations: (relation, _date, _contextDate) => ({
     template:
       relation('generateDatetimestampTemplate'),
 
@@ -12,35 +16,74 @@ export default {
 
   slots: {
     style: {
-      validate: v => v.is('full', 'year'),
+      validate: v => v.is(...[
+        'full',
+        'year',
+        'minimal-difference',
+        'year-difference',
+      ]),
       default: 'full',
     },
-
-    // Only has an effect for 'year' style.
-    tooltip: {
-      type: 'boolean',
-      default: false,
-    },
   },
 
-  generate: (data, relations, slots, {language}) =>
-    relations.template.slots({
-      mainContent:
-        (slots.style === 'full'
-          ? language.formatDate(data.date)
-       : slots.style === 'year'
-          ? data.date.getFullYear().toString()
-          : null),
-
-      tooltip:
-        slots.tooltip &&
-        slots.style === 'year' &&
-          relations.tooltip.slots({
-            content:
-              language.formatDate(data.date),
-          }),
-
-      datetime:
-        data.date.toISOString(),
-    }),
+  generate(data, relations, slots, {html, language}) {
+    if (!data.date) {
+      return html.blank();
+    }
+
+    relations.template.setSlots({
+      tooltip: relations.tooltip,
+      datetime: data.date.toISOString(),
+    });
+
+    let label = null;
+    let tooltip = null;
+
+    switch (slots.style) {
+      case 'full': {
+        label = language.formatDate(data.date);
+        break;
+      }
+
+      case 'year': {
+        label = language.formatYear(data.date);
+        tooltip = language.formatDate(data.date);
+        break;
+      }
+
+      case 'minimal-difference': {
+        if (data.date.toDateString() === data.contextDate?.toDateString()) {
+          return html.blank();
+        }
+
+        if (data.date.getFullYear() === data.contextDate?.getFullYear()) {
+          label = language.formatMonthDay(data.date);
+          tooltip = language.formatDate(data.date);
+        } else {
+          label = language.formatYear(data.date);
+          tooltip = language.formatDate(data.date);
+        }
+
+        break;
+      }
+
+      case 'year-difference': {
+        if (data.date.toDateString() === data.contextDate?.toDateString()) {
+          return html.blank();
+        }
+
+        if (data.date.getFullYear() === data.contextDate?.getFullYear()) {
+          label = language.formatDate(data.date);
+        } else {
+          label = language.formatYear(data.date);
+          tooltip = language.formatDate(data.date);
+        }
+      }
+    }
+
+    relations.template.setSlot('mainContent', label);
+    relations.tooltip.setSlot('content', tooltip);
+
+    return relations.template;
+  },
 };
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 4da3ecb9..8cc30913 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -1,10 +1,11 @@
+import multilingualWordCount from 'word-count';
+
 import {sortChronologically} from '#sort';
 import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  sprawl({albumData}) {
-    return {albumData};
-  },
+  sprawl: ({albumData}) =>
+    ({albumData}),
 
   query(sprawl) {
     const query = {};
@@ -18,44 +19,52 @@ export default {
           .filter(({commentary}) => commentary)
           .flatMap(({commentary}) => commentary));
 
-    query.wordCounts =
-      entries.map(entries =>
-        accumulateSum(
-          entries,
-          entry => entry.body.split(' ').length));
+    query.bodies =
+      entries.map(entries => entries.map(entry => entry.body));
 
     query.entryCounts =
       entries.map(entries => entries.length);
 
-    filterMultipleArrays(query.albums, query.wordCounts, query.entryCounts,
-      (album, wordCount, entryCount) => entryCount >= 1);
+    filterMultipleArrays(query.albums, query.bodies, query.entryCounts,
+      (album, bodies, entryCount) => entryCount >= 1);
 
     return query;
   },
 
-  relations(relation, query) {
-    return {
-      layout:
-        relation('generatePageLayout'),
+  relations: (relation, query) => ({
+    layout:
+      relation('generatePageLayout'),
 
-      albumLinks:
-        query.albums
-          .map(album => relation('linkAlbumCommentary', album)),
-    };
-  },
+    albumLinks:
+      query.albums
+        .map(album => relation('linkAlbumCommentary', album)),
 
-  data(query) {
-    return {
-      wordCounts: query.wordCounts,
-      entryCounts: query.entryCounts,
+    albumBodies:
+      query.bodies
+        .map(bodies => bodies
+          .map(body => relation('transformContent', body))),
+  }),
 
-      totalWordCount: accumulateSum(query.wordCounts),
-      totalEntryCount: accumulateSum(query.entryCounts),
-    };
-  },
+  data: (query) => ({
+    entryCounts: query.entryCounts,
+    totalEntryCount: accumulateSum(query.entryCounts),
+  }),
 
-  generate: (data, relations, {html, language}) =>
-    language.encapsulate('commentaryIndex', pageCapsule =>
+  generate(data, relations, {html, language}) {
+    const wordCounts =
+      relations.albumBodies.map(bodies =>
+        accumulateSum(bodies, body =>
+          multilingualWordCount(
+            html.resolve(
+              body.slot('mode', 'multiline'),
+              {normalize: 'plain'}))));
+
+    const totalWordCount =
+      accumulateSum(wordCounts);
+
+    const {entryCounts, totalEntryCount} = data;
+
+    return language.encapsulate('commentaryIndex', pageCapsule =>
       relations.layout.slots({
         title: language.$(pageCapsule, 'title'),
 
@@ -66,11 +75,11 @@ export default {
           html.tag('p', language.$(pageCapsule, 'infoLine', {
             words:
               html.tag('b',
-                language.formatWordCount(data.totalWordCount, {unit: true})),
+                language.formatWordCount(totalWordCount, {unit: true})),
 
             entries:
               html.tag('b',
-                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+                language.countCommentaryEntries(totalEntryCount, {unit: true})),
           })),
 
           language.encapsulate(pageCapsule, 'albumList', listCapsule => [
@@ -80,8 +89,8 @@ export default {
             html.tag('ul',
               stitchArrays({
                 albumLink: relations.albumLinks,
-                wordCount: data.wordCounts,
-                entryCount: data.entryCounts,
+                wordCount: wordCounts,
+                entryCount: entryCounts,
               }).map(({albumLink, wordCount, entryCount}) =>
                 html.tag('li',
                   language.$(listCapsule, 'item', {
@@ -97,5 +106,6 @@ export default {
           {auto: 'home'},
           {auto: 'current'},
         ],
-      })),
+      }));
+  },
 };
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index db18e9e4..e489eea6 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -24,9 +24,9 @@ export default {
         : null),
 
     datetimestamp:
-      (artwork.date && artwork.date !== artwork.thing.date
-        ? relation('generateAbsoluteDatetimestamp', artwork.date)
-        : null),
+      relation('generateAbsoluteDatetimestamp',
+        artwork.date,
+        artwork.thing.date),
   }),
 
 
@@ -53,10 +53,7 @@ export default {
         {class: 'origin-details'},
 
         (() => {
-          relations.datetimestamp?.setSlots({
-            style: 'year',
-            tooltip: true,
-          });
+          relations.datetimestamp.setSlot('style', 'year-difference');
 
           const artworkBy =
             language.encapsulate(capsule, 'artworkBy', workingCapsule => {
@@ -67,7 +64,7 @@ export default {
                 workingOptions.label = data.label;
               }
 
-              if (relations.datetimestamp) {
+              if (!html.isBlank(relations.datetimestamp)) {
                 workingCapsule += '.withYear';
                 workingOptions.year = relations.datetimestamp;
               }
@@ -108,7 +105,7 @@ export default {
                 workingOptions.label = data.label;
               }
 
-              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+              if (html.isBlank(artworkBy) && !html.isBlank(relations.datetimestamp)) {
                 workingCapsule += '.withYear';
                 workingOptions.year = relations.datetimestamp;
               }
@@ -125,7 +122,7 @@ export default {
                 label: data.label,
               };
 
-              if (relations.datetimestamp) {
+              if (!html.isBlank(relations.datetimestamp)) {
                 workingCapsule += '.withYear';
                 workingOptions.year = relations.datetimestamp;
               }
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 09b0a542..1211dfb8 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -66,7 +66,7 @@ export default {
             workingOptions.yearAccent =
               language.$(yearCapsule, 'accent', {
                 year:
-                  relations.datetimestamp.slots({style: 'year', tooltip: true}),
+                  relations.datetimestamp.slot('style', 'year'),
               });
           }
 
diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
index a985742b..1f6ee6d4 100644
--- a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
+++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
@@ -49,10 +49,7 @@ export default {
       if (relations.previousEntryDatetimestamp) {
         parts.push('withDate');
         options.date =
-          relations.previousEntryDatetimestamp.slots({
-            style: 'full',
-            tooltip: true,
-          });
+          relations.previousEntryDatetimestamp.slot('style', 'full');
       }
 
       entryLines.push(language.$(...parts, options));
@@ -67,10 +64,7 @@ export default {
       if (relations.nextEntryDatetimestamp) {
         parts.push('withDate');
         options.date =
-          relations.nextEntryDatetimestamp.slots({
-            style: 'full',
-            tooltip: true,
-          });
+          relations.nextEntryDatetimestamp.slot('style', 'full');
       }
 
       entryLines.push(language.$(...parts, options));
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
index b3fe6239..1415564e 100644
--- a/src/content/dependencies/generateRelativeDatetimestamp.js
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -20,19 +20,11 @@ export default {
       validate: v => v.is('full', 'year'),
       default: 'full',
     },
-
-    tooltip: {
-      type: 'boolean',
-      default: false,
-    },
   },
 
   generate(data, relations, slots, {language}) {
     if (data.equal) {
-      return relations.fallback.slots({
-        style: slots.style,
-        tooltip: slots.tooltip,
-      });
+      return relations.fallback.slot('style', slots.style);
     }
 
     return relations.template.slots({
@@ -44,15 +36,14 @@ export default {
           : null),
 
       tooltip:
-        slots.tooltip &&
-          relations.tooltip.slots({
-            content:
-              language.formatRelativeDate(data.currentDate, data.referenceDate, {
-                considerRoundingDays: true,
-                approximate: true,
-                absolute: slots.style === 'year',
-              }),
-          }),
+        relations.tooltip.slots({
+          content:
+            language.formatRelativeDate(data.currentDate, data.referenceDate, {
+              considerRoundingDays: true,
+              approximate: true,
+              absolute: slots.style === 'year',
+            }),
+        }),
 
       datetime:
         data.currentDate.toISOString(),
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 73452cfa..db9f5d99 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -35,8 +35,8 @@ const inlineMarked = new Marked({
   ...commonMarkedOptions,
 
   renderer: {
-    paragraph(text) {
-      return text;
+    paragraph({tokens}) {
+      return this.parser.parseInline(tokens);
     },
   },
 });
diff --git a/src/data/language.js b/src/data/language.js
index 8dc06e7e..e97267c0 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -8,7 +8,6 @@ import yaml from 'js-yaml';
 
 import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
   from '#aggregate';
-import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
 import {empty, splitKeys, withEntries} from '#sugar';
 import T from '#things';
@@ -247,16 +246,8 @@ async function processLanguageSpecFromFile(file, processLanguageSpecOpts) {
   }
 }
 
-export function initializeLanguageObject() {
-  const language = new Language();
-
-  language.externalLinkSpec = externalLinkSpec;
-
-  return language;
-}
-
 export async function processLanguageFile(file) {
-  const language = initializeLanguageObject();
+  const language = new Language()
   const properties = await processLanguageSpecFromFile(file);
   return Object.assign(language, properties);
 }
@@ -267,7 +258,7 @@ export function watchLanguageFile(file, {
   const basename = path.basename(file);
 
   const events = new EventEmitter();
-  const language = initializeLanguageObject();
+  const language = new Language();
 
   let emittedReady = false;
   let successfullyAppliedLanguage = false;
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 997cf31e..91774761 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -4,16 +4,16 @@ import {withAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
 import {input} from '#composite';
 import * as html from '#html';
-import {empty, withEntries} from '#sugar';
+import {accumulateSum, empty, withEntries} from '#sugar';
 import {isLanguageCode} from '#validators';
 import Thing from '#thing';
 import {languageOptionRegex} from '#wiki-data';
 
 import {
+  externalLinkSpec,
   getExternalLinkStringOfStyleFromDescriptors,
   getExternalLinkStringsFromDescriptors,
   isExternalLinkContext,
-  isExternalLinkSpec,
   isExternalLinkStyle,
 } from '#external-links';
 
@@ -82,13 +82,6 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
-    // List of descriptors for providing to external link utilities when using
-    // language.formatExternalLink - refer to #external-links for info.
-    externalLinkSpec: {
-      flags: {update: true, expose: true},
-      update: {validate: isExternalLinkSpec},
-    },
-
     // Expose only
 
     isLanguage: [
@@ -106,12 +99,14 @@ export class Language extends Thing {
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
     intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
+    intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
     intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}),
     intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}),
     intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+    intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}),
 
     validKeys: {
       flags: {expose: true},
@@ -169,6 +164,15 @@ export class Language extends Thing {
     }
   }
 
+  countWords(text) {
+    this.assertIntlAvailable('intl_wordSegmenter');
+
+    const string = html.resolve(text, {normalize: 'plain'});
+    const segments = this.intl_wordSegmenter.segment(string);
+
+    return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0);
+  }
+
   getUnitForm(value) {
     this.assertIntlAvailable('intl_pluralCardinal');
     return this.intl_pluralCardinal.select(value);
@@ -470,6 +474,15 @@ export class Language extends Thing {
     return this.intl_dateYear.format(date);
   }
 
+  formatMonthDay(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateMonthDay');
+    return this.intl_dateMonthDay.format(date);
+  }
+
   formatYearRange(startDate, endDate) {
     // formatYearRange expects both values to be present, but if both are null
     // or both are undefined, that's just blank content.
@@ -648,10 +661,6 @@ export class Language extends Thing {
     style = 'platform',
     context = 'generic',
   } = {}) {
-    if (!this.externalLinkSpec) {
-      throw new TypeError(`externalLinkSpec unavailable`);
-    }
-
     // Null or undefined url is blank content.
     if (url === null || url === undefined) {
       return html.blank();
@@ -660,7 +669,7 @@ export class Language extends Thing {
     isExternalLinkContext(context);
 
     if (style === 'all') {
-      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+      return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, {
         language: this,
         context,
       });
@@ -669,7 +678,7 @@ export class Language extends Thing {
     isExternalLinkStyle(style);
 
     const result =
-      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, {
         language: this,
         context,
       });
diff --git a/src/html.js b/src/html.js
index 0a868ebd..444edd6a 100644
--- a/src/html.js
+++ b/src/html.js
@@ -2,6 +2,8 @@
 
 import {inspect} from 'node:util';
 
+import striptags from 'striptags';
+
 import {withAggregate} from '#aggregate';
 import {colors} from '#cli';
 import {empty, typeAppearance, unique} from '#sugar';
@@ -39,6 +41,40 @@ export const selfClosingTags = [
   'wbr',
 ];
 
+// Every element under:
+// https://html.spec.whatwg.org/multipage/text-level-semantics.html
+export const textLevelSemanticTags = [
+  'a',
+  'abbr',
+  'b',
+  'bdi',
+  'bdo',
+  'br',
+  'cite',
+  'code',
+  'data',
+  'dfn',
+  'em',
+  'i',
+  'kbd',
+  'mark',
+  'q',
+  'rp',
+  'rt',
+  'ruby',
+  's',
+  'samp',
+  'small',
+  'span',
+  'strong',
+  'sub',
+  'sup',
+  'time',
+  'u',
+  'var',
+  'wbr',
+];
+
 // Not so comprehensive!!
 export const attributeSpec = {
   'class': {
@@ -469,6 +505,7 @@ export class Tag {
 
     this.#content = contentArray;
     this.#content.toString = () => this.#stringifyContent();
+    this.#content.toPlainText = () => this.#plainifyContent();
   }
 
   get content() {
@@ -677,6 +714,10 @@ export class Tag {
         : '\n'));
   }
 
+  toPlainText() {
+    return this.content.toPlainText();
+  }
+
   #getContentJoiner() {
     if (this.joinChildren === undefined) {
       return '\n';
@@ -696,11 +737,8 @@ export class Tag {
 
     const joiner = this.#getContentJoiner();
 
-    let content = '';
     let blockwrapClosers = '';
 
-    let seenSiblingIndependentContent = false;
-
     const chunkwrapSplitter =
       (this.chunkwrap
         ? this.#getAttributeRaw('split')
@@ -711,110 +749,64 @@ export class Tag {
         ? false
         : null);
 
-    let contentItems;
-
-    determineContentItems: {
-      if (this.chunkwrap) {
-        contentItems = smush(this).content;
-        break determineContentItems;
-      }
-
-      contentItems = this.content;
-    }
-
-    for (const [index, item] of contentItems.entries()) {
-      const nonTemplateItem =
-        Template.resolve(item);
-
-      if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) {
-        seenSiblingIndependentContent = true;
-        continue;
-      }
+    const contentItems =
+      (this.chunkwrap
+        ? smush(this).content
+        : this.content);
+
+    let content = this.#renderContentItems({
+      from: '',
+      items: contentItems,
+
+      getItemContent: item => item.toString(),
+
+      appendItemContent(content, itemContent, item) {
+        const chunkwrapChunks =
+          (typeof item === 'string' && chunkwrapSplitter
+            ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter))
+            : null);
+
+        const itemIncludesChunkwrapSplit =
+          (chunkwrapChunks
+            ? chunkwrapChunks.length > 1
+            : null);
+
+        if (content) {
+          if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) {
+            // The first time we see a chunkwrap splitter, backtrack and wrap
+            // the content *so far* in a chunk. This will be treated just like
+            // any other open chunkwrap, and closed after the first chunk of
+            // this item! (That means the existing content is part of the same
+            // chunk as the first chunk included in this content, which makes
+            // sense, because that first chink is really just more text that
+            // precedes the first split.)
+            content = `<span class="chunkwrap">` + content;
+          }
 
-      let itemContent;
-      try {
-        itemContent = nonTemplateItem.toString();
-      } catch (caughtError) {
-        const indexPart = colors.yellow(`child #${index + 1}`);
-
-        const error =
-          new Error(
-            `Error in ${indexPart} ` +
-            `of ${inspect(this, {compact: true})}`,
-            {cause: caughtError});
-
-        if (this.#traceError && !disabledTagTracing) {
-          error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-          error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
-
-          error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
-            /content-function\.js/,
-            /util\/html\.js/,
-          ];
-
-          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-            /content\/dependencies\/(.*\.js:.*(?=\)))/,
-          ];
+          content += joiner;
+        } else if (itemIncludesChunkwrapSplit) {
+          // We've encountered a chunkwrap split before any other content.
+          // This means there's no content to wrap, no existing chunkwrap
+          // to close, and no reason to add a joiner, but we *do* need to
+          // enter a chunkwrap wrapper *now*, so the first chunk of this
+          // item will be properly wrapped.
+          content = `<span class="chunkwrap">`;
         }
 
-        throw error;
-      }
-
-      if (!itemContent) {
-        continue;
-      }
-
-      if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) {
-        seenSiblingIndependentContent = true;
-      }
-
-      const chunkwrapChunks =
-        (typeof nonTemplateItem === 'string' && chunkwrapSplitter
-          ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter))
-          : null);
-
-      const itemIncludesChunkwrapSplit =
-        (chunkwrapChunks
-          ? chunkwrapChunks.length > 1
-          : null);
-
-      if (content) {
-        if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) {
-          // The first time we see a chunkwrap splitter, backtrack and wrap
-          // the content *so far* in a chunk. This will be treated just like
-          // any other open chunkwrap, and closed after the first chunk of
-          // this item! (That means the existing content is part of the same
-          // chunk as the first chunk included in this content, which makes
-          // sense, because that first chink is really just more text that
-          // precedes the first split.)
-          content = `<span class="chunkwrap">` + content;
+        if (itemIncludesChunkwrapSplit) {
+          seenChunkwrapSplitter = true;
         }
 
-        content += joiner;
-      } else if (itemIncludesChunkwrapSplit) {
-        // We've encountered a chunkwrap split before any other content.
-        // This means there's no content to wrap, no existing chunkwrap
-        // to close, and no reason to add a joiner, but we *do* need to
-        // enter a chunkwrap wrapper *now*, so the first chunk of this
-        // item will be properly wrapped.
-        content = `<span class="chunkwrap">`;
-      }
-
-      if (itemIncludesChunkwrapSplit) {
-        seenChunkwrapSplitter = true;
-      }
-
-      // Blockwraps only apply if they actually contain some content whose
-      // words should be kept together, so it's okay to put them beneath the
-      // itemContent check. They also never apply at the very start of content,
-      // because at that point there aren't any preceding words from which the
-      // blockwrap would differentiate its content.
-      if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) {
-        content += `<span class="blockwrap">`;
-        blockwrapClosers += `</span>`;
-      }
+        // Blockwraps only apply if they actually contain some content whose
+        // words should be kept together, so it's okay to put them beneath the
+        // itemContent check. They also never apply at the very start of content,
+        // because at that point there aren't any preceding words from which the
+        // blockwrap would differentiate its content.
+        if (item instanceof Tag && item.blockwrap && content) {
+          content += `<span class="blockwrap">`;
+          blockwrapClosers += `</span>`;
+        }
 
-      appendItemContent: {
         if (itemIncludesChunkwrapSplit) {
           for (const [index, {chunk, following}] of chunkwrapChunks.entries()) {
             if (index === 0) {
@@ -848,17 +840,15 @@ export class Tag {
             }
           }
 
-          break appendItemContent;
+          return content;
         }
 
-        content += itemContent;
-      }
-    }
+        return content += itemContent;
+      },
+    });
 
-    // If we've only seen sibling-dependent content (or just no content),
-    // then the content in total is blank.
-    if (!seenSiblingIndependentContent) {
-      return '';
+    if (!content.length) {
+      return content;
     }
 
     if (chunkwrapSplitter) {
@@ -878,6 +868,130 @@ export class Tag {
     return content;
   }
 
+  #plainifyContent() {
+    // Doesn't play too nice with transformContent, because that function,
+    // working with the Marked library to process markdown, returns a mix of
+    // raw HTML strings and actual tags - this function only makes nice line
+    // breaks out of actual tags.
+
+    if (this.selfClosing) {
+      return '';
+    }
+
+    let joiner = this.#getContentJoiner();
+
+    if (joiner instanceof Tag && joiner.tagName === 'br') {
+      joiner = '\n';
+    }
+
+    if (joiner === '\n') {
+      joiner = ' ';
+    }
+
+    let content = this.#renderContentItems({
+      from: '',
+      items: this.content,
+
+      getItemContent: item =>
+        (item instanceof Tag
+          ? item.toPlainText()
+          : item.toString()),
+
+      appendItemContent(content, itemContent, item) {
+        if (joiner === ' ') {
+          if (item instanceof Tag && !textLevelSemanticTags.includes(item.tagName)) {
+            content += '\n\n';
+          } else if (!content.endsWith(' ')) {
+            content += ' ';
+          }
+        } else {
+          content += joiner;
+        }
+
+        return content += itemContent;
+      },
+    });
+
+    content =
+      striptags(content)
+        .replaceAll('&#39;', `'`)
+        .replaceAll('&quot;', `"`);
+
+    return content;
+  }
+
+  #renderContentItems(config) {
+    let content = structuredClone(config.from);
+
+    let seenSiblingIndependentContent = false;
+
+    for (const [index, item] of config.items.entries()) {
+      const nonTemplateItem = Template.resolve(item);
+
+      if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) {
+        seenSiblingIndependentContent = true;
+        continue;
+      }
+
+      let itemContent;
+      try {
+        itemContent = config.getItemContent(nonTemplateItem);
+      } catch (caughtError) {
+        throw this.#annotateContentItemError(caughtError, index);
+      }
+
+      if (!itemContent) {
+        continue;
+      }
+
+      const previousLength = content.length;
+
+      content = config.appendItemContent(content, itemContent, nonTemplateItem);
+
+      if (content.length === previousLength) {
+        continue;
+      }
+
+      if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) {
+        seenSiblingIndependentContent = true;
+      }
+    }
+
+    // If we've only seen sibling-dependent content (or just no content),
+    // then the content in total is blank.
+    if (!seenSiblingIndependentContent) {
+      return config.from;
+    }
+
+    return content;
+  }
+
+  #annotateContentItemError(caughtError, index) {
+    const indexPart = colors.yellow(`child #${index + 1}`);
+
+    const error =
+      new Error(
+        `Error in ${indexPart} ` +
+        `of ${inspect(this, {compact: true})}`,
+        {cause: caughtError});
+
+    if (this.#traceError && !disabledTagTracing) {
+      error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
+      error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+
+      error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+        /content-function\.js/,
+        /util\/html\.js/,
+      ];
+
+      error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+        /content\/dependencies\/(.*\.js:.*(?=\)))/,
+      ];
+    }
+
+    return error;
+  }
+
   static normalize(content) {
     // Normalizes contents that are valid from an `isHTML` perspective so
     // that it's always a pure, single Tag object.
@@ -1534,6 +1648,8 @@ export function resolve(tagOrTemplate, {
     return Tag.normalize(tagOrTemplate);
   } else if (normalize === 'string') {
     return Tag.normalize(tagOrTemplate).toString();
+  } else if (normalize === 'plain') {
+    return Tag.normalize(tagOrTemplate).toPlainText();
   } else if (normalize) {
     throw new TypeError(`Expected normalize to be 'tag', 'string', or null`);
   } else {