diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/content/dependencies/generateAbsoluteDatetimestamp.js | 103 | ||||
-rw-r--r-- | src/content/dependencies/generateCommentaryIndexPage.js | 78 | ||||
-rw-r--r-- | src/content/dependencies/generateCoverArtworkOriginDetails.js | 17 | ||||
-rw-r--r-- | src/content/dependencies/generateGroupInfoPageAlbumsListItem.js | 2 | ||||
-rw-r--r-- | src/content/dependencies/generateNewsEntryReadAnotherLinks.js | 10 | ||||
-rw-r--r-- | src/content/dependencies/generateRelativeDatetimestamp.js | 27 | ||||
-rw-r--r-- | src/content/dependencies/transformContent.js | 4 | ||||
-rw-r--r-- | src/data/language.js | 13 | ||||
-rw-r--r-- | src/data/things/language.js | 39 | ||||
-rw-r--r-- | src/html.js | 334 |
10 files changed, 389 insertions, 238 deletions
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(''', `'`) + .replaceAll('"', `"`); + + 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 { |