diff options
-rw-r--r-- | src/content-function.js | 244 | ||||
-rw-r--r-- | src/content/dependencies/generateAlbumInfoPage.js | 303 | ||||
-rw-r--r-- | src/content/dependencies/generateAlbumInfoPageContent.js | 306 | ||||
-rw-r--r-- | src/content/dependencies/generatePageLayout.js | 2 | ||||
-rw-r--r-- | src/content/dependencies/generateReleaseInfoContributionsLine.js | 49 | ||||
-rw-r--r-- | src/content/dependencies/generateStickyHeadingContainer.js | 10 | ||||
-rw-r--r-- | src/content/dependencies/generateTrackCoverArtwork.js | 2 | ||||
-rw-r--r-- | src/content/dependencies/generateTrackInfoPage.js | 660 | ||||
-rw-r--r-- | src/content/dependencies/generateTrackInfoPageContent.js | 654 | ||||
-rw-r--r-- | src/content/dependencies/linkThing.js | 124 | ||||
-rw-r--r-- | src/static/client.js | 1 | ||||
-rw-r--r-- | src/util/html.js | 139 | ||||
-rw-r--r-- | src/util/sugar.js | 36 | ||||
-rw-r--r-- | test/unit/util/html.js | 28 |
14 files changed, 1337 insertions, 1221 deletions
diff --git a/src/content-function.js b/src/content-function.js index 73e4629e..d4cc3dbc 100644 --- a/src/content-function.js +++ b/src/content-function.js @@ -1,22 +1,62 @@ -import {annotateFunction, empty} from './util/sugar.js'; +import { + annotateFunction, + empty, + setIntersection, +} from './util/sugar.js'; export default function contentFunction({ contentDependencies = [], extraDependencies = [], + slots, sprawl, relations, data, generate, }) { + const expectedContentDependencyKeys = new Set(contentDependencies); + const expectedExtraDependencyKeys = new Set(extraDependencies); + + // Initial checks. These only need to be run once per description of a + // content function, and don't depend on any mutable context (e.g. which + // dependencies have been fulfilled so far). + + const overlappingContentExtraDependencyKeys = + setIntersection(expectedContentDependencyKeys, expectedExtraDependencyKeys); + + if (!empty(overlappingContentExtraDependencyKeys)) { + throw new Error(`Overlap in content and extra dependency keys: ${[...overlappingContentExtraDependencyKeys].join(', ')}`); + } + + if (!generate) { + throw new Error(`Expected generate function`); + } + + if (sprawl && !expectedExtraDependencyKeys.has('wikiData')) { + throw new Error(`Content functions which sprawl must specify wikiData in extraDependencies`); + } + + if (slots && !expectedExtraDependencyKeys.has('html')) { + throw new Error(`Content functions with slots must specify html in extraDependencies`); + } + + // Pass all the details to expectDependencies, which will recursively build + // up a set of fulfilled dependencies and make functions like `relations` + // and `generate` callable only with sufficient fulfilled dependencies. + return expectDependencies({ + slots, sprawl, relations, data, generate, - expectedContentDependencyKeys: contentDependencies, - expectedExtraDependencyKeys: extraDependencies, + expectedContentDependencyKeys, + expectedExtraDependencyKeys, + missingContentDependencyKeys: new Set(expectedContentDependencyKeys), + missingExtraDependencyKeys: new Set(expectedExtraDependencyKeys), + invalidatingDependencyKeys: new Set(), + fulfilledDependencyKeys: new Set(), fulfilledDependencies: {}, }); } @@ -24,6 +64,7 @@ export default function contentFunction({ contentFunction.identifyingSymbol = Symbol(`Is a content function?`); export function expectDependencies({ + slots, sprawl, relations, data, @@ -31,43 +72,39 @@ export function expectDependencies({ expectedContentDependencyKeys, expectedExtraDependencyKeys, + missingContentDependencyKeys, + missingExtraDependencyKeys, + invalidatingDependencyKeys, + fulfilledDependencyKeys, fulfilledDependencies, }) { - if (!generate) { - throw new Error(`Expected generate function`); - } - const hasSprawlFunction = !!sprawl; const hasRelationsFunction = !!relations; const hasDataFunction = !!data; + const hasSlotsDescription = !!slots; - if (hasSprawlFunction && !expectedExtraDependencyKeys.includes('wikiData')) { - throw new Error(`Content functions which sprawl must specify wikiData in extraDependencies`); - } - - const fulfilledDependencyKeys = Object.keys(fulfilledDependencies); - - const invalidatingDependencyKeys = Object.entries(fulfilledDependencies) - .filter(([key, value]) => value?.fulfilled === false) - .map(([key]) => key); - - const missingContentDependencyKeys = expectedContentDependencyKeys - .filter(key => !fulfilledDependencyKeys.includes(key)); - - const missingExtraDependencyKeys = expectedExtraDependencyKeys - .filter(key => !fulfilledDependencyKeys.includes(key)); + const isInvalidated = !empty(invalidatingDependencyKeys); + const isMissingContentDependencies = !empty(missingContentDependencyKeys); + const isMissingExtraDependencies = !empty(missingExtraDependencyKeys); let wrappedGenerate; - if (!empty(invalidatingDependencyKeys)) { + if (isInvalidated) { wrappedGenerate = function() { - throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${invalidatingDependencyKeys.join(', ')}`); + throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`); }; annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'}); wrappedGenerate.fulfilled = false; - } else if (empty(missingContentDependencyKeys) && empty(missingExtraDependencyKeys)) { - wrappedGenerate = function(arg1, arg2) { + } else if (isMissingContentDependencies || isMissingExtraDependencies) { + wrappedGenerate = function() { + throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`); + }; + + annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'}); + wrappedGenerate.fulfilled = false; + } else { + const callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => { if (hasDataFunction && !arg1) { throw new Error(`Expected data`); } @@ -81,27 +118,52 @@ export function expectDependencies({ } if (hasDataFunction && hasRelationsFunction) { - return generate(arg1, arg2, fulfilledDependencies); + return generate(arg1, arg2, ...extraArgs, fulfilledDependencies); } else if (hasDataFunction || hasRelationsFunction) { - return generate(arg1, fulfilledDependencies); + return generate(arg1, ...extraArgs, fulfilledDependencies); } else { - return generate(fulfilledDependencies); + return generate(...extraArgs, fulfilledDependencies); } }; - annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'}); - wrappedGenerate.fulfilled = true; + if (hasSlotsDescription) { + const stationery = fulfilledDependencies.html.stationery({ + annotation: generate.name, + + // These extra slots are for the data and relations (positional) args. + // No hacks to store them temporarily or otherwise "invisibly" alter + // the behavior of the template description's `content`, since that + // would be expressly against the purpose of templates! + slots: { + _cfArg1: {validate: v => v.isObject}, + _cfArg2: {validate: v => v.isObject}, + ...slots, + }, + + content(slots) { + const args = [slots._cfArg1, slots._cfArg2]; + return callUnderlyingGenerate(args, slots); + }, + }); + + wrappedGenerate = function(...args) { + return stationery.template().slots({ + _cfArg1: args[0] ?? null, + _cfArg2: args[1] ?? null, + }); + }; + } else { + wrappedGenerate = function(...args) { + return callUnderlyingGenerate(args); + }; + } wrappedGenerate.fulfill = function() { - throw new Error(`All dependencies already fulfilled`); - }; - } else { - wrappedGenerate = function() { - throw new Error(`Dependencies still needed: ${missingContentDependencyKeys.concat(missingExtraDependencyKeys).join(', ')}`); + throw new Error(`All dependencies already fulfilled (${generate.name})`); }; - annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'}); - wrappedGenerate.fulfilled = false; + annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'}); + wrappedGenerate.fulfilled = true; } wrappedGenerate[contentFunction.identifyingSymbol] = true; @@ -119,7 +181,31 @@ export function expectDependencies({ } wrappedGenerate.fulfill ??= function fulfill(dependencies) { + // To avoid unneeded destructuring, `fullfillDependencies` is a mutating + // function. But `fulfill` itself isn't meant to mutate! We create a copy + // of these variables, so their original values are kept for additional + // calls to this same `fulfill`. + const newlyMissingContentDependencyKeys = new Set(missingContentDependencyKeys); + const newlyMissingExtraDependencyKeys = new Set(missingExtraDependencyKeys); + const newlyInvalidatingDependencyKeys = new Set(invalidatingDependencyKeys); + const newlyFulfilledDependencyKeys = new Set(fulfilledDependencyKeys); + const newlyFulfilledDependencies = {...fulfilledDependencies}; + + try { + fulfillDependencies(dependencies, { + missingContentDependencyKeys: newlyMissingContentDependencyKeys, + missingExtraDependencyKeys: newlyMissingExtraDependencyKeys, + invalidatingDependencyKeys: newlyInvalidatingDependencyKeys, + fulfilledDependencyKeys: newlyFulfilledDependencyKeys, + fulfilledDependencies: newlyFulfilledDependencies, + }); + } catch (error) { + error.message += ` (${generate.name})`; + throw error; + } + return expectDependencies({ + slots, sprawl, relations, data, @@ -127,16 +213,13 @@ export function expectDependencies({ expectedContentDependencyKeys, expectedExtraDependencyKeys, - - fulfilledDependencies: fulfillDependencies({ - name: generate.name, - dependencies, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - fulfilledDependencies, - }), + missingContentDependencyKeys: newlyMissingContentDependencyKeys, + missingExtraDependencyKeys: newlyMissingExtraDependencyKeys, + invalidatingDependencyKeys: newlyInvalidatingDependencyKeys, + fulfilledDependencyKeys: newlyFulfilledDependencyKeys, + fulfilledDependencies: newlyFulfilledDependencies, }); + }; Object.assign(wrappedGenerate, { @@ -147,63 +230,72 @@ export function expectDependencies({ return wrappedGenerate; } -export function fulfillDependencies({ - name, - dependencies, - expectedContentDependencyKeys, - expectedExtraDependencyKeys, +export function fulfillDependencies(dependencies, { + missingContentDependencyKeys, + missingExtraDependencyKeys, + invalidatingDependencyKeys, + fulfilledDependencyKeys, fulfilledDependencies, }) { - const newFulfilledDependencies = {...fulfilledDependencies}; - const fulfilledDependencyKeys = Object.keys(fulfilledDependencies); + // This is a mutating function. Be aware: it WILL mutate the provided sets + // and objects EVEN IF there are errors. This function doesn't exit early, + // so all provided dependencies which don't have an associated error should + // be treated as fulfilled (this is reflected via fulfilledDependencyKeys). const errors = []; - let bail = false; for (let [key, value] of Object.entries(dependencies)) { - if (fulfilledDependencyKeys.includes(key)) { + if (fulfilledDependencyKeys.has(key)) { errors.push(new Error(`Dependency ${key} is already fulfilled`)); - bail = true; continue; } - const isContentKey = expectedContentDependencyKeys.includes(key); - const isExtraKey = expectedExtraDependencyKeys.includes(key); + const isContentKey = missingContentDependencyKeys.has(key); + const isExtraKey = missingExtraDependencyKeys.has(key); if (!isContentKey && !isExtraKey) { errors.push(new Error(`Dependency ${key} is not expected`)); - bail = true; continue; } if (value === undefined) { errors.push(new Error(`Dependency ${key} was provided undefined`)); - bail = true; continue; } - if (isContentKey && !value?.[contentFunction.identifyingSymbol]) { - errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`)); - bail = true; - continue; - } + const isContentFunction = + !!value?.[contentFunction.identifyingSymbol]; - if (isExtraKey && value?.[contentFunction.identifyingSymbol]) { - errors.push(new Error(`Extra dependency ${key} is a content function`)); - bail = true; - continue; - } + const isFulfilledContentFunction = + isContentFunction && value.fulfilled; - if (!bail) { - newFulfilledDependencies[key] = value; + if (isContentKey) { + if (!isContentFunction) { + errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`)); + continue; + } + + if (!isFulfilledContentFunction) { + invalidatingDependencyKeys.add(key); + } + + missingContentDependencyKeys.delete(key); + } else if (isExtraKey) { + if (isContentFunction) { + errors.push(new Error(`Extra dependency ${key} is a content function`)); + continue; + } + + missingExtraDependencyKeys.delete(key); } + + fulfilledDependencyKeys.add(key); + fulfilledDependencies[key] = value; } if (!empty(errors)) { - throw new AggregateError(errors, `Errors fulfilling dependencies for ${name}`); + throw new AggregateError(errors, `Errors fulfilling dependencies`); } - - return newFulfilledDependencies; } export function getRelationsTree(dependencies, contentFunctionName, wikiData, ...args) { diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index 749dd2af..e317adb1 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -1,28 +1,51 @@ import getChronologyRelations from '../util/getChronologyRelations.js'; import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; +import {accumulateSum, empty} from '../../util/sugar.js'; export default { contentDependencies: [ - 'generateAlbumInfoPageContent', + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', + 'generateAlbumCoverArtwork', 'generateAlbumNavAccent', 'generateAlbumSidebar', 'generateAlbumSocialEmbed', 'generateAlbumStyleRules', + 'generateAlbumTrackList', 'generateChronologyLinks', 'generateColorStyleRules', + 'generateContentHeading', 'generatePageLayout', + 'generateReleaseInfoContributionsLine', 'linkAlbum', + 'linkAlbumCommentary', + 'linkAlbumGallery', 'linkArtist', + 'linkExternal', 'linkTrack', + 'transformContent', ], - extraDependencies: ['language'], + extraDependencies: ['html', 'language'], relations(relation, album) { - return { - layout: relation('generatePageLayout'), + const relations = {}; + const sections = relations.sections = {}; - coverArtistChronologyContributions: getChronologyRelations(album, { + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', album); + + relations.colorStyleRules = + relation('generateColorStyleRules', album.color); + + relations.socialEmbed = + relation('generateAlbumSocialEmbed', album); + + relations.coverArtistChronologyContributions = + getChronologyRelations(album, { contributions: album.coverArtistContribs, linkArtist: artist => relation('linkArtist', artist), @@ -37,26 +60,124 @@ export default { ...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist, ]), - }), + }); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', album, null); + + relations.chronologyLinks = + relation('generateChronologyLinks'); + + relations.sidebar = + relation('generateAlbumSidebar', album, null); + + if (album.hasCoverArt) { + relations.cover = + relation('generateAlbumCoverArtwork', album); + } + + // Section: Release info + + const releaseInfo = sections.releaseInfo = {}; + + releaseInfo.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.artistContribs); + + releaseInfo.coverArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.coverArtistContribs); + + releaseInfo.wallpaperArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs); + + releaseInfo.bannerArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs); + + // Section: Listen on + + if (!empty(album.urls)) { + const listen = sections.listen = {}; + + listen.externalLinks = + album.urls.map(url => + relation('linkExternal', url, {type: 'album'})); + } + + // Section: Extra links + + const extra = sections.extra = {}; + + if (album.tracks.some(t => t.hasUniqueCoverArt)) { + extra.galleryLink = + relation('linkAlbumGallery', album); + } + + if (album.commentary || album.tracks.some(t => t.commentary)) { + extra.commentaryLink = + relation('linkAlbumCommentary', album); + } + + if (!empty(album.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', album.additionalFiles); + } + + // Section: Track list + + relations.trackList = + relation('generateAlbumTrackList', album); + + // Section: Additional files + + if (!empty(album.additionalFiles)) { + const additionalFiles = sections.additionalFiles = {}; + + additionalFiles.heading = + relation('generateContentHeading'); + + additionalFiles.additionalFilesList = + relation('generateAlbumAdditionalFilesList', album, album.additionalFiles); + } + + // Section: Artist commentary + + if (album.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); - albumNavAccent: relation('generateAlbumNavAccent', album, null), - chronologyLinks: relation('generateChronologyLinks'), + artistCommentary.content = + relation('transformContent', album.commentary); + } - content: relation('generateAlbumInfoPageContent', album), - sidebar: relation('generateAlbumSidebar', album, null), - socialEmbed: relation('generateAlbumSocialEmbed', album), - albumStyleRules: relation('generateAlbumStyleRules', album), - colorStyleRules: relation('generateColorStyleRules', album.color), - }; + return relations; }, data(album) { - return { - name: album.name, - }; + const data = {}; + + data.name = album.name; + data.date = album.date; + + data.duration = accumulateSum(album.tracks, track => track.duration); + data.durationApproximate = album.tracks.length > 1; + + if (album.coverArtDate && +album.coverArtDate !== +album.date) { + data.coverArtDate = album.coverArtDate; + } + + if (!empty(album.additionalFiles)) { + data.numAdditionalFiles = album.additionalFiles.length; + } + + data.dateAddedToWiki = album.dateAddedToWiki; + + return data; }, - generate(data, relations, {language}) { + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + return relations.layout .slots({ title: language.$('albumPage.title', {album: data.name}), @@ -65,8 +186,130 @@ export default { colorStyleRules: [relations.colorStyleRules], additionalStyleRules: [relations.albumStyleRules], - cover: relations.content.cover, - mainContent: relations.content.main.content, + cover: + (relations.cover + ? relations.cover.slots({ + alt: language.$('misc.alt.albumCover'), + }) + : null), + + mainContent: [ + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + sec.releaseInfo.artistContributionsLine + .slots({stringKey: 'releaseInfo.by'}), + + sec.releaseInfo.coverArtistContributionsLine + .slots({stringKey: 'releaseInfo.coverArtBy'}), + + sec.releaseInfo.wallpaperArtistContributionsLine + .slots({stringKey: 'releaseInfo.wallpaperArtBy'}), + + sec.releaseInfo.bannerArtistContributionsLine + .slots({stringKey: 'releasInfo.bannerArtBy'}), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: + language.formatDuration(data.duration, { + approximate: data.durationApproximate, + }), + }), + ]), + + sec.listen && + html.tag('p', + language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(sec.listen.externalLinks), + })), + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, + [ + sec.extra.additionalFilesShortcut, + + sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewGalleryOrCommentary', { + gallery: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')), + commentary: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')), + }), + + sec.extra.galleryLink && !sec.extra.commentaryLink && + language.$('releaseInfo.viewGallery', { + link: + sec.extra.galleryLink + .slot('content', language.$('releaseInfo.viewGallery.link')), + }), + + !sec.extra.galleryLink && sec.extra.commentaryLink && + language.$('releaseInfo.viewCommentary', { + link: + sec.extra.commentaryLink + .slot('content', language.$('releaseInfo.viewCommentary.link')), + }), + ]), + + relations.trackList, + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + data.dateAddedToWiki && + language.$('releaseInfo.addedToWiki', { + date: language.formatDate(data.dateAddedToWiki), + }), + ]), + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.additionalFilesList, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ], navLinkStyle: 'hierarchical', navLinks: [ @@ -97,3 +340,23 @@ export default { }); }, }; + +/* + banner: !empty(album.bannerArtistContribs) && { + dimensions: album.bannerDimensions, + path: [ + 'media.albumBanner', + album.directory, + album.bannerFileExtension, + ], + alt: language.$('misc.alt.albumBanner'), + position: 'top', + }, + + secondaryNav: generateAlbumSecondaryNav(album, null, { + getLinkThemeString, + html, + language, + link, + }), +*/ diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js deleted file mode 100644 index 230d7351..00000000 --- a/src/content/dependencies/generateAlbumInfoPageContent.js +++ /dev/null @@ -1,306 +0,0 @@ -import {accumulateSum, empty} from '../../util/sugar.js'; - -export default { - contentDependencies: [ - 'generateAdditionalFilesShortcut', - 'generateAlbumAdditionalFilesList', - 'generateAlbumCoverArtwork', - 'generateAlbumTrackList', - 'generateContentHeading', - 'linkAlbumCommentary', - 'linkAlbumGallery', - 'linkContribution', - 'linkExternal', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - - relations(relation, album) { - const relations = {}; - const sections = relations.sections = {}; - - const contributionLinksRelation = contribs => - contribs.map(contrib => - relation('linkContribution', contrib.who, contrib.what)); - - // Section: Release info - - const releaseInfo = sections.releaseInfo = {}; - - if (!empty(album.artistContribs)) { - releaseInfo.artistContributionLinks = - contributionLinksRelation(album.artistContribs); - } - - if (album.hasCoverArt) { - relations.cover = - relation('generateAlbumCoverArtwork', album); - releaseInfo.coverArtistContributionLinks = - contributionLinksRelation(album.coverArtistContribs); - } else { - relations.cover = null; - } - - if (album.hasWallpaperArt) { - releaseInfo.wallpaperArtistContributionLinks = - contributionLinksRelation(album.wallpaperArtistContribs); - } - - if (album.hasBannerArt) { - releaseInfo.bannerArtistContributionLinks = - contributionLinksRelation(album.bannerArtistContribs); - } - - // Section: Listen on - - if (!empty(album.urls)) { - const listen = sections.listen = {}; - - listen.externalLinks = - album.urls.map(url => - relation('linkExternal', url, {type: 'album'})); - } - - // Section: Extra links - - const extra = sections.extra = {}; - - if (album.tracks.some(t => t.hasUniqueCoverArt)) { - extra.galleryLink = - relation('linkAlbumGallery', album); - } - - if (album.commentary || album.tracks.some(t => t.commentary)) { - extra.commentaryLink = - relation('linkAlbumCommentary', album); - } - - if (!empty(album.additionalFiles)) { - extra.additionalFilesShortcut = - relation('generateAdditionalFilesShortcut', album.additionalFiles); - } - - // Section: Track list - - relations.trackList = - relation('generateAlbumTrackList', album); - - // Section: Additional files - - if (!empty(album.additionalFiles)) { - const additionalFiles = sections.additionalFiles = {}; - - additionalFiles.heading = - relation('generateContentHeading'); - - additionalFiles.additionalFilesList = - relation('generateAlbumAdditionalFilesList', album, album.additionalFiles); - } - - // Section: Artist commentary - - if (album.commentary) { - const artistCommentary = sections.artistCommentary = {}; - - artistCommentary.heading = - relation('generateContentHeading'); - - artistCommentary.content = - relation('transformContent', album.commentary); - } - - return relations; - }, - - data(album) { - const data = {}; - - data.date = album.date; - data.duration = accumulateSum(album.tracks, track => track.duration); - data.durationApproximate = album.tracks.length > 1; - - data.hasCoverArt = album.hasCoverArt; - - if (album.hasCoverArt) { - data.coverArtDirectory = album.directory; - data.coverArtFileExtension = album.coverArtFileExtension; - - if (album.coverArtDate && +album.coverArtDate !== +album.date) { - data.coverArtDate = album.coverArtDate; - } - } - - if (!empty(album.additionalFiles)) { - data.numAdditionalFiles = album.additionalFiles.length; - } - - data.dateAddedToWiki = album.dateAddedToWiki; - - return data; - }, - - generate(data, relations, { - html, - language, - }) { - const content = {}; - - const {sections: sec} = relations; - - const formatContributions = - (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) => - contributionLinks && - language.$(stringKey, { - artists: - language.formatConjunctionList( - contributionLinks.map(link => - link.slots({showContribution, showIcons}))), - }); - - if (data.hasCoverArt) { - content.cover = relations.cover - .slots({ - alt: language.$('misc.alt.albumCover'), - }); - } else { - content.cover = null; - } - - content.main = { - headingMode: 'sticky', - content: html.tags([ - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: html.tag('br'), - }, - [ - formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks), - formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks), - formatContributions('releaseInfo.wallpaperArtBy', sec.releaseInfo.wallpaperArtistContributionLinks), - formatContributions('releaseInfo.bannerArtBy', sec.releaseInfo.bannerArtistContributionLinks), - - data.date && - language.$('releaseInfo.released', { - date: language.formatDate(data.date), - }), - - data.coverArtDate && - language.$('releaseInfo.artReleased', { - date: language.formatDate(data.coverArtDate), - }), - - data.duration && - language.$('releaseInfo.duration', { - duration: - language.formatDuration(data.duration, { - approximate: data.durationApproximate, - }), - }), - ]), - - sec.listen && - html.tag('p', - language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList(sec.listen.externalLinks), - })), - - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: html.tag('br'), - }, - [ - sec.extra.additionalFilesShortcut, - - sec.extra.galleryLink && sec.extra.commentaryLink && - language.$('releaseInfo.viewGalleryOrCommentary', { - gallery: - sec.extra.galleryLink - .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')), - commentary: - sec.extra.commentaryLink - .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')), - }), - - sec.extra.galleryLink && !sec.extra.commentaryLink && - language.$('releaseInfo.viewGallery', { - link: - sec.extra.galleryLink - .slot('content', language.$('releaseInfo.viewGallery.link')), - }), - - !sec.extra.galleryLink && sec.extra.commentaryLink && - language.$('releaseInfo.viewCommentary', { - link: - sec.extra.commentaryLink - .slot('content', language.$('releaseInfo.viewCommentary.link')), - }), - ]), - - relations.trackList, - - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - data.dateAddedToWiki && - language.$('releaseInfo.addedToWiki', { - date: language.formatDate(data.dateAddedToWiki), - }), - ]), - - sec.additionalFiles && [ - sec.additionalFiles.heading - .slots({ - id: 'additional-files', - title: - language.$('releaseInfo.additionalFiles.heading', { - additionalFiles: - language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), - }), - }), - - sec.additionalFiles.additionalFilesList, - ], - - sec.artistCommentary && [ - sec.artistCommentary.heading - .slots({ - id: 'artist-commentary', - title: language.$('releaseInfo.artistCommentary') - }), - - html.tag('blockquote', - sec.artistCommentary.content - .slot('mode', 'multiline')), - ], - ]), - }; - - return content; - }, -}; - -/* - banner: !empty(album.bannerArtistContribs) && { - dimensions: album.bannerDimensions, - path: [ - 'media.albumBanner', - album.directory, - album.bannerFileExtension, - ], - alt: language.$('misc.alt.albumBanner'), - position: 'top', - }, - - secondaryNav: generateAlbumSecondaryNav(album, null, { - getLinkThemeString, - html, - language, - link, - }), -*/ diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 796dc1e5..84acca0b 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -104,7 +104,6 @@ export default { showWikiNameInTitle: {type: 'boolean', default: true}, cover: {type: 'html'}, - coverNeedsReveal: {type: 'boolean'}, socialEmbed: {type: 'html'}, @@ -204,7 +203,6 @@ export default { relations.stickyHeadingContainer.slots({ title: slots.title, cover: slots.cover, - needsReveal: slots.coverNeedsReveal, }); break; case 'static': diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js new file mode 100644 index 00000000..2b342d09 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -0,0 +1,49 @@ +import {empty} from '../../util/sugar.js'; + +export default { + contentDependencies: ['linkContribution'], + extraDependencies: ['html', 'language'], + + relations(relation, contributions) { + if (empty(contributions)) { + return {}; + } + + return { + contributionLinks: + contributions + .slice(0, 4) + .map(({who, what}) => + relation('linkContribution', who, what)), + }; + }, + + generate(relations, {html, language}) { + return html.template({ + annotation: `generateReleaseInfoContributionsLine`, + + slots: { + stringKey: {type: 'string'}, + + showContribution: {type: 'boolean', default: true}, + showIcons: {type: 'boolean', default: true}, + }, + + content(slots) { + if (!relations.contributionLinks) { + return html.blank(); + } + + return language.$(slots.stringKey, { + artists: + language.formatConjunctionList( + relations.contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + }); + }, + }); + }, +}; diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js index fb6d8307..6602a2a3 100644 --- a/src/content/dependencies/generateStickyHeadingContainer.js +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -8,7 +8,6 @@ export default { slots: { title: {type: 'html'}, cover: {type: 'html'}, - needsReveal: {type: 'boolean', default: false}, }, content(slots) { @@ -27,13 +26,8 @@ export default { hasCover && html.tag('div', {class: 'content-sticky-heading-cover-container'}, - html.tag('div', - {class: [ - 'content-sticky-heading-cover', - slots.needsReveal && - 'content-sticky-heading-cover-needs-reveal', - ]}, - slots.cover.slot('displayMode', 'thumbnail'))) + html.tag('div', {class: 'content-sticky-heading-cover'}, + slots.cover.slot('displayMode', 'thumbnail'))), ]), html.tag('div', {class: 'content-sticky-subheading-row'}, diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js index f6084f36..757ad2d6 100644 --- a/src/content/dependencies/generateTrackCoverArtwork.js +++ b/src/content/dependencies/generateTrackCoverArtwork.js @@ -7,7 +7,7 @@ export default { relation('generateCoverArtwork', (track.hasUniqueCoverArt ? track.artTags - : album.artTags)), + : track.album.artTags)), }; }, diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index ee68f534..ed28edec 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -1,27 +1,61 @@ import getChronologyRelations from '../util/getChronologyRelations.js'; -import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js'; + +import { + sortAlbumsTracksChronologically, + sortFlashesChronologically, +} from '../../util/wiki-data.js'; + +import {empty} from '../../util/sugar.js'; export default { contentDependencies: [ - 'generateTrackInfoPageContent', + 'generateAdditionalFilesShortcut', + 'generateAlbumAdditionalFilesList', 'generateAlbumNavAccent', 'generateAlbumSidebar', 'generateAlbumStyleRules', 'generateChronologyLinks', 'generateColorStyleRules', + 'generateContentHeading', 'generatePageLayout', + 'generateReleaseInfoContributionsLine', + 'generateTrackCoverArtwork', + 'generateTrackList', + 'generateTrackListDividedByGroups', 'linkAlbum', 'linkArtist', + 'linkContribution', + 'linkExternal', + 'linkFlash', 'linkTrack', + 'transformContent', ], - extraDependencies: ['language'], + extraDependencies: ['html', 'language', 'wikiData'], - relations(relation, track) { + sprawl({wikiInfo}) { return { - layout: relation('generatePageLayout'), + divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames, + }; + }, + + relations(relation, sprawl, track) { + const relations = {}; + const sections = relations.sections = {}; + const {album} = track; + + relations.layout = + relation('generatePageLayout'); + + relations.albumStyleRules = + relation('generateAlbumStyleRules', track.album); + + relations.colorStyleRules = + relation('generateColorStyleRules', track.color); - artistChronologyContributions: getChronologyRelations(track, { + relations.artistChronologyContributions = + getChronologyRelations(track, { contributions: [...track.artistContribs, ...track.contributorContribs], linkArtist: artist => relation('linkArtist', artist), @@ -32,9 +66,10 @@ export default { ...artist.tracksAsArtist, ...artist.tracksAsContributor, ]), - }), + }); - coverArtistChronologyContributions: getChronologyRelations(track, { + relations.coverArtistChronologyContributions = + getChronologyRelations(track, { contributions: track.coverArtistContribs, linkArtist: artist => relation('linkArtist', artist), @@ -53,28 +88,255 @@ export default { }), }), - albumLink: relation('linkAlbum', track.album), - trackLink: relation('linkTrack', track), - albumNavAccent: relation('generateAlbumNavAccent', track.album, track), - chronologyLinks: relation('generateChronologyLinks'), + relations.albumLink = + relation('linkAlbum', track.album); - content: relation('generateTrackInfoPageContent', track), - sidebar: relation('generateAlbumSidebar', track.album, track), - albumStyleRules: relation('generateAlbumStyleRules', track.album), - colorStyleRules: relation('generateColorStyleRules', track.color), - }; + relations.trackLink = + relation('linkTrack', track); + + relations.albumNavAccent = + relation('generateAlbumNavAccent', track.album, track); + + relations.chronologyLinks = + relation('generateChronologyLinks'); + + relations.sidebar = + relation('generateAlbumSidebar', track.album, track); + + const additionalFilesSection = additionalFiles => ({ + heading: relation('generateContentHeading'), + list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), + }); + + if (track.hasUniqueCoverArt || album.hasCoverArt) { + relations.cover = + relation('generateTrackCoverArtwork', track); + } + + // Section: Release info + + const releaseInfo = sections.releaseInfo = {}; + + releaseInfo.artistContributionLinks = + relation('generateReleaseInfoContributionsLine', track.artistContribs); + + if (track.hasUniqueCoverArt) { + releaseInfo.coverArtistContributionsLine = + relation('generateReleaseInfoContributionsLine', track.coverArtistContribs); + } + + // Section: Listen on + + const listen = sections.listen = {}; + + if (!empty(track.urls)) { + listen.externalLinks = + track.urls.map(url => + relation('linkExternal', url)); + } + + // Section: Extra links + + const extra = sections.extra = {}; + + if (!empty(track.additionalFiles)) { + extra.additionalFilesShortcut = + relation('generateAdditionalFilesShortcut', track.additionalFiles); + } + + // Section: Other releases + + if (!empty(track.otherReleases)) { + const otherReleases = sections.otherReleases = {}; + + otherReleases.heading = + relation('generateContentHeading'); + + otherReleases.items = + track.otherReleases.map(track => ({ + trackLink: relation('linkTrack', track), + albumLink: relation('linkAlbum', track.album), + })); + } + + // Section: Contributors + + if (!empty(track.contributorContribs)) { + const contributors = sections.contributors = {}; + + contributors.heading = + relation('generateContentHeading'); + + contributors.contributionLinks = + track.contributorContribs.map(({who, what}) => + relation('linkContribution', who, what)); + } + + // Section: Referenced tracks + + if (!empty(track.referencedTracks)) { + const references = sections.references = {}; + + references.heading = + relation('generateContentHeading'); + + references.list = + relation('generateTrackList', track.referencedTracks); + } + + // Section: Tracks that reference + + if (!empty(track.referencedByTracks)) { + const referencedBy = sections.referencedBy = {}; + + referencedBy.heading = + relation('generateContentHeading'); + + referencedBy.list = + relation('generateTrackListDividedByGroups', + track.referencedByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Sampled tracks + + if (!empty(track.sampledTracks)) { + const samples = sections.samples = {}; + + samples.heading = + relation('generateContentHeading'); + + samples.list = + relation('generateTrackList', track.sampledTracks); + } + + // Section: Tracks that sample + + if (!empty(track.sampledByTracks)) { + const sampledBy = sections.sampledBy = {}; + + sampledBy.heading = + relation('generateContentHeading'); + + sampledBy.list = + relation('generateTrackListDividedByGroups', + track.sampledByTracks, + sprawl.divideTrackListsByGroups); + } + + // Section: Flashes that feature + + if (sprawl.enableFlashesAndGames) { + const sortedFeatures = + sortFlashesChronologically( + [track, ...track.otherReleases].flatMap(track => + track.featuredInFlashes.map(flash => ({ + // These aren't going to be exposed directly, they're processed + // into the appropriate relations after this sort. + flash, track, + + // These properties are only used for the sort. + act: flash.act, + date: flash.date, + })))); + + if (!empty(sortedFeatures)) { + const flashesThatFeature = sections.flashesThatFeature = {}; + + flashesThatFeature.heading = + relation('generateContentHeading'); + + flashesThatFeature.entries = + sortedFeatures.map(({flash, track: directlyFeaturedTrack}) => + (directlyFeaturedTrack === track + ? { + flashLink: relation('linkFlash', flash), + } + : { + flashLink: relation('linkFlash', flash), + trackLink: relation('linkTrack', directlyFeaturedTrack), + })); + } + } + + // Section: Lyrics + + if (track.lyrics) { + const lyrics = sections.lyrics = {}; + + lyrics.heading = + relation('generateContentHeading'); + + lyrics.content = + relation('transformContent', track.lyrics); + } + + // Sections: Sheet music files, MIDI/proejct files, additional files + + if (!empty(track.sheetMusicFiles)) { + sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles); + } + + if (!empty(track.midiProjectFiles)) { + sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles); + } + + if (!empty(track.additionalFiles)) { + sections.additionalFiles = additionalFilesSection(track.additionalFiles); + } + + // Section: Artist commentary + + if (track.commentary) { + const artistCommentary = sections.artistCommentary = {}; + + artistCommentary.heading = + relation('generateContentHeading'); + + artistCommentary.content = + relation('transformContent', track.commentary); + } + + return relations; }, - data(track) { - return { - name: track.name, + data(sprawl, track) { + const data = {}; + const {album} = track; - hasTrackNumbers: track.album.hasTrackNumbers, - trackNumber: track.album.tracks.indexOf(track) + 1, - }; + data.name = track.name; + data.date = track.date; + data.duration = track.duration; + + data.hasUniqueCoverArt = track.hasUniqueCoverArt; + data.hasAlbumCoverArt = album.hasCoverArt; + + if (track.hasUniqueCoverArt) { + data.albumCoverArtDirectory = album.directory; + data.trackCoverArtDirectory = track.directory; + data.coverArtFileExtension = track.coverArtFileExtension; + + if (track.coverArtDate && +track.coverArtDate !== +track.date) { + data.coverArtDate = track.coverArtDate; + } + } else if (track.album.hasCoverArt) { + data.albumCoverArtDirectory = album.directory; + data.coverArtFileExtension = album.coverArtFileExtension; + } + + data.hasTrackNumbers = album.hasTrackNumbers; + data.trackNumber = album.tracks.indexOf(track) + 1; + + if (!empty(track.additionalFiles)) { + data.numAdditionalFiles = track.additionalFiles.length; + } + + return data; }, - generate(data, relations, {language}) { + generate(data, relations, {html, language}) { + const {sections: sec} = relations; + return relations.layout .slots({ title: language.$('trackPage.title', {track: data.name}), @@ -83,9 +345,246 @@ export default { colorStyleRules: [relations.colorStyleRules], additionalStyleRules: [relations.albumStyleRules], - cover: relations.content.cover, - coverNeedsReveal: relations.content.coverNeedsReveal, - mainContent: relations.content.main.content, + cover: + (relations.cover + ? relations.cover.slots({ + alt: language.$('misc.alt.trackCover'), + }) + : null), + + mainContent: [ + html.tag('p', { + [html.onlyIfContent]: true, + [html.joinChildren]: html.tag('br'), + }, [ + sec.releaseInfo.artistContributionLinks + .slots({stringKey: 'releaseInfo.by'}), + + sec.releaseInfo.coverArtistContributionsLine + ?.slots({stringKey: 'releaseInfo.coverArtBy'}), + + data.date && + language.$('releaseInfo.released', { + date: language.formatDate(data.date), + }), + + data.coverArtDate && + language.$('releaseInfo.artReleased', { + date: language.formatDate(data.coverArtDate), + }), + + data.duration && + language.$('releaseInfo.duration', { + duration: language.formatDuration(data.duration), + }), + ]), + + html.tag('p', + (sec.listen.externalLinks + ? language.$('releaseInfo.listenOn', { + links: language.formatDisjunctionList(sec.listen.externalLinks), + }) + : language.$('releaseInfo.listenOn.noLinks', { + name: html.tag('i', data.name), + }))), + + html.tag('p', + { + [html.onlyIfContent]: true, + [html.joinChildren]: '<br>', + }, + [ + sec.sheetMusicFiles && + language.$('releaseInfo.sheetMusicFiles.shortcut', { + link: html.tag('a', + {href: '#sheet-music-files'}, + language.$('releaseInfo.sheetMusicFiles.shortcut.link')), + }), + + sec.midiProjectFiles && + language.$('releaseInfo.midiProjectFiles.shortcut', { + link: html.tag('a', + {href: '#midi-project-files'}, + language.$('releaseInfo.midiProjectFiles.shortcut.link')), + }), + + sec.additionalFiles && + sec.extra.additionalFilesShortcut, + + sec.artistCommentary && + language.$('releaseInfo.readCommentary', { + link: html.tag('a', + {href: '#artist-commentary'}, + language.$('releaseInfo.readCommentary.link')), + }), + ]), + + sec.otherReleases && [ + sec.otherReleases.heading + .slots({ + id: 'also-released-as', + title: language.$('releaseInfo.alsoReleasedAs'), + }), + + html.tag('ul', + sec.otherReleases.items.map(({trackLink, albumLink}) => + html.tag('li', + language.$('releaseInfo.alsoReleasedAs.item', { + track: trackLink, + album: albumLink, + })))), + ], + + sec.contributors && [ + sec.contributors.heading + .slots({ + id: 'contributors', + title: language.$('releaseInfo.contributors'), + }), + + html.tag('ul', + sec.contributors.contributionLinks.map(contributionLink => + html.tag('li', + contributionLink + .slots({ + showIcons: true, + showContribution: true, + })))), + ], + + sec.references && [ + sec.references.heading + .slots({ + id: 'references', + title: + language.$('releaseInfo.tracksReferenced', { + track: html.tag('i', data.name), + }), + }), + + sec.references.list, + ], + + sec.referencedBy && [ + sec.referencedBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatReference', { + track: html.tag('i', data.name), + }), + }), + + sec.referencedBy.list, + ], + + sec.samples && [ + sec.samples.heading + .slots({ + id: 'samples', + title: + language.$('releaseInfo.tracksSampled', { + track: html.tag('i', data.name), + }), + }), + + sec.samples.list, + ], + + sec.sampledBy && [ + sec.sampledBy.heading + .slots({ + id: 'referenced-by', + title: + language.$('releaseInfo.tracksThatSample', { + track: html.tag('i', data.name), + }), + }), + + sec.sampledBy.list, + ], + + sec.flashesThatFeature && [ + sec.flashesThatFeature.heading + .slots({ + id: 'featured-in', + title: + language.$('releaseInfo.flashesThatFeature', { + track: html.tag('i', data.name), + }), + }), + + html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) => + (trackLink + ? html.tag('li', {class: 'rerelease'}, + language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { + flash: flashLink, + track: trackLink, + })) + : html.tag('li', + language.$('releaseInfo.flashesThatFeature.item', { + flash: flashLink, + }))))), + ], + + sec.lyrics && [ + sec.lyrics.heading + .slots({ + id: 'lyrics', + title: language.$('releaseInfo.lyrics'), + }), + + html.tag('blockquote', + sec.lyrics.content + .slot('mode', 'lyrics')), + ], + + sec.sheetMusicFiles && [ + sec.sheetMusicFiles.heading + .slots({ + id: 'sheet-music-files', + title: language.$('releaseInfo.sheetMusicFiles.heading'), + }), + + sec.sheetMusicFiles.list, + ], + + sec.midiProjectFiles && [ + sec.midiProjectFiles.heading + .slots({ + id: 'midi-project-files', + title: language.$('releaseInfo.midiProjectFiles.heading'), + }), + + sec.midiProjectFiles.list, + ], + + sec.additionalFiles && [ + sec.additionalFiles.heading + .slots({ + id: 'additional-files', + title: + language.$('releaseInfo.additionalFiles.heading', { + additionalFiles: + language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), + }), + }), + + sec.additionalFiles.list, + ], + + sec.artistCommentary && [ + sec.artistCommentary.heading + .slots({ + id: 'artist-commentary', + title: language.$('releaseInfo.artistCommentary') + }), + + html.tag('blockquote', + sec.artistCommentary.content + .slot('mode', 'multiline')), + ], + ], navLinkStyle: 'hierarchical', navLinks: [ @@ -129,4 +628,109 @@ export default { ...relations.sidebar, }); }, -} +}; + +/* + const data = { + type: 'data', + path: ['track', track.directory], + data: ({ + serializeContribs, + serializeCover, + serializeGroupsForTrack, + serializeLink, + }) => ({ + name: track.name, + directory: track.directory, + dates: { + released: track.date, + originallyReleased: track.originalDate, + coverArtAdded: track.coverArtDate, + }, + duration: track.duration, + color: track.color, + cover: serializeCover(track, getTrackCover), + artistsContribs: serializeContribs(track.artistContribs), + contributorContribs: serializeContribs(track.contributorContribs), + coverArtistContribs: serializeContribs(track.coverArtistContribs || []), + album: serializeLink(track.album), + groups: serializeGroupsForTrack(track), + references: track.references.map(serializeLink), + referencedBy: track.referencedBy.map(serializeLink), + alsoReleasedAs: otherReleases.map((track) => ({ + track: serializeLink(track), + album: serializeLink(track.album), + })), + }), + }; + + const getSocialEmbedDescription = ({ + getArtistString: _getArtistString, + language, + }) => { + const hasArtists = !empty(track.artistContribs); + const hasCoverArtists = !empty(track.coverArtistContribs); + const getArtistString = (contribs) => + _getArtistString(contribs, { + // We don't want to put actual HTML tags in social embeds (sadly + // they don't get parsed and displayed, generally speaking), so + // override the link argument so that artist "links" just show + // their names. + link: {artist: (artist) => artist.name}, + }); + if (!hasArtists && !hasCoverArtists) return ''; + return language.formatString( + 'trackPage.socialEmbed.body' + + [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] + .filter(Boolean) + .join(''), + Object.fromEntries( + [ + hasArtists && ['artists', getArtistString(track.artistContribs)], + hasCoverArtists && [ + 'coverArtists', + getArtistString(track.coverArtistContribs), + ], + ].filter(Boolean) + ) + ); + }; + + const page = { + page: () => { + return { + title: language.$('trackPage.title', {track: track.name}), + stylesheet: getAlbumStylesheet(album, {to}), + + themeColor: track.color, + theme: + getThemeString(track.color, { + additionalVariables: [ + `--album-directory: ${album.directory}`, + `--track-directory: ${track.directory}`, + ] + }), + + socialEmbed: { + heading: language.$('trackPage.socialEmbed.heading', { + album: track.album.name, + }), + headingLink: absoluteTo('localized.album', album.directory), + title: language.$('trackPage.socialEmbed.title', { + track: track.name, + }), + description: getSocialEmbedDescription({getArtistString, language}), + image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), + color: track.color, + }, + + secondaryNav: generateAlbumSecondaryNav(album, track, { + getLinkThemeString, + html, + language, + link, + }), + }; + }, + }; +*/ diff --git a/src/content/dependencies/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js deleted file mode 100644 index 43f8e689..00000000 --- a/src/content/dependencies/generateTrackInfoPageContent.js +++ /dev/null @@ -1,654 +0,0 @@ -import {empty} from '../../util/sugar.js'; -import {sortFlashesChronologically} from '../../util/wiki-data.js'; - -export default { - contentDependencies: [ - 'generateAdditionalFilesShortcut', - 'generateAlbumAdditionalFilesList', - 'generateContentHeading', - 'generateTrackCoverArtwork', - 'generateTrackList', - 'generateTrackListDividedByGroups', - 'linkAlbum', - 'linkContribution', - 'linkExternal', - 'linkFlash', - 'linkTrack', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - - sprawl({wikiInfo}) { - return { - divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups, - enableFlashesAndGames: wikiInfo.enableFlashesAndGames, - }; - }, - - relations(relation, sprawl, track) { - const {album} = track; - - const relations = {}; - const sections = relations.sections = {}; - - const contributionLinksRelation = contribs => - contribs - .slice(0, 4) - .map(contrib => - relation('linkContribution', contrib.who, contrib.what)); - - const additionalFilesSection = additionalFiles => ({ - heading: relation('generateContentHeading'), - list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), - }); - - if (track.hasUniqueCoverArt || album.hasCoverArt) { - relations.cover = - relation('generateTrackCoverArtwork', track); - } - - // Section: Release info - - const releaseInfo = sections.releaseInfo = {}; - - releaseInfo.artistContributionLinks = - contributionLinksRelation(track.artistContribs); - - if (track.hasUniqueCoverArt) { - releaseInfo.coverArtistContributionLinks = - contributionLinksRelation(track.coverArtistContribs); - } - - // Section: Listen on - - const listen = sections.listen = {}; - - if (!empty(track.urls)) { - listen.externalLinks = - track.urls.map(url => - relation('linkExternal', url)); - } - - // Section: Extra links - - const extra = sections.extra = {}; - - if (!empty(track.additionalFiles)) { - extra.additionalFilesShortcut = - relation('generateAdditionalFilesShortcut', track.additionalFiles); - } - - // Section: Other releases - - if (!empty(track.otherReleases)) { - const otherReleases = sections.otherReleases = {}; - - otherReleases.heading = - relation('generateContentHeading'); - - otherReleases.items = - track.otherReleases.map(track => ({ - trackLink: relation('linkTrack', track), - albumLink: relation('linkAlbum', track.album), - })); - } - - // Section: Contributors - - if (!empty(track.contributorContribs)) { - const contributors = sections.contributors = {}; - - contributors.heading = - relation('generateContentHeading'); - - contributors.contributionLinks = - contributionLinksRelation(track.contributorContribs); - } - - // Section: Referenced tracks - - if (!empty(track.referencedTracks)) { - const references = sections.references = {}; - - references.heading = - relation('generateContentHeading'); - - references.list = - relation('generateTrackList', track.referencedTracks); - } - - // Section: Tracks that reference - - if (!empty(track.referencedByTracks)) { - const referencedBy = sections.referencedBy = {}; - - referencedBy.heading = - relation('generateContentHeading'); - - referencedBy.list = - relation('generateTrackListDividedByGroups', - track.referencedByTracks, - sprawl.divideTrackListsByGroups); - } - - // Section: Sampled tracks - - if (!empty(track.sampledTracks)) { - const samples = sections.samples = {}; - - samples.heading = - relation('generateContentHeading'); - - samples.list = - relation('generateTrackList', track.sampledTracks); - } - - // Section: Tracks that sample - - if (!empty(track.sampledByTracks)) { - const sampledBy = sections.sampledBy = {}; - - sampledBy.heading = - relation('generateContentHeading'); - - sampledBy.list = - relation('generateTrackListDividedByGroups', - track.sampledByTracks, - sprawl.divideTrackListsByGroups); - } - - // Section: Flashes that feature - - if (sprawl.enableFlashesAndGames) { - const sortedFeatures = - sortFlashesChronologically( - [track, ...track.otherReleases].flatMap(track => - track.featuredInFlashes.map(flash => ({ - // These aren't going to be exposed directly, they're processed - // into the appropriate relations after this sort. - flash, track, - - // These properties are only used for the sort. - act: flash.act, - date: flash.date, - })))); - - if (!empty(sortedFeatures)) { - const flashesThatFeature = sections.flashesThatFeature = {}; - - flashesThatFeature.heading = - relation('generateContentHeading'); - - flashesThatFeature.entries = - sortedFeatures.map(({flash, track: directlyFeaturedTrack}) => - (directlyFeaturedTrack === track - ? { - flashLink: relation('linkFlash', flash), - } - : { - flashLink: relation('linkFlash', flash), - trackLink: relation('linkTrack', directlyFeaturedTrack), - })); - } - } - - // Section: Lyrics - - if (track.lyrics) { - const lyrics = sections.lyrics = {}; - - lyrics.heading = - relation('generateContentHeading'); - - lyrics.content = - relation('transformContent', track.lyrics); - } - - // Sections: Sheet music files, MIDI/proejct files, additional files - - if (!empty(track.sheetMusicFiles)) { - sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles); - } - - if (!empty(track.midiProjectFiles)) { - sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles); - } - - if (!empty(track.additionalFiles)) { - sections.additionalFiles = additionalFilesSection(track.additionalFiles); - } - - // Section: Artist commentary - - if (track.commentary) { - const artistCommentary = sections.artistCommentary = {}; - - artistCommentary.heading = - relation('generateContentHeading'); - - artistCommentary.content = - relation('transformContent', track.commentary); - } - - return relations; - }, - - data(sprawl, track) { - const data = {}; - - const {album} = track; - - data.name = track.name; - data.date = track.date; - data.duration = track.duration; - - data.hasUniqueCoverArt = track.hasUniqueCoverArt; - data.hasAlbumCoverArt = album.hasCoverArt; - - if (track.hasUniqueCoverArt) { - data.albumCoverArtDirectory = album.directory; - data.trackCoverArtDirectory = track.directory; - data.coverArtFileExtension = track.coverArtFileExtension; - data.coverNeedsReveal = track.artTags.some(t => t.isContentWarning); - - if (track.coverArtDate && +track.coverArtDate !== +track.date) { - data.coverArtDate = track.coverArtDate; - } - } else if (track.album.hasCoverArt) { - data.albumCoverArtDirectory = album.directory; - data.coverArtFileExtension = album.coverArtFileExtension; - data.coverNeedsReveal = album.artTags.some(t => t.isContentWarning); - } - - if (!empty(track.additionalFiles)) { - data.numAdditionalFiles = track.additionalFiles.length; - } - - return data; - }, - - generate(data, relations, {html, language}) { - const content = {}; - - const {sections: sec} = relations; - - const formatContributions = - (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) => - contributionLinks && - language.$(stringKey, { - artists: - language.formatConjunctionList( - contributionLinks.map(link => - link.slots({showContribution, showIcons}))), - }); - - if (data.hasUniqueCoverArt || data.hasAlbumCoverArt) { - content.cover = relations.cover - .slots({ - alt: language.$('misc.alt.trackCover'), - }); - content.coverNeedsReveal = data.coverNeedsReveal; - } else { - content.cover = null; - content.coverNeedsReveal = null; - } - - content.main = { - headingMode: 'sticky', - - content: html.tags([ - html.tag('p', { - [html.onlyIfContent]: true, - [html.joinChildren]: html.tag('br'), - }, [ - formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks), - formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks), - - data.date && - language.$('releaseInfo.released', { - date: language.formatDate(data.date), - }), - - data.coverArtDate && - language.$('releaseInfo.artReleased', { - date: language.formatDate(data.coverArtDate), - }), - - data.duration && - language.$('releaseInfo.duration', { - duration: language.formatDuration(data.duration), - }), - ]), - - html.tag('p', - (sec.listen.externalLinks - ? language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList(sec.listen.externalLinks), - }) - : language.$('releaseInfo.listenOn.noLinks', { - name: html.tag('i', data.name), - }))), - - html.tag('p', - { - [html.onlyIfContent]: true, - [html.joinChildren]: '<br>', - }, - [ - sec.sheetMusicFiles && - language.$('releaseInfo.sheetMusicFiles.shortcut', { - link: html.tag('a', - {href: '#sheet-music-files'}, - language.$('releaseInfo.sheetMusicFiles.shortcut.link')), - }), - - sec.midiProjectFiles && - language.$('releaseInfo.midiProjectFiles.shortcut', { - link: html.tag('a', - {href: '#midi-project-files'}, - language.$('releaseInfo.midiProjectFiles.shortcut.link')), - }), - - sec.additionalFiles && - sec.extra.additionalFilesShortcut, - - sec.artistCommentary && - language.$('releaseInfo.readCommentary', { - link: html.tag('a', - {href: '#artist-commentary'}, - language.$('releaseInfo.readCommentary.link')), - }), - ]), - - sec.otherReleases && [ - sec.otherReleases.heading - .slots({ - id: 'also-released-as', - title: language.$('releaseInfo.alsoReleasedAs'), - }), - - html.tag('ul', - sec.otherReleases.items.map(({trackLink, albumLink}) => - html.tag('li', - language.$('releaseInfo.alsoReleasedAs.item', { - track: trackLink, - album: albumLink, - })))), - ], - - sec.contributors && [ - sec.contributors.heading - .slots({ - id: 'contributors', - title: language.$('releaseInfo.contributors'), - }), - - html.tag('ul', sec.contributors.contributionLinks.map(contributionLink => - html.tag('li', - contributionLink - .slots({ - showIcons: true, - showContribution: true, - })))), - ], - - sec.references && [ - sec.references.heading - .slots({ - id: 'references', - title: - language.$('releaseInfo.tracksReferenced', { - track: html.tag('i', data.name), - }), - }), - - sec.references.list, - ], - - sec.referencedBy && [ - sec.referencedBy.heading - .slots({ - id: 'referenced-by', - title: - language.$('releaseInfo.tracksThatReference', { - track: html.tag('i', data.name), - }), - }), - - sec.referencedBy.list, - ], - - sec.samples && [ - sec.samples.heading - .slots({ - id: 'samples', - title: - language.$('releaseInfo.tracksSampled', { - track: html.tag('i', data.name), - }), - }), - - sec.samples.list, - ], - - sec.sampledBy && [ - sec.sampledBy.heading - .slots({ - id: 'referenced-by', - title: - language.$('releaseInfo.tracksThatSample', { - track: html.tag('i', data.name), - }), - }), - - sec.sampledBy.list, - ], - sec.flashesThatFeature && [ - sec.flashesThatFeature.heading - .slots({ - id: 'featured-in', - title: - language.$('releaseInfo.flashesThatFeature', { - track: html.tag('i', data.name), - }), - }), - - html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) => - (trackLink - ? html.tag('li', {class: 'rerelease'}, - language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', { - flash: flashLink, - track: trackLink, - })) - : html.tag('li', - language.$('releaseInfo.flashesThatFeature.item', { - flash: flashLink, - }))))), - ], - - sec.lyrics && [ - sec.lyrics.heading - .slots({ - id: 'lyrics', - title: language.$('releaseInfo.lyrics'), - }), - - html.tag('blockquote', - sec.lyrics.content - .slot('mode', 'lyrics')), - ], - - sec.sheetMusicFiles && [ - sec.sheetMusicFiles.heading - .slots({ - id: 'sheet-music-files', - title: language.$('releaseInfo.sheetMusicFiles.heading'), - }), - - sec.sheetMusicFiles.list, - ], - - sec.midiProjectFiles && [ - sec.midiProjectFiles.heading - .slots({ - id: 'midi-project-files', - title: language.$('releaseInfo.midiProjectFiles.heading'), - }), - - sec.midiProjectFiles.list, - ], - - sec.additionalFiles && [ - sec.additionalFiles.heading - .slots({ - id: 'additional-files', - title: - language.$('releaseInfo.additionalFiles.heading', { - additionalFiles: - language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}), - }), - }), - - sec.additionalFiles.list, - ], - - sec.artistCommentary && [ - sec.artistCommentary.heading - .slots({ - id: 'artist-commentary', - title: language.$('releaseInfo.artistCommentary') - }), - - html.tag('blockquote', - sec.artistCommentary.content - .slot('mode', 'multiline')), - ], - ]), - }; - - return content; - }, -}; - -/* - const generateCommentary = ({language, link, transformMultiline}) => - transformMultiline([ - track.commentary, - ...otherReleases.map((track) => - track.commentary - ?.split('\n') - .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>')) - .flatMap(line => [ - line, - language.$('releaseInfo.artistCommentary.seeOriginalRelease', { - original: link.track(track), - }), - ]) - .join('\n') - ), - ].filter(Boolean).join('\n')); - - const data = { - type: 'data', - path: ['track', track.directory], - data: ({ - serializeContribs, - serializeCover, - serializeGroupsForTrack, - serializeLink, - }) => ({ - name: track.name, - directory: track.directory, - dates: { - released: track.date, - originallyReleased: track.originalDate, - coverArtAdded: track.coverArtDate, - }, - duration: track.duration, - color: track.color, - cover: serializeCover(track, getTrackCover), - artistsContribs: serializeContribs(track.artistContribs), - contributorContribs: serializeContribs(track.contributorContribs), - coverArtistContribs: serializeContribs(track.coverArtistContribs || []), - album: serializeLink(track.album), - groups: serializeGroupsForTrack(track), - references: track.references.map(serializeLink), - referencedBy: track.referencedBy.map(serializeLink), - alsoReleasedAs: otherReleases.map((track) => ({ - track: serializeLink(track), - album: serializeLink(track.album), - })), - }), - }; - - const getSocialEmbedDescription = ({ - getArtistString: _getArtistString, - language, - }) => { - const hasArtists = !empty(track.artistContribs); - const hasCoverArtists = !empty(track.coverArtistContribs); - const getArtistString = (contribs) => - _getArtistString(contribs, { - // We don't want to put actual HTML tags in social embeds (sadly - // they don't get parsed and displayed, generally speaking), so - // override the link argument so that artist "links" just show - // their names. - link: {artist: (artist) => artist.name}, - }); - if (!hasArtists && !hasCoverArtists) return ''; - return language.formatString( - 'trackPage.socialEmbed.body' + - [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists'] - .filter(Boolean) - .join(''), - Object.fromEntries( - [ - hasArtists && ['artists', getArtistString(track.artistContribs)], - hasCoverArtists && [ - 'coverArtists', - getArtistString(track.coverArtistContribs), - ], - ].filter(Boolean) - ) - ); - }; - - const page = { - page: () => { - return { - title: language.$('trackPage.title', {track: track.name}), - stylesheet: getAlbumStylesheet(album, {to}), - - themeColor: track.color, - theme: - getThemeString(track.color, { - additionalVariables: [ - `--album-directory: ${album.directory}`, - `--track-directory: ${track.directory}`, - ] - }), - - socialEmbed: { - heading: language.$('trackPage.socialEmbed.heading', { - album: track.album.name, - }), - headingLink: absoluteTo('localized.album', album.directory), - title: language.$('trackPage.socialEmbed.title', { - track: track.name, - }), - description: getSocialEmbedDescription({getArtistString, language}), - image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}), - color: track.color, - }, - - secondaryNav: generateAlbumSecondaryNav(album, track, { - getLinkThemeString, - html, - language, - link, - }), - }; - }, - }; -*/ diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js index 4ccdf58d..03aa9836 100644 --- a/src/content/dependencies/linkThing.js +++ b/src/content/dependencies/linkThing.js @@ -25,70 +25,68 @@ export default { }; }, - generate(data, relations, {html}) { + slots: { + // content: relations.linkTemplate.getSlotDescription('content'), + content: {type: 'html'}, + + preferShortName: {type: 'boolean', default: false}, + + tooltip: { + validate: v => v.oneOf(v.isBoolean, v.isString), + default: false, + }, + + color: { + validate: v => v.oneOf(v.isBoolean, v.isColor), + default: true, + }, + + anchor: {type: 'boolean', default: false}, + + // attributes: relations.linkTemplate.getSlotDescription('attributes'), + // hash: relations.linkTemplate.getSlotDescription('hash'), + attributes: {validate: v => v.isAttributes}, + hash: {type: 'string'}, + }, + + generate(data, relations, slots, {html}) { const path = [data.pathKey, data.directory]; - return html.template({ - annotation: 'linkThing', - - slots: { - content: relations.linkTemplate.getSlotDescription('content'), - preferShortName: {type: 'boolean', default: false}, - - tooltip: { - validate: v => v.oneOf(v.isBoolean, v.isString), - default: false, - }, - - color: { - validate: v => v.oneOf(v.isBoolean, v.isColor), - default: true, - }, - - anchor: {type: 'boolean', default: false}, - - attributes: relations.linkTemplate.getSlotDescription('attributes'), - hash: relations.linkTemplate.getSlotDescription('hash'), - }, - - content(slots) { - let content = slots.content; - - const name = - (slots.preferShortName - ? data.nameShort ?? data.name - : data.name); - - if (html.isBlank(content)) { - content = name; - } - - let color = null; - if (slots.color === true) { - color = data.color ?? null; - } else if (typeof slots.color === 'string') { - color = slots.color; - } - - let tooltip = null; - if (slots.tooltip === true) { - tooltip = name; - } else if (typeof slots.tooltip === 'string') { - tooltip = slots.tooltip; - } - - return relations.linkTemplate - .slots({ - path: slots.anchor ? [] : path, - href: slots.anchor ? '' : null, - content, - color, - tooltip, - - attributes: slots.attributes, - hash: slots.hash, - }); - }, - }); + let content = slots.content; + + const name = + (slots.preferShortName + ? data.nameShort ?? data.name + : data.name); + + if (html.isBlank(content)) { + content = name; + } + + let color = null; + if (slots.color === true) { + color = data.color ?? null; + } else if (typeof slots.color === 'string') { + color = slots.color; + } + + let tooltip = null; + if (slots.tooltip === true) { + tooltip = name; + } else if (typeof slots.tooltip === 'string') { + tooltip = slots.tooltip; + } + + return relations.linkTemplate + .slots({ + path: slots.anchor ? [] : path, + href: slots.anchor ? '' : null, + content, + color, + tooltip, + + attributes: slots.attributes, + hash: slots.hash, + }); }, } diff --git a/src/static/client.js b/src/static/client.js index 62122952..e75fbd99 100644 --- a/src/static/client.js +++ b/src/static/client.js @@ -561,6 +561,7 @@ function prepareStickyHeadings() { } of stickyHeadingInfo) { const coverRevealImage = contentCover?.querySelector('.reveal'); if (coverRevealImage) { + stickyCover.classList.add('content-sticky-heading-cover-needs-reveal'); coverRevealImage.addEventListener('hsmusic-reveal', () => { stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); }); diff --git a/src/util/html.js b/src/util/html.js index b5930d06..b75820e8 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -489,7 +489,10 @@ export class Template { #slotValues = {}; constructor(description) { - Template.validateDescription(description); + if (!description[Stationery.validated]) { + Template.validateDescription(description); + } + this.#description = description; } @@ -528,69 +531,79 @@ export class Template { break validateSlots; } - const slotErrors = []; + try { + this.validateSlotsDescription(description.slots); + } catch (slotError) { + topErrors.push(slotError); + } + } - for (const [slotName, slotDescription] of Object.entries(description.slots)) { - if (typeof slotDescription !== 'object' || slotDescription === null) { - slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`)); - continue; - } + if (!empty(topErrors)) { + throw new AggregateError(topErrors, + (typeof description.annotation === 'string' + ? `Errors validating template "${description.annotation}" description` + : `Errors validating template description`)); + } - if ('default' in slotDescription) validateDefault: { - if ( - slotDescription.default === undefined || - slotDescription.default === null - ) { - slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`)); - break validateDefault; - } + return true; + } - try { - Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription); - } catch (error) { - error.message = `(${slotName}) Error validating slot default value: ${error.message}`; - slotErrors.push(error); - } + static validateSlotsDescription(slots) { + const slotErrors = []; + + for (const [slotName, slotDescription] of Object.entries(slots)) { + if (typeof slotDescription !== 'object' || slotDescription === null) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`)); + continue; + } + + if ('default' in slotDescription) validateDefault: { + if ( + slotDescription.default === undefined || + slotDescription.default === null + ) { + slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`)); + break validateDefault; } - if ('validate' in slotDescription && 'type' in slotDescription) { - slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`)); - } else if (!('validate' in slotDescription || 'type' in slotDescription)) { - slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`)); - } else if ('validate' in slotDescription) { - if (typeof slotDescription.validate !== 'function') { - slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`)); - } - } else if ('type' in slotDescription) { - const acceptableSlotTypes = [ - 'string', - 'number', - 'bigint', - 'boolean', - 'symbol', - 'html', - ]; - - if (slotDescription.type === 'function') { - slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`)); - } else if (slotDescription.type === 'object') { - slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`)); - } else if (!acceptableSlotTypes.includes(slotDescription.type)) { - slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); - } + try { + Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription); + } catch (error) { + error.message = `(${slotName}) Error validating slot default value: ${error.message}`; + slotErrors.push(error); } } - if (!empty(slotErrors)) { - topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`)); + if ('validate' in slotDescription && 'type' in slotDescription) { + slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`)); + } else if (!('validate' in slotDescription || 'type' in slotDescription)) { + slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`)); + } else if ('validate' in slotDescription) { + if (typeof slotDescription.validate !== 'function') { + slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`)); + } + } else if ('type' in slotDescription) { + const acceptableSlotTypes = [ + 'string', + 'number', + 'bigint', + 'boolean', + 'symbol', + 'html', + ]; + + if (slotDescription.type === 'function') { + slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`)); + } else if (slotDescription.type === 'object') { + slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`)); + } else if (!acceptableSlotTypes.includes(slotDescription.type)) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); + } } } - if (!empty(topErrors)) { - throw new AggregateError(topErrors, - (typeof description.annotation === 'string' - ? `Errors validating template "${description.annotation}" description` - : `Errors validating template description`)); + if (!empty(slotErrors)) { + throw new AggregateError(slotErrors, `Errors in slot descriptions`); } return true; @@ -769,3 +782,23 @@ export class Template { return this.content.toString(); } } + +export function stationery(description) { + return new Stationery(description); +} + +export class Stationery { + #templateDescription = null; + + static validated = Symbol('Stationery.validated'); + + constructor(templateDescription) { + Template.validateDescription(templateDescription); + templateDescription[Stationery.validated] = true; + this.#templateDescription = templateDescription; + } + + template() { + return new Template(this.#templateDescription); + } +} diff --git a/src/util/sugar.js b/src/util/sugar.js index 6ab70bc6..3a7e6f82 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -26,18 +26,24 @@ export function* splitArray(array, fn) { } } -// Null-accepting function to check if an array is empty. Accepts null (and -// treats as empty) as a shorthand for "hey, check if this property is an array -// with/without stuff in it" for objects where properties that are PRESENT but -// don't currently have a VALUE are null (instead of undefined). -export function empty(arrayOrNull) { - if (arrayOrNull === null) { +// Null-accepting function to check if an array or set is empty. Accepts null +// (which is treated as empty) as a shorthand for "hey, check if this property +// is an array with/without stuff in it" for objects where properties that are +// PRESENT but don't currently have a VALUE are null (rather than undefined). +export function empty(value) { + if (value === null) { return true; - } else if (Array.isArray(arrayOrNull)) { - return arrayOrNull.length === 0; - } else { - throw new Error(`Expected array or null`); } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (value instanceof Set) { + return value.size === 0; + } + + throw new Error(`Expected array, set, or null`); } // Repeats all the items of an array a number of times. @@ -82,6 +88,16 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); +export function setIntersection(set1, set2) { + const intersection = new Set(); + for (const item of set1) { + if (set2.has(item)) { + intersection.add(item); + } + } + return intersection; +} + export function filterProperties(obj, properties) { const set = new Set(properties); return Object.fromEntries( diff --git a/test/unit/util/html.js b/test/unit/util/html.js index 82f96b48..01a510ec 100644 --- a/test/unit/util/html.js +++ b/test/unit/util/html.js @@ -904,3 +904,31 @@ t.test(`Template - slot value errors`, t => { `arrayOfHTML length: 0`, ]).toString()); }); + +t.test(`Stationery`, t => { + t.plan(3); + + // 1-3: basic behavior + + const stationery1 = new html.Stationery({ + slots: { + slot1: {type: 'string', default: 'apricot'}, + slot2: {type: 'string', default: 'disaster'}, + }, + + content: ({slot1, slot2}) => html.tag('span', `${slot1} ${slot2}`), + }); + + const template1 = stationery1.template(); + const template2 = stationery1.template(); + + template2.setSlots({slot1: 'aquaduct', slot2: 'dichotomy'}); + + const template3 = stationery1.template(); + + template3.setSlots({slot2: 'vinaigrette'}); + + t.equal(template1.toString(), `<span>apricot disaster</span>`); + t.equal(template2.toString(), `<span>aquaduct dichotomy</span>`); + t.equal(template3.toString(), `<span>apricot vinaigrette</span>`); +}); |