diff options
Diffstat (limited to 'src')
21 files changed, 525 insertions, 160 deletions
diff --git a/src/content-function.js b/src/content-function.js index e141a686..04f2ce90 100644 --- a/src/content-function.js +++ b/src/content-function.js @@ -2,7 +2,7 @@ import {inspect as nodeInspect} from 'node:util'; import {decorateError} from '#aggregate'; import {colors, decorateTime, ENABLE_COLOR} from '#cli'; -import {Template} from '#html'; +import {Tag, Template} from '#html'; import {empty} from '#sugar'; function inspect(value, opts = {}) { @@ -103,6 +103,20 @@ function prepareWorkingGenerateFunction(spec, boundExtraDependencies) { } }; + generate = (baseGenerate => (...args) => { + const result = baseGenerate(...args); + + if (result instanceof Template || result instanceof Tag) { + if (Object.hasOwn(result, Symbol.for('hsmusic.content.via'))) { + result[Symbol.for('hsmusic.contentFunction.via')].push(dependency); + } else { + result[Symbol.for('hsmusic.contentFunction.via')] = [dependency]; + } + } + + return result; + })(generate); + generate = optionalDecorateTime(`generate`, dependency, generate); if (spec.slots) { diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index ab8d477d..68722a83 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -40,7 +40,7 @@ export default { generate: (data, relations, slots) => relations.item.slots({ - showArtists: true, + showArtists: 'auto', showDuration: (slots.collapseDurationScope === 'track' diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index 89b66ce0..616b3c95 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -122,6 +122,30 @@ export default { thumb: 'medium', reveal: true, link: true, + + responsiveThumb: true, + responsiveSizes: + // No clamp(), min(), or max() here because Safari. + // The boundaries here are mostly experimental, apart from + // the ones which flat-out switch layouts. + + // Layout - Thin (phones) + // Most of viewport width + '(max-width: 600px) 90vw,\n' + + + // Layout - Medium + // Sidebar is hidden; content area is by definition + // most of the viewport + '(max-width: 640px) 220px,\n' + + '(max-width: 800px) 36vw,\n' + + '(max-width: 850px) 280px,\n' + + + // Layout - Wide + // Sidebar is visible; content area has its own maximum + // Assume the sidebar is at minimum width + '(max-width: 880px) 220px,\n' + + '(max-width: 1050pz) calc(0.40 * (90vw - 150px - 10px)),\n' + + '280px', }), slots.showOriginDetails && diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 86ec6648..935ffdc6 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -63,7 +63,7 @@ export default { relation('generateFlashNavAccent', flash), featuredTracksList: - relation('generateTrackList', flash.featuredTracks), + relation('generateTrackList', flash.featuredTracks, []), contributorContributionList: relation('generateContributionList', flash.contributorContribs), diff --git a/src/content/dependencies/generateNearbyTrackList.js b/src/content/dependencies/generateNearbyTrackList.js new file mode 100644 index 00000000..56ab2df5 --- /dev/null +++ b/src/content/dependencies/generateNearbyTrackList.js @@ -0,0 +1,44 @@ +export default { + query: (tracks, contextTrack, _contextContributions) => ({ + presentedTracks: + (contextTrack + ? tracks.map(track => + track.otherReleases.find(({album}) => album === contextTrack.album) ?? + track) + : tracks), + }), + + relations: (relation, query, _tracks, _contextTrack, contextContributions) => ({ + items: + query.presentedTracks + .map(track => relation('generateTrackListItem', track, contextContributions)), + }), + + slots: { + showArtists: { + validate: v => v.is(true, false, 'auto'), + default: 'auto', + }, + + showDuration: { + type: 'boolean', + default: false, + }, + + colorMode: { + validate: v => v.is('none', 'track', 'line'), + default: 'track', + }, + }, + + generate: (relations, slots, {html}) => + html.tag('ul', + {[html.onlyIfContent]: true}, + + relations.items.map(item => + item.slots({ + showArtists: slots.showArtists, + showDuration: slots.showDuration, + colorMode: slots.colorMode, + }))), +}; diff --git a/src/content/dependencies/generateReferencedTracksList.js b/src/content/dependencies/generateReferencedTracksList.js new file mode 100644 index 00000000..1d566ce9 --- /dev/null +++ b/src/content/dependencies/generateReferencedTracksList.js @@ -0,0 +1,29 @@ +export default { + relations: (relation, track) => ({ + previousProductionTrackList: + relation('generateNearbyTrackList', + track.previousProductionTracks, + track, + track.artistContribs), + + referencedTrackList: + relation('generateNearbyTrackList', + track.referencedTracks, + track, + []), + }), + + generate: (relations, {html, language}) => + html.tag('ul', {[html.onlyIfContent]: true}, [ + html.inside(relations.previousProductionTrackList) + .map(li => html.inside(li)) + .map(label => + html.tag('li', + language.$('trackList.item.previousProduction', + {track: label}))), + + html.inside(relations.referencedTrackList), + ]), +}; + + diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 92e00a41..d3c2d766 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -80,17 +80,20 @@ export default { readCommentaryLine: relation('generateReadCommentaryLine', track), - otherReleasesList: - relation('generateTrackInfoPageOtherReleasesList', track), + otherReleasesLine: + relation('generateTrackInfoPageOtherReleasesLine', track), + + previousProductionLine: + relation('generateTrackInfoPagePreviousProductionLine', track), contributorContributionList: relation('generateContributionList', track.contributorContribs), referencedTracksList: - relation('generateTrackList', track.referencedTracks, track), + relation('generateReferencedTracksList', track), sampledTracksList: - relation('generateTrackList', track.sampledTracks, track), + relation('generateNearbyTrackList', track.sampledTracks, track, []), referencedByTracksList: relation('generateTrackListDividedByGroups', @@ -228,7 +231,11 @@ export default { })), ])), - relations.otherReleasesList, + html.tag('p', {[html.onlyIfContent]: true}, + relations.otherReleasesLine), + + html.tag('p', {[html.onlyIfContent]: true}, + relations.previousProductionLine), html.tags([ relations.contentHeading.clone() diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js new file mode 100644 index 00000000..1793b73f --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesLine.js @@ -0,0 +1,80 @@ +import {onlyItem, stitchArrays} from '#sugar'; + +export default { + query(track) { + const query = {}; + + query.singleSingle = + onlyItem( + track.otherReleases.filter(track => track.album.style === 'single')); + + query.regularReleases = + (query.singleSingle + ? track.otherReleases.filter(track => track !== query.singleSingle) + : track.otherReleases); + + return query; + }, + + relations: (relation, query, _track) => ({ + singleLink: + (query.singleSingle + ? relation('linkTrack', query.singleSingle) + : null), + + trackLinks: + query.regularReleases + .map(track => relation('linkTrack', track)), + }), + + data: (query, _track) => ({ + albumNames: + query.regularReleases + .map(track => track.album.name), + + albumColors: + query.regularReleases + .map(track => track.album.color), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo.alsoReleased', capsule => + language.encapsulate(capsule, workingCapsule => { + const workingOptions = {}; + + let any = false; + + const albumList = + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + albumColor: data.albumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))); + + if (!html.isBlank(albumList)) { + any = true; + workingCapsule += '.onAlbums'; + workingOptions.albums = albumList; + } + + if (relations.singleLink) { + any = true; + workingCapsule += '.asSingle'; + workingOptions.single = + relations.singleLink.slots({ + content: language.$(capsule, 'single'), + }); + } + + if (any) { + return language.$(workingCapsule, workingOptions); + } else { + return html.blank(); + } + })), +}; diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js deleted file mode 100644 index ca6c3fb7..00000000 --- a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js +++ /dev/null @@ -1,83 +0,0 @@ -import {onlyItem, stitchArrays} from '#sugar'; - -export default { - query(track) { - const query = {}; - - query.singleSingle = - onlyItem( - track.otherReleases.filter(track => track.album.style === 'single')); - - query.regularReleases = - (query.singleSingle - ? track.otherReleases.filter(track => track !== query.singleSingle) - : track.otherReleases); - - return query; - }, - - relations: (relation, query, _track) => ({ - singleLink: - (query.singleSingle - ? relation('linkTrack', query.singleSingle) - : null), - - trackLinks: - query.regularReleases - .map(track => relation('linkTrack', track)), - }), - - data: (query, _track) => ({ - albumNames: - query.regularReleases - .map(track => track.album.name), - - albumColors: - query.regularReleases - .map(track => track.album.color), - }), - - generate: (data, relations, {html, language}) => - html.tag('p', - {[html.onlyIfContent]: true}, - - language.encapsulate('releaseInfo.alsoReleased', capsule => - language.encapsulate(capsule, workingCapsule => { - const workingOptions = {}; - - let any = false; - - const albumList = - language.formatConjunctionList( - stitchArrays({ - trackLink: relations.trackLinks, - albumName: data.albumNames, - albumColor: data.albumColors, - }).map(({trackLink, albumName, albumColor}) => - trackLink.slots({ - content: language.sanitize(albumName), - color: albumColor, - }))); - - if (!html.isBlank(albumList)) { - any = true; - workingCapsule += '.onAlbums'; - workingOptions.albums = albumList; - } - - if (relations.singleLink) { - any = true; - workingCapsule += '.asSingle'; - workingOptions.single = - relations.singleLink.slots({ - content: language.$(capsule, 'single'), - }); - } - - if (any) { - return language.$(workingCapsule, workingOptions); - } else { - return html.blank(); - } - }))), -}; diff --git a/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js b/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js new file mode 100644 index 00000000..b2f50cf3 --- /dev/null +++ b/src/content/dependencies/generateTrackInfoPagePreviousProductionLine.js @@ -0,0 +1,37 @@ +import {stitchArrays} from '#sugar'; +import {getKebabCase} from '#wiki-data'; + +export default { + relations: (relation, track) => ({ + trackLinks: + track.followingProductionTracks + .map(track => relation('linkTrack', track)), + + albumLinks: + track.followingProductionTracks + .map(following => + (following.album !== track.album && + getKebabCase(following.name) === getKebabCase(track.name) + + ? relation('linkAlbum', following.album) + : null)), + }), + + generate: (relations, {language}) => + language.encapsulate('releaseInfo.previousProduction', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['tracks'], + + tracks: + stitchArrays({ + trackLink: relations.trackLinks, + albumLink: relations.albumLinks, + }).map(({trackLink, albumLink}) => + (albumLink + ? language.$(capsule, 'trackOnAlbum', { + track: trackLink, + album: albumLink, + }) + : trackLink)), + })), +}; diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js index e30feb23..c259c914 100644 --- a/src/content/dependencies/generateTrackList.js +++ b/src/content/dependencies/generateTrackList.js @@ -1,20 +1,21 @@ export default { - query: (tracks, contextTrack) => ({ - presentedTracks: - (contextTrack - ? tracks.map(track => - track.otherReleases.find(({album}) => album === contextTrack.album) ?? - track) - : tracks), - }), - - relations: (relation, query, _tracks, _contextTrack) => ({ + relations: (relation, tracks, contextContributions) => ({ items: - query.presentedTracks - .map(track => relation('generateTrackListItem', track, [])), + tracks.map(track => + relation('generateTrackListItem', track, contextContributions)), }), slots: { + showArtists: { + validate: v => v.is(true, false, 'auto'), + default: 'auto', + }, + + showDuration: { + type: 'boolean', + default: false, + }, + colorMode: { validate: v => v.is('none', 'track', 'line'), default: 'track', @@ -27,8 +28,8 @@ export default { relations.items.map(item => item.slots({ - showArtists: true, - showDuration: false, + showArtists: slots.showArtists, + showDuration: slots.showDuration, colorMode: slots.colorMode, }))), }; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js index d7342891..419d7c0f 100644 --- a/src/content/dependencies/generateTrackListDividedByGroups.js +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -45,7 +45,7 @@ export default { relations: (relation, query, sprawl, tracks, contextTrack) => ({ flatList: (empty(sprawl.divideTrackListsByGroups) - ? relation('generateTrackList', tracks, contextTrack) + ? relation('generateNearbyTrackList', tracks, contextTrack, []) : null), contentHeading: @@ -57,12 +57,12 @@ export default { groupedTrackLists: query.groupedTracks - .map(tracks => relation('generateTrackList', tracks, contextTrack)), + .map(tracks => relation('generateNearbyTrackList', tracks, contextTrack, [])), ungroupedTrackList: (empty(query.ungroupedTracks) ? null - : relation('generateTrackList', query.ungroupedTracks, contextTrack)), + : relation('generateNearbyTrackList', query.ungroupedTracks, contextTrack, [])), }), data: (query, _sprawl, _tracks) => ({ diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js index 9de9c3a6..c4b7c21e 100644 --- a/src/content/dependencies/generateTrackListItem.js +++ b/src/content/dependencies/generateTrackListItem.js @@ -3,12 +3,18 @@ export default { trackLink: relation('linkTrack', track), - credit: + contextualCredit: relation('generateArtistCredit', track.artistContribs, contextContributions, track.artistText), + acontextualCredit: + relation('generateArtistCredit', + track.artistContribs, + [], + track.artistText), + colorStyle: relation('generateColorStyleAttribute', track.color), @@ -27,12 +33,11 @@ export default { }), slots: { - // showArtists enables showing artists *at all.* It doesn't take precedence - // over behavior which automatically collapses (certain) artists because of - // provided context contributions. + // true always shows artists, false never does; 'auto' shows only if + // the track's artists differ from the given context contributions. showArtists: { - type: 'boolean', - default: true, + validate: v => v.is(true, false, 'auto'), + default: 'auto', }, // If true and the track doesn't have a duration, a missing-duration cue @@ -72,24 +77,33 @@ export default { : relations.missingDuration); } - const artistCapsule = language.encapsulate(itemCapsule, 'withArtists'); - - relations.credit.setSlots({ - normalStringKey: - artistCapsule + '.by', - - featuringStringKey: - artistCapsule + '.featuring', - - normalFeaturingStringKey: - artistCapsule + '.by.featuring', - }); - - if (!html.isBlank(relations.credit)) { - workingCapsule += '.withArtists'; - workingOptions.by = - html.tag('span', {class: 'by'}, - relations.credit); + const chosenCredit = + (slots.showArtists === true + ? relations.acontextualCredit + : slots.showArtists === 'auto' + ? relations.contextualCredit + : null); + + if (chosenCredit) { + const artistCapsule = language.encapsulate(itemCapsule, 'withArtists'); + + chosenCredit.setSlots({ + normalStringKey: + artistCapsule + '.by', + + featuringStringKey: + artistCapsule + '.featuring', + + normalFeaturingStringKey: + artistCapsule + '.by.featuring', + }); + + if (!html.isBlank(chosenCredit)) { + workingCapsule += '.withArtists'; + workingOptions.by = + html.tag('span', {class: 'by'}, + chosenCredit); + } } return language.$(workingCapsule, workingOptions); diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index 1b6b08dd..d979b0bc 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -28,6 +28,8 @@ export default { slots: { thumb: {type: 'string'}, + responsiveThumb: {type: 'boolean', default: false}, + responsiveSizes: {type: 'string'}, reveal: {type: 'boolean', default: true}, lazy: {type: 'boolean', default: false}, @@ -199,31 +201,29 @@ export default { // so it won't be set if thumbnails aren't available. let revealSrc = null; + let originalDimensions; + let availableThumbs; + let selectedThumbtack; + + const getThumbSrc = (thumbtack) => + to('thumb.path', mediaSrc.replace(/\.(png|jpg)$/, `.${thumbtack}.jpg`)); + // If thumbnails are available *and* being used, calculate thumbSrc, // and provide some attributes relevant to the large image overlay. if (hasThumbnails && slots.thumb) { - const selectedSize = + selectedThumbtack = getThumbnailEqualOrSmaller(slots.thumb, mediaSrc); - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); - displaySrc = - to('thumb.path', mediaSrcJpeg); + getThumbSrc(selectedThumbtack); if (willReveal) { - const miniSize = - getThumbnailEqualOrSmaller('mini', mediaSrc); - - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${miniSize}.jpg`); - revealSrc = - to('thumb.path', mediaSrcJpeg); + getThumbSrc(getThumbnailEqualOrSmaller('mini', mediaSrc)); } - const originalDimensions = getDimensionsOfImagePath(mediaSrc); - const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions); + originalDimensions = getDimensionsOfImagePath(mediaSrc); + availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions); const fileSize = (willLink && mediaSrc @@ -239,11 +239,54 @@ export default { !empty(availableThumbs) && {'data-thumbs': availableThumbs - .map(([name, size]) => `${name}:${size}`) + .map(([tack, size]) => `${tack}:${size}`) .join(' ')}, ]); } + let displayStaticImg = + html.tag('img', + imgAttributes, + {src: displaySrc}); + + if (hasThumbnails && slots.responsiveThumb) responsive: { + if (slots.lazy) { + logWarn`${'responsiveThumb'} and ${'lazy'} are used together, but not compatible`; + break responsive; + } + + if (!slots.thumb) { + logWarn`${'responsiveThumb'} must be used alongside a default ${'thumb'}`; + break responsive; + } + + const srcset = [ + // Never load the original source, which might be a very large + // uncompressed file. Bah! + /* [originalSrc, `${Math.min(...originalDimensions)}w`], */ + + ...availableThumbs.map(([tack, size]) => + [getThumbSrc(tack), `${Math.floor(0.95 * size)}w`]), + + // fallback + [displaySrc], + ].map(line => line.join(' ')).join(',\n'); + + displayStaticImg = + html.tag('img', + imgAttributes, + + {sizes: + (slots.responsiveSizes.match(/(?=(?:,|^))\s*\S/) + // slot provided fallback size + ? slots.responsiveSizes + // default fallback size + : slots.responsiveSizes + ',\n' + + new Map(availableThumbs).get(selectedThumbtack) + 'px')}, + + {srcset}); + } + if (!displaySrc) { return ( prepare( @@ -252,10 +295,7 @@ export default { } const images = { - displayStatic: - html.tag('img', - imgAttributes, - {src: displaySrc}), + displayStatic: displayStaticImg, displayLazy: slots.lazy && diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js index 7784afe7..166a857d 100644 --- a/src/content/dependencies/linkThing.js +++ b/src/content/dependencies/linkThing.js @@ -77,14 +77,15 @@ export default { const linkAttributes = slots.attributes; const wrapperAttributes = html.attributes(); + const name = + relations.name.slot('preferShortName', slots.preferShortName); + const showShortName = slots.preferShortName && !data.nameText && data.nameShort && data.nameShort !== data.name; - const name = relations.name; - const showWikiTooltip = (slots.tooltipStyle === 'auto' ? showShortName diff --git a/src/data/checks.js b/src/data/checks.js index e68b2ed0..4786f16b 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -297,6 +297,7 @@ export function filterReferenceErrors(wikiData, { artistContribs: '_contrib', contributorContribs: '_contrib', coverArtistContribs: '_contrib', + previousProductionTracks: '_trackMainReleasesOnly', referencedTracks: '_trackMainReleasesOnly', sampledTracks: '_trackMainReleasesOnly', artTags: '_artTag', diff --git a/src/data/things/language.js b/src/data/things/language.js index 91774761..43f69f3d 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -354,13 +354,19 @@ export class Language extends Thing { partInProgress += template.slice(lastIndex, match.index); - for (const insertionItem of html.smush(insertion).content) { + const insertionItems = html.smush(insertion).content; + if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') { + // Push the insertion exactly as it is, rather than manipulating. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertion); + partInProgress = ''; + } else for (const insertionItem of insertionItems) { if (typeof insertionItem === 'string') { // Join consecutive strings together. partInProgress += insertionItem; } else { // Push the string part in progress, then the insertion as-is. - outputParts.push(partInProgress); + if (partInProgress) outputParts.push(partInProgress); outputParts.push(insertionItem); partInProgress = ''; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 93193b6a..64790a61 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -393,6 +393,17 @@ export class Track extends Thing { // > Update & expose - Referenced tracks + previousProductionTracks: [ + inheritFromMainRelease({ + notFoundValue: input.value([]), + }), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], + referencedTracks: [ inheritFromMainRelease({ notFoundValue: input.value([]), @@ -563,6 +574,10 @@ export class Track extends Thing { }), ], + followingProductionTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreFollowingProductionsOf'), + }), + referencedByTracks: reverseReferenceList({ reverse: soupyReverse.input('tracksWhichReference'), }), @@ -701,6 +716,7 @@ export class Track extends Thing { // Referenced tracks + 'Previous Productions': {property: 'previousProductionTracks'}, 'Referenced Tracks': {property: 'referencedTracks'}, 'Sampled Tracks': {property: 'sampledTracks'}, @@ -912,6 +928,13 @@ export class Track extends Thing { referencing: track => track.isSecondaryRelease ? [track] : [], referenced: track => [track.mainReleaseTrack], }, + + tracksWhichAreFollowingProductionsOf: { + bindTo: 'trackData', + + referencing: track => track, + referenced: track => track.previousProductionTracks, + }, }; // Track YAML loading is handled in album.js. diff --git a/src/html.js b/src/html.js index 444edd6a..4cac9525 100644 --- a/src/html.js +++ b/src/html.js @@ -1698,6 +1698,61 @@ export function smooth(smoothie) { return tags(helper(smoothie)); } +export function inside(insidee) { + if (insidee instanceof Template) { + return inside(Template.resolve(insidee)); + } + + if (insidee instanceof Tag) { + return Array.from(smooth(tags(insidee.content)).content); + } + + return []; +} + +export function findInside(insidee, query) { + if (typeof query === 'object' && query.slots) { + return findInside(insidee, item => + Template.resolveForSlots(item, query.slots, 'null')); + } + + if (typeof query === 'object' && query.annotation) { + return findInside(insidee, item => + Template.resolveForAnnotation(item, query.annotation, 'null')); + } + + if (typeof query === 'object' && query.tag) { + return findInside(insidee, item => { + const tag = normalize(item); + if (tag.tagName === query) { + return tag; + } else { + return null; + } + }); + } + + if (typeof query === 'string') { + return findInside(insidee, item => + Template.resolveForContentFunction(item, query, 'null')); + } + + if (typeof query !== 'function') { + throw new Error(`Expected {slots}, {annotation}, or query function`); + } + + for (const item of inside(insidee)) { + const result = query(item); + if (result && result === true) { + return item; + } else if (result) { + return result; + } + } + + return null; +} + export function template(description) { return new Template(description); } @@ -2109,7 +2164,7 @@ export class Template { return content; } - static resolveForSlots(content, slots) { + static resolveForSlots(content, slots, without = 'throw') { if (!slots || typeof slots !== 'object') { throw new Error( `Expected slots to be an object or array, ` + @@ -2132,9 +2187,72 @@ export class Template { } } - throw new Error( - `Didn't find slots ${inspect(slots, {compact: true})} ` + - `resolving ${inspect(content, {compact: true})}`); + if (without === 'throw') { + throw new Error( + `Didn't find slots ${inspect(slots, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } + } + + static resolveForAnnotation(content, annotation, without = 'throw') { + if (!annotation || typeof annotation !== 'string') { + throw new Error( + `Expected annotation to be a string, ` + + `got ${typeAppearance(annotation)}`); + } + + while (content instanceof Template) { + if (content.description.annotation === annotation) { + return content; + } else { + content = content.content; + } + } + + if (without === 'throw') { + throw new Error( + `Didn't find annotation ${inspect(annotation, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } + } + + static resolveForContentFunction(content, dependency, without = 'throw') { + if (!dependency || typeof dependency !== 'string') { + throw new Error( + `Expected dependency to be a string, ` + + `got ${typeAppearance(dependency)}`); + } + + const considerContentFunction = () => + (content instanceof Tag || content instanceof Template) && + Object.hasOwn(content, Symbol.for('hsmusic.contentFunction.via')) && + content[Symbol.for('hsmusic.contentFunction.via')].includes(dependency); + + while (content instanceof Template) { + if (considerContentFunction()) { + return content; + } else if (content.description.annotation === dependency) { + return content; + } else { + content = content.content; + } + } + + if (considerContentFunction()) { + return content; + } + + if (without === 'throw') { + throw new Error( + `Didn't find dependency ${inspect(dependency, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } } [inspect.custom]() { diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js index 6809726a..0595bff7 100644 --- a/src/static/js/client/image-overlay.js +++ b/src/static/js/client/image-overlay.js @@ -147,7 +147,8 @@ function getImageLinkDetails(imageLink) { a.href, embeddedSrc: - img?.src ?? + img?.src || + img?.currentSrc || a.dataset.embedSrc, originalFileSize: diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 5bbecbf3..85d849db 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -301,6 +301,11 @@ releaseInfo: single: "as a single" + previousProduction: + _: "This track is a previous version or production of {TRACKS}." + + trackOnAlbum: "{TRACK} (on {ALBUM})" + tracksReferenced: _: "Tracks that {TRACK} references:" sticky: "Tracks that this one references:" @@ -462,6 +467,9 @@ trackList: rerelease: >- {TRACK} (rerelease) + previousProduction: >- + {TRACK} (previous version or production) + # # misc: # |