From 60b6715b38d137f8d6d0ce3c537a546a507ecf1f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 20 Aug 2023 21:22:15 -0300 Subject: content: listArtistsByName: divide by main groups --- src/content/dependencies/listArtistsByName.js | 123 ++++++++++++++++++++------ src/data/things/artist.js | 17 ++++ src/strings-default.json | 2 + 3 files changed, 113 insertions(+), 29 deletions(-) diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js index 6c0ad836..d83150e8 100644 --- a/src/content/dependencies/listArtistsByName.js +++ b/src/content/dependencies/listArtistsByName.js @@ -1,51 +1,116 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays, unique} from '#sugar'; import {getArtistNumContributions, sortAlphabetically} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkArtist'], + contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], extraDependencies: ['language', 'wikiData'], - sprawl({artistData}) { - return {artistData}; + sprawl({artistData, wikiInfo}) { + return {artistData, wikiInfo}; }, - query({artistData}, spec) { - return { - spec, + query(sprawl, spec) { + const artists = sortAlphabetically(sprawl.artistData.slice()); + const groups = sprawl.wikiInfo.divideTrackListsByGroups; - artists: sortAlphabetically(artistData.slice()), - }; + if (empty(groups)) { + return {spec, artists}; + } + + const artistGroups = + artists.map(artist => + unique( + unique([ + ...artist.albumsAsAny, + ...artist.tracksAsAny.map(track => track.album), + ]).flatMap(album => album.groups))) + + const artistsByGroup = + groups.map(group => + artists.filter((artist, index) => artistGroups[index].includes(group))); + + return {spec, groups, artistsByGroup}; }, relations(relation, query) { - return { - page: relation('generateListingPage', query.spec), + const relations = {}; - artistLinks: + relations.page = + relation('generateListingPage', query.spec); + + if (query.artists) { + relations.artistLinks = query.artists - .map(artist => relation('linkArtist', artist)), - }; + .map(artist => relation('linkArtist', artist)); + } + + if (query.artistsByGroup) { + relations.groupLinks = + query.groups + .map(group => relation('linkGroup', group)); + + relations.artistLinksByGroup = + query.artistsByGroup + .map(artists => artists + .map(artist => relation('linkArtist', artist))); + } + + return relations; }, data(query) { - return { - counts: + const data = {}; + + if (query.artists) { + data.counts = query.artists - .map(artist => getArtistNumContributions(artist)), - }; + .map(artist => getArtistNumContributions(artist)); + } + + if (query.artistsByGroup) { + data.countsByGroup = + query.artistsByGroup + .map(artists => artists + .map(artist => getArtistNumContributions(artist))); + } + + return data; }, generate(data, relations, {language}) { - return relations.page.slots({ - type: 'rows', - rows: - stitchArrays({ - link: relations.artistLinks, - count: data.counts, - }).map(({link, count}) => ({ - artist: link, - contributions: language.countContributions(count, {unit: true}), - })), - }); + return ( + (relations.artistLinksByGroup + ? relations.page.slots({ + type: 'chunks', + + chunkTitles: + relations.groupLinks.map(groupLink => ({ + group: groupLink, + })), + + chunkRows: + stitchArrays({ + artistLinks: relations.artistLinksByGroup, + counts: data.countsByGroup, + }).map(({artistLinks, counts}) => + stitchArrays({ + link: artistLinks, + count: counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + }))), + }) + : relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + })), + }))); }, }; diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 522ca5f9..6d4f4a0d 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -99,6 +99,23 @@ export class Artist extends Thing { albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'), + albumsAsAny: { + flags: {expose: true}, + + expose: { + dependencies: ['albumData'], + + compute: ({albumData, [Artist.instance]: artist}) => + albumData?.filter((album) => + [ + ...album.artistContribs, + ...album.coverArtistContribs, + ...album.wallpaperArtistContribs, + ...album.bannerArtistContribs, + ].some(({who}) => who === artist)) ?? [], + }, + }, + albumsAsCommentator: { flags: {expose: true}, diff --git a/src/strings-default.json b/src/strings-default.json index 8d7756ad..2b4b0981 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -367,6 +367,8 @@ "listingPage.listArtists.byName.title": "Artists - by Name", "listingPage.listArtists.byName.title.short": "...by Name", "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byName.chunk.title": "Contributed to {GROUP}:", + "listingPage.listArtists.byName.chunk.item": "{ARTIST} ({CONTRIBUTIONS})", "listingPage.listArtists.byContribs.title": "Artists - by Contributions", "listingPage.listArtists.byContribs.title.short": "...by Contributions", "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})", -- cgit 1.3.0-6-gf8a5 From e035dab576875bca12485f60a1aeb257c394c723 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 20 Aug 2023 22:07:05 -0300 Subject: content: generateListingPage: "skip to a section" --- src/content/dependencies/generateListingPage.js | 38 +++++++++++++++++++++++-- src/content/dependencies/listArtistsByName.js | 19 ++++++++++++- src/strings-default.json | 1 + 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 08eb40c6..4de2d006 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -7,6 +7,7 @@ export default { 'generatePageLayout', 'linkListing', 'linkListingIndex', + 'linkTemplate', ], extraDependencies: ['html', 'language', 'wikiData'], @@ -26,6 +27,9 @@ export default { relations.chunkHeading = relation('generateContentHeading'); + relations.showSkipToSectionLinkTemplate = + relation('linkTemplate'); + if (listing.target.listings.length > 1) { relations.sameTargetListingLinks = listing.target.listings @@ -65,6 +69,9 @@ export default { chunkTitles: {validate: v => v.strictArrayOf(v.isObject)}, chunkRows: {validate: v => v.strictArrayOf(v.isObject)}, + showSkipToSection: {type: 'boolean', default: false}, + chunkIDs: {validate: v => v.strictArrayOf(v.isString)}, + listStyle: { validate: v => v.is('ordered', 'unordered'), default: 'unordered', @@ -128,16 +135,40 @@ export default { formatListingString('item', row)))), slots.type === 'chunks' && - html.tag('dl', + html.tag('dl', [ + slots.showSkipToSection && [ + html.tag('dt', + language.$('listingPage.skipToSection')), + + html.tag('dd', + html.tag('ul', + stitchArrays({ + title: slots.chunkTitles, + id: slots.chunkIDs, + }).filter(({id}) => id) + .map(({title, id}) => + html.tag('li', + relations.showSkipToSectionLinkTemplate + .clone() + .slots({ + hash: id, + content: + formatListingString('chunk.title', title) + .replace(/:$/, ''), + }))))), + ], + stitchArrays({ title: slots.chunkTitles, rows: slots.chunkRows, - }).map(({title, rows}) => [ + id: slots.chunkIDs, + }).map(({title, rows, id}) => [ relations.chunkHeading .clone() .slots({ tag: 'dt', title: formatListingString('chunk.title', title), + id, }), html.tag('dd', @@ -146,7 +177,8 @@ export default { html.tag('li', {class: row.stringsKey === 'rerelease' && 'rerelease'}, formatListingString('chunk.item', row))))), - ])), + ]), + ]), slots.type === 'custom' && slots.content, diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js index d83150e8..3778b9e3 100644 --- a/src/content/dependencies/listArtistsByName.js +++ b/src/content/dependencies/listArtistsByName.js @@ -1,5 +1,10 @@ import {empty, stitchArrays, unique} from '#sugar'; -import {getArtistNumContributions, sortAlphabetically} from '#wiki-data'; + +import { + filterMultipleArrays, + getArtistNumContributions, + sortAlphabetically, +} from '#wiki-data'; export default { contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], @@ -29,6 +34,9 @@ export default { groups.map(group => artists.filter((artist, index) => artistGroups[index].includes(group))); + filterMultipleArrays(groups, artistsByGroup, + (group, artists) => !empty(artists)); + return {spec, groups, artistsByGroup}; }, @@ -68,6 +76,10 @@ export default { } if (query.artistsByGroup) { + data.groupDirectories = + query.groups + .map(group => group.directory); + data.countsByGroup = query.artistsByGroup .map(artists => artists @@ -83,6 +95,11 @@ export default { ? relations.page.slots({ type: 'chunks', + showSkipToSection: true, + chunkIDs: + data.groupDirectories + .map(directory => `contributed-to-${directory}`), + chunkTitles: relations.groupLinks.map(groupLink => ({ group: groupLink, diff --git a/src/strings-default.json b/src/strings-default.json index 2b4b0981..2cab3a19 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -348,6 +348,7 @@ "listingPage.target.other": "Other", "listingPage.listingsFor": "Listings for {TARGET}: {LISTINGS}", "listingPage.seeAlso": "Also check out: {LISTINGS}", + "listingPage.skipToSection": "Skip to a section:", "listingPage.listAlbums.byName.title": "Albums - by Name", "listingPage.listAlbums.byName.title.short": "...by Name", "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})", -- cgit 1.3.0-6-gf8a5 From f12605331f60796da178f3d4b7bbc096f02b0d48 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 09:34:29 -0300 Subject: content: move new "Artists - by Name" implementation to "by Group" --- src/content/dependencies/listArtistsByGroup.js | 133 ++++++++++++++++++++++ src/content/dependencies/listArtistsByName.js | 151 ++++++------------------- src/listing-spec.js | 10 ++ src/strings-default.json | 7 +- 4 files changed, 180 insertions(+), 121 deletions(-) create mode 100644 src/content/dependencies/listArtistsByGroup.js diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js new file mode 100644 index 00000000..3778b9e3 --- /dev/null +++ b/src/content/dependencies/listArtistsByGroup.js @@ -0,0 +1,133 @@ +import {empty, stitchArrays, unique} from '#sugar'; + +import { + filterMultipleArrays, + getArtistNumContributions, + sortAlphabetically, +} from '#wiki-data'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], + extraDependencies: ['language', 'wikiData'], + + sprawl({artistData, wikiInfo}) { + return {artistData, wikiInfo}; + }, + + query(sprawl, spec) { + const artists = sortAlphabetically(sprawl.artistData.slice()); + const groups = sprawl.wikiInfo.divideTrackListsByGroups; + + if (empty(groups)) { + return {spec, artists}; + } + + const artistGroups = + artists.map(artist => + unique( + unique([ + ...artist.albumsAsAny, + ...artist.tracksAsAny.map(track => track.album), + ]).flatMap(album => album.groups))) + + const artistsByGroup = + groups.map(group => + artists.filter((artist, index) => artistGroups[index].includes(group))); + + filterMultipleArrays(groups, artistsByGroup, + (group, artists) => !empty(artists)); + + return {spec, groups, artistsByGroup}; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + if (query.artists) { + relations.artistLinks = + query.artists + .map(artist => relation('linkArtist', artist)); + } + + if (query.artistsByGroup) { + relations.groupLinks = + query.groups + .map(group => relation('linkGroup', group)); + + relations.artistLinksByGroup = + query.artistsByGroup + .map(artists => artists + .map(artist => relation('linkArtist', artist))); + } + + return relations; + }, + + data(query) { + const data = {}; + + if (query.artists) { + data.counts = + query.artists + .map(artist => getArtistNumContributions(artist)); + } + + if (query.artistsByGroup) { + data.groupDirectories = + query.groups + .map(group => group.directory); + + data.countsByGroup = + query.artistsByGroup + .map(artists => artists + .map(artist => getArtistNumContributions(artist))); + } + + return data; + }, + + generate(data, relations, {language}) { + return ( + (relations.artistLinksByGroup + ? relations.page.slots({ + type: 'chunks', + + showSkipToSection: true, + chunkIDs: + data.groupDirectories + .map(directory => `contributed-to-${directory}`), + + chunkTitles: + relations.groupLinks.map(groupLink => ({ + group: groupLink, + })), + + chunkRows: + stitchArrays({ + artistLinks: relations.artistLinksByGroup, + counts: data.countsByGroup, + }).map(({artistLinks, counts}) => + stitchArrays({ + link: artistLinks, + count: counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + }))), + }) + : relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + })), + }))); + }, +}; diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js index 3778b9e3..554b4587 100644 --- a/src/content/dependencies/listArtistsByName.js +++ b/src/content/dependencies/listArtistsByName.js @@ -1,133 +1,46 @@ -import {empty, stitchArrays, unique} from '#sugar'; - -import { - filterMultipleArrays, - getArtistNumContributions, - sortAlphabetically, -} from '#wiki-data'; +import {stitchArrays} from '#sugar'; +import {getArtistNumContributions, sortAlphabetically} from '#wiki-data'; export default { contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], extraDependencies: ['language', 'wikiData'], - sprawl({artistData, wikiInfo}) { - return {artistData, wikiInfo}; - }, - - query(sprawl, spec) { - const artists = sortAlphabetically(sprawl.artistData.slice()); - const groups = sprawl.wikiInfo.divideTrackListsByGroups; - - if (empty(groups)) { - return {spec, artists}; - } - - const artistGroups = - artists.map(artist => - unique( - unique([ - ...artist.albumsAsAny, - ...artist.tracksAsAny.map(track => track.album), - ]).flatMap(album => album.groups))) - - const artistsByGroup = - groups.map(group => - artists.filter((artist, index) => artistGroups[index].includes(group))); - - filterMultipleArrays(groups, artistsByGroup, - (group, artists) => !empty(artists)); - - return {spec, groups, artistsByGroup}; - }, - - relations(relation, query) { - const relations = {}; + sprawl: ({artistData, wikiInfo}) => + ({artistData, wikiInfo}), - relations.page = - relation('generateListingPage', query.spec); + query: (sprawl, spec) => ({ + spec, - if (query.artists) { - relations.artistLinks = - query.artists - .map(artist => relation('linkArtist', artist)); - } + artists: + sortAlphabetically(sprawl.artistData.slice()), + }), - if (query.artistsByGroup) { - relations.groupLinks = - query.groups - .map(group => relation('linkGroup', group)); + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), - relations.artistLinksByGroup = - query.artistsByGroup - .map(artists => artists - .map(artist => relation('linkArtist', artist))); - } + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }), - return relations; - }, - - data(query) { - const data = {}; - - if (query.artists) { - data.counts = - query.artists - .map(artist => getArtistNumContributions(artist)); - } - - if (query.artistsByGroup) { - data.groupDirectories = - query.groups - .map(group => group.directory); - - data.countsByGroup = - query.artistsByGroup - .map(artists => artists - .map(artist => getArtistNumContributions(artist))); - } - - return data; - }, + data: (query) => ({ + counts: + query.artists + .map(artist => getArtistNumContributions(artist)), + }), generate(data, relations, {language}) { - return ( - (relations.artistLinksByGroup - ? relations.page.slots({ - type: 'chunks', - - showSkipToSection: true, - chunkIDs: - data.groupDirectories - .map(directory => `contributed-to-${directory}`), - - chunkTitles: - relations.groupLinks.map(groupLink => ({ - group: groupLink, - })), - - chunkRows: - stitchArrays({ - artistLinks: relations.artistLinksByGroup, - counts: data.countsByGroup, - }).map(({artistLinks, counts}) => - stitchArrays({ - link: artistLinks, - count: counts, - }).map(({link, count}) => ({ - artist: link, - contributions: language.countContributions(count, {unit: true}), - }))), - }) - : relations.page.slots({ - type: 'rows', - rows: - stitchArrays({ - link: relations.artistLinks, - count: data.counts, - }).map(({link, count}) => ({ - artist: link, - contributions: language.countContributions(count, {unit: true}), - })), - }))); + return relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artistLinks, + count: data.counts, + }).map(({link, count}) => ({ + artist: link, + contributions: language.countContributions(count, {unit: true}), + })), + }); }, }; diff --git a/src/listing-spec.js b/src/listing-spec.js index fe36fc01..7918dd1e 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -54,12 +54,14 @@ listingSpec.push({ directory: 'artists/by-name', stringsKey: 'listArtists.byName', contentFunction: 'listArtistsByName', + seeAlso: ['artists/by-contribs', 'artists/by-group'], }); listingSpec.push({ directory: 'artists/by-contribs', stringsKey: 'listArtists.byContribs', contentFunction: 'listArtistsByContributions', + seeAlso: ['artists/by-name', 'artists/by-group'], }); listingSpec.push({ @@ -74,6 +76,14 @@ listingSpec.push({ contentFunction: 'listArtistsByDuration', }); +listingSpec.push({ + directory: 'artists/by-group', + stringsKey: 'listArtists.byGroup', + contentFunction: 'listArtistsByGroup', + featureFlag: 'enableGroupUI', + seeAlso: ['artists/by-name', 'artists/by-contribs'], +}); + listingSpec.push({ directory: 'artists/by-latest', stringsKey: 'listArtists.byLatest', diff --git a/src/strings-default.json b/src/strings-default.json index 2cab3a19..457b4bed 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -368,8 +368,6 @@ "listingPage.listArtists.byName.title": "Artists - by Name", "listingPage.listArtists.byName.title.short": "...by Name", "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byName.chunk.title": "Contributed to {GROUP}:", - "listingPage.listArtists.byName.chunk.item": "{ARTIST} ({CONTRIBUTIONS})", "listingPage.listArtists.byContribs.title": "Artists - by Contributions", "listingPage.listArtists.byContribs.title.short": "...by Contributions", "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})", @@ -379,6 +377,11 @@ "listingPage.listArtists.byDuration.title": "Artists - by Duration", "listingPage.listArtists.byDuration.title.short": "...by Duration", "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", + "listingPage.listArtists.byGroup.title": "Artists - by Group", + "listingPage.listArtists.byGroup.title.short": "...by Group", + "listingPage.listArtists.byGroup.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byGroup.chunk.title": "Contributed to {GROUP}:", + "listingPage.listArtists.byGroup.chunk.item": "{ARTIST} ({CONTRIBUTIONS})", "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution", "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})", -- cgit 1.3.0-6-gf8a5 From 168425e7a3dd3a268cfdbd2a395e11ac72eaa3b2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 09:57:50 -0300 Subject: content: generateListingPage: don't mutate options in slots --- src/content/dependencies/generateListingPage.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 4de2d006..3c50bd59 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -91,12 +91,13 @@ export default { const parts = [baseStringsKey, contextStringsKey]; - if (options.stringsKey) { + const {stringsKey, ...passOptions} = options; + + if (stringsKey) { parts.push(options.stringsKey); - delete options.stringsKey; } - return language.formatString(parts.join('.'), options); + return language.formatString(parts.join('.'), passOptions); }; return relations.layout.slots({ -- cgit 1.3.0-6-gf8a5 From 6b76c7a0c852b87b3ff2e4f3a11fe15424618511 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 09:58:22 -0300 Subject: content: listArtistsByContributions: use updated chunk layout --- .../dependencies/listArtistsByContributions.js | 116 ++++++++++----------- src/strings-default.json | 5 +- 2 files changed, 58 insertions(+), 63 deletions(-) diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js index 86c8cfa2..58c51a40 100644 --- a/src/content/dependencies/listArtistsByContributions.js +++ b/src/content/dependencies/listArtistsByContributions.js @@ -1,5 +1,11 @@ -import {stitchArrays, unique} from '#sugar'; -import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data'; +import {empty, stitchArrays, unique} from '#sugar'; + +import { + filterByCount, + filterMultipleArrays, + sortAlphabetically, + sortByCount, +} from '#wiki-data'; export default { contentDependencies: ['generateListingPage', 'linkArtist'], @@ -96,68 +102,54 @@ export default { return data; }, - generate(data, relations, {html, language}) { - const lists = Object.fromEntries( - ([ - ['tracks', [ - relations.artistLinksByTrackContributions, - data.countsByTrackContributions, - 'countTracks', - ]], - - ['artworks', [ - relations.artistLinksByArtworkContributions, - data.countsByArtworkContributions, - 'countArtworks', - ]], - - data.enableFlashesAndGames && - ['flashes', [ - relations.artistLinksByFlashContributions, - data.countsByFlashContributions, - 'countFlashes', - ]], - ]).filter(Boolean) - .map(([key, [artistLinks, counts, countFunction]]) => [ - key, - html.tag('ul', - stitchArrays({ - artistLink: artistLinks, - count: counts, - }).map(({artistLink, count}) => - html.tag('li', - language.$('listingPage.listArtists.byContribs.item', { - artist: artistLink, - contributions: language[countFunction](count, {unit: true}), - })))), - ])); + generate(data, relations, {language}) { + const listChunkIDs = ['tracks', 'artworks', 'flashes']; + const listTitleStringsKeys = ['trackContributors', 'artContributors', 'flashContributors']; + const listCountFunctions = ['countTracks', 'countArtworks', 'countFlashes']; + + const listArtistLinks = [ + relations.artistLinksByTrackContributions, + relations.artistLinksByArtworkContributions, + relations.artistLinksByFlashContributions, + ]; + + const listArtistCounts = [ + data.countsByTrackContributions, + data.countsByArtworkContributions, + data.countsByFlashContributions, + ]; + + filterMultipleArrays( + listChunkIDs, + listTitleStringsKeys, + listCountFunctions, + listArtistLinks, + listArtistCounts, + (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) => + !empty(artistLinks)); return relations.page.slots({ - type: 'custom', - content: - html.tag('div', {class: 'content-columns'}, [ - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$('listingPage.misc.trackContributors')), - - lists.tracks, - ]), - - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$( - 'listingPage.misc.artContributors')), - - lists.artworks, - - lists.flashes && [ - html.tag('h2', - language.$('listingPage.misc.flashContributors')), - - lists.flashes, - ], - ]), - ]), + type: 'chunks', + + showSkipToSection: true, + chunkIDs: listChunkIDs, + + chunkTitles: + listTitleStringsKeys.map(stringsKey => ({stringsKey})), + + chunkRows: + stitchArrays({ + artistLinks: listArtistLinks, + artistCounts: listArtistCounts, + countFunction: listCountFunctions, + }).map(({artistLinks, artistCounts, countFunction}) => + stitchArrays({ + artistLink: artistLinks, + artistCount: artistCounts, + }).map(({artistLink, artistCount}) => ({ + artist: artistLink, + contributions: language[countFunction](artistCount, {unit: true}), + }))), }); }, }; diff --git a/src/strings-default.json b/src/strings-default.json index 457b4bed..d170b86b 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -370,7 +370,10 @@ "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", "listingPage.listArtists.byContribs.title": "Artists - by Contributions", "listingPage.listArtists.byContribs.title.short": "...by Contributions", - "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})", + "listingPage.listArtists.byContribs.chunk.title.trackContributors": "Contributed tracks:", + "listingPage.listArtists.byContribs.chunk.title.artContributors": "Contributed artworks:", + "listingPage.listArtists.byContribs.chunk.title.flashContributors": "Contributed to flashes & games:", + "listingPage.listArtists.byContribs.chunk.item": "{ARTIST} ({CONTRIBUTIONS})", "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries", "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries", "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})", -- cgit 1.3.0-6-gf8a5 From 715bd8eff86c20b3773a66e96e9c9d6ae8602644 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 19:19:39 -0300 Subject: content: listArtistsByLatestContribution: chunk-based rework --- .../listArtistsByLatestContribution.js | 594 ++++++++++----------- src/strings-default.json | 6 +- 2 files changed, 279 insertions(+), 321 deletions(-) diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js index b6ea8e96..edb02e0d 100644 --- a/src/content/dependencies/listArtistsByLatestContribution.js +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -1,15 +1,16 @@ -import {transposeArrays, empty, stitchArrays} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; +import T from '#things'; import { chunkMultipleArrays, - compareCaseLessSensitive, - compareDates, - filterMultipleArrays, - reduceMultipleArrays, sortAlphabetically, + sortAlbumsTracksChronologically, + sortFlashesChronologically, sortMultipleArrays, } from '#wiki-data'; +const {Album, Flash} = T; + export default { contentDependencies: [ 'generateListingPage', @@ -20,348 +21,303 @@ export default { extraDependencies: ['html', 'language', 'wikiData'], - sprawl({artistData, wikiInfo}) { - return { - artistData, - enableFlashesAndGames: wikiInfo.enableFlashesAndGames, - }; - }, + sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) => + ({albumData, artistData, flashData, trackData, + enableFlashesAndGames: wikiInfo.enableFlashesAndGames}), query(sprawl, spec) { - const query = { - spec, - enableFlashesAndGames: sprawl.enableFlashesAndGames, - }; - - const queryContributionInfo = ( - artistsKey, - chunkThingsKey, - datesKey, - datelessArtistsKey, - fn, - ) => { - const artists = sortAlphabetically(sprawl.artistData.slice()); - - // Each value stored in dateLists, corresponding to each artist, - // is going to be a list of dates and nulls. Any nulls represent - // a contribution which isn't associated with a particular date. - const [chunkThingLists, dateLists] = - transposeArrays(artists.map(artist => fn(artist))); - - // Scrap artists who don't even have any relevant contributions. - // These artists may still have other contributions across the wiki, but - // they weren't returned by the callback and so aren't relevant to this - // list. - filterMultipleArrays( - artists, - chunkThingLists, - dateLists, - (artists, chunkThings, dates) => !empty(dates)); - - // Also exclude artists whose remaining contributions are all dateless. - // But keep track of the artists removed here, since they'll be displayed - // in an additional list in the final listing page. - const {removed: [datelessArtists]} = - filterMultipleArrays( - artists, - chunkThingLists, - dateLists, - (artist, chunkThings, dates) => !empty(dates.filter(Boolean))); - - // Cut out dateless contributions. They're not relevant to finding the - // latest date. - for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) { - filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date); + // + // First main step is to get the latest thing each artist has contributed + // to, and the date associated with that contribution! Some notes: + // + // * Album and track contributions are considered before flashes, so + // they'll take priority if an artist happens to have multiple contribs + // landing on the same date to both an album and a flash. + // + // * The final (album) contribution list is chunked by album, but also by + // date, because an individual album can cover a variety of dates. + // + // * If an artist has contributed both artworks and tracks to the album + // containing their latest contribution, then that will be indicated + // in an annotation, but *only if* those contributions were also on + // the same date. + // + // * If an artist made contributions to multiple albums on the same date, + // then the first of the *albums* sorted chronologically (latest first) + // is the one that will count. + // + // * Same for artists who've contributed to multiple flashes which were + // released on the same date. + // + // * The map may exclude artists none of whose contributions were dated. + // + + const artistLatestContribMap = new Map(); + + const considerDate = (artist, date, thing, contribution) => { + if (!date) { + return; } - const [chunkThings, dates] = - transposeArrays( - transposeArrays([chunkThingLists, dateLists]) - .map(([chunkThings, dates]) => - reduceMultipleArrays( - chunkThings, dates, - (accChunkThing, accDate, chunkThing, date) => - (date && date > accDate - ? [chunkThing, date] - : [accChunkThing, accDate])))); - - sortMultipleArrays(artists, dates, chunkThings, - (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => { - const dateComparison = compareDates(dateA, dateB, {latestFirst: true}); - if (dateComparison !== 0) { - return dateComparison; - } - - // TODO: Compare alphabetically, not just by directory. - return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory); - }); - - const chunks = - chunkMultipleArrays(artists, dates, chunkThings, - (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) => - +date !== +lastDate || chunkThing !== lastChunkThing); + if (artistLatestContribMap.has(artist)) { + const latest = artistLatestContribMap.get(artist); + if (latest.date > date) { + return; + } - query[chunkThingsKey] = - chunks.map(([artists, dates, chunkThings]) => chunkThings[0]); - - query[datesKey] = - chunks.map(([artists, dates, chunkThings]) => dates[0]); + if (latest.date === date) { + if (latest.thing === thing) { + // May combine differnt contributions to the same thing and date. + latest.contribution.add(contribution); + } - query[artistsKey] = - chunks.map(([artists, dates, chunkThings]) => artists); + // Earlier-processed things of same date take priority. + return; + } + } - query[datelessArtistsKey] = datelessArtists; + // First entry for artist or more recent contribution than latest date. + artistLatestContribMap.set(artist, { + date, + thing, + contribution: new Set([contribution]), + }); }; - queryContributionInfo( - 'artistsByTrackContributions', - 'albumsByTrackContributions', - 'datesByTrackContributions', - 'datelessArtistsByTrackContributions', - artist => { - const tracks = - [...artist.tracksAsArtist, ...artist.tracksAsContributor] - .filter(track => !track.originalReleaseTrack); - - const albums = tracks.map(track => track.album); - const dates = tracks.map(track => track.date); + const getArtists = (thing, key) => thing[key].map(({who}) => who); - return [albums, dates]; - }); + const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice()); + const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice()); + const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice()); - queryContributionInfo( - 'artistsByArtworkContributions', - 'albumsByArtworkContributions', - 'datesByArtworkContributions', - 'datelessArtistsByArtworkContributions', - artist => [ - [ - ...artist.tracksAsCoverArtist.map(track => track.album), - ...artist.albumsAsCoverArtist, - ...artist.albumsAsWallpaperArtist, - ...artist.albumsAsBannerArtist, - ], - [ - // TODO: Per-artwork dates, see #90. - ...artist.tracksAsCoverArtist.map(track => track.coverArtDate), - ...artist.albumsAsCoverArtist.map(album => album.coverArtDate), - ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate), - ...artist.albumsAsBannerArtist.map(album => album.coverArtDate), - ], - ]); - - if (sprawl.enableFlashesAndGames) { - queryContributionInfo( - 'artistsByFlashContributions', - 'flashesByFlashContributions', - 'datesByFlashContributions', - 'datelessArtistsByFlashContributions', - artist => [ - [ - ...artist.flashesAsContributor, - ], - [ - ...artist.flashesAsContributor.map(flash => flash.date), - ], - ]); + for (const album of albumsLatestFirst) { + for (const artist of new Set([ + ...getArtists(album, 'coverArtistContribs'), + ...getArtists(album, 'wallpaperArtistContribs'), + ...getArtists(album, 'bannerArtistContribs'), + ])) { + // Might combine later with 'track' of the same album and date. + considerDate(artist, album.coverArtDate, album, 'artwork'); + } } - return query; - }, - - relations(relation, query) { - const relations = {}; - - relations.page = - relation('generateListingPage', query.spec); - - // Track contributors - - relations.albumLinksByTrackContributions = - query.albumsByTrackContributions - .map(album => relation('linkAlbum', album)); - - relations.artistLinksByTrackContributions = - query.artistsByTrackContributions - .map(artists => - artists.map(artist => relation('linkArtist', artist))); - - relations.datelessArtistLinksByTrackContributions = - query.datelessArtistsByTrackContributions - .map(artist => relation('linkArtist', artist)); - - // Artwork contributors - - relations.albumLinksByArtworkContributions = - query.albumsByArtworkContributions - .map(album => relation('linkAlbum', album)); + for (const track of tracksLatestFirst) { + for (const artist of getArtists(track, 'coverArtistContribs')) { + // No special effect if artist already has 'artwork' for the same album and date. + considerDate(artist, track.coverArtDate, track.album, 'artwork'); + } - relations.artistLinksByArtworkContributions = - query.artistsByArtworkContributions - .map(artists => - artists.map(artist => relation('linkArtist', artist))); + for (const artist of new Set([ + ...getArtists(track, 'artistContribs'), + ...getArtists(track, 'contributorContribs'), + ])) { + // Might be combining with 'artwork' of the same album and date. + considerDate(artist, track.date, track.album, 'track'); + } + } - relations.datelessArtistLinksByArtworkContributions = - query.datelessArtistsByArtworkContributions - .map(artist => relation('linkArtist', artist)); + for (const flash of flashesLatestFirst) { + for (const artist of getArtists(flash, 'contributorContribs')) { + // Won't take priority above album contributions of the same date. + considerDate(artist, flash.date, flash, 'flash'); + } + } - // Flash contributors + // + // Next up is to sort all the processed artist information! + // + // Entries with the same album/flash and the same date go together first, + // with the following rules for sorting artists therein: + // + // * If the contributions are different, which can only happen for albums, + // then it's tracks-only first, tracks + artworks next, and artworks-only + // last. + // + // * If the contributions are the same, then sort alphabetically. + // + // Entries with different albums/flashes follow another set of rules: + // + // * Later dates come before earlier dates. + // + // * On the same date, albums come before flashes. + // + // * Things of the same type *and* date are sorted alphabetically. + // + + const artistsAlphabetically = + sortAlphabetically(sprawl.artistData.slice()); + + const artists = + Array.from(artistLatestContribMap.keys()); + + const artistContribEntries = + Array.from(artistLatestContribMap.values()); + + const artistThings = + artistContribEntries.map(({thing}) => thing); + + const artistDates = + artistContribEntries.map(({date}) => date); + + const artistContributions = + artistContribEntries.map(({contribution}) => contribution); + + sortMultipleArrays(artistThings, artistDates, artistContributions, artists, + (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => { + if (date1 === date2 && thing1 === thing2) { + // Move artwork-only contribs after contribs with tracks. + if (!contrib1.has('track') && contrib2.has('track')) return 1; + if (!contrib2.has('track') && contrib1.has('track')) return -1; + + // Move track-only contribs before tracks with tracks and artwork. + if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1; + if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1; + + // Sort artists of the same type of contribution alphabetically, + // referring to a previous sort. + const index1 = artistsAlphabetically.indexOf(artist1); + const index2 = artistsAlphabetically.indexOf(artist2); + return index1 - index2; + } else { + // Move later dates before earlier ones. + if (date1 !== date2) return date2 - date1; + + // Move albums before flashes. + if (thing1 instanceof Album && thing2 instanceof Flash) return -1; + if (thing1 instanceof Flash && thing2 instanceof Album) return 1; + + // Sort two albums or two flashes alphabetically, referring to a + // previous sort (which was chronological but includes the correct + // ordering for things released on the same date). + const thingsLatestFirst = + (thing1 instanceof Album + ? albumsLatestFirst + : flashesLatestFirst); + const index1 = thingsLatestFirst.indexOf(thing1); + const index2 = thingsLatestFirst.indexOf(thing2); + return index2 - index1; + } + }); - if (query.enableFlashesAndGames) { - relations.flashLinksByFlashContributions = - query.flashesByFlashContributions - .map(flash => relation('linkFlash', flash)); + // Last off, turn the flat sorted list into a proper chunked list, now that + // entries going in the same chunk are sorted correctly next to each other. + // Then extract the parts that are useful for displaying on the listing! - relations.artistLinksByFlashContributions = - query.artistsByFlashContributions - .map(artists => - artists.map(artist => relation('linkArtist', artist))); + const chunks = + chunkMultipleArrays(artistThings, artistDates, artistContributions, artists, + (thing, lastThing, date, lastDate) => + thing !== lastThing || + +date !== +lastDate); - relations.datelessArtistLinksByFlashContributions = - query.datelessArtistsByFlashContributions - .map(artist => relation('linkArtist', artist)); - } + const chunkThings = + chunks.map(([artistThings, , , ]) => artistThings[0]); - return relations; - }, + const chunkDates = + chunks.map(([, artistDates, , ]) => artistDates[0]); - data(query) { - const data = {}; + const chunkArtistContributions = + chunks.map(([, , artistContributions, ]) => artistContributions); - data.enableFlashesAndGames = query.enableFlashesAndGames; + const chunkArtists = + chunks.map(([, , , artists]) => artists); - data.datesByTrackContributions = query.datesByTrackContributions; - data.datesByArtworkContributions = query.datesByArtworkContributions; + // And one bonus step - keep track of all the artists whose contributions + // were all without date. - if (query.enableFlashesAndGames) { - data.datesByFlashContributions = query.datesByFlashContributions; - } + const datelessArtists = + artistsAlphabetically + .filter(artist => !artists.includes(artist)); - return data; + return { + spec, + chunkThings, + chunkDates, + chunkArtistContributions, + chunkArtists, + datelessArtists, + }; }, - generate(data, relations, {html, language}) { - const chunkTitles = Object.fromEntries( - ([ - ['tracks', [ - 'album', - relations.albumLinksByTrackContributions, - data.datesByTrackContributions, - ]], - - ['artworks', [ - 'album', - relations.albumLinksByArtworkContributions, - data.datesByArtworkContributions, - ]], - - data.enableFlashesAndGames && - ['flashes', [ - 'flash', - relations.flashLinksByFlashContributions, - data.datesByFlashContributions, - ]], - ]).filter(Boolean) - .map(([key, [stringsKey, links, dates]]) => [ - key, - stitchArrays({link: links, date: dates}) - .map(({link, date}) => - html.tag('dt', - language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, { - [stringsKey]: link, - date: language.formatDate(date), - }))), - ])); - - const chunkItems = Object.fromEntries( - ([ - ['tracks', relations.artistLinksByTrackContributions], - ['artworks', relations.artistLinksByArtworkContributions], - data.enableFlashesAndGames && - ['flashes', relations.artistLinksByFlashContributions], - ]).filter(Boolean) - .map(([key, artistLinkLists]) => [ - key, - artistLinkLists.map(artistLinks => - html.tag('dd', - html.tag('ul', - artistLinks.map(artistLink => - html.tag('li', - language.$('listingPage.listArtists.byLatest.chunk.item', { - artist: artistLink, - })))))), - ])); - - const lists = Object.fromEntries( - ([ - ['tracks', [ - chunkTitles.tracks, - chunkItems.tracks, - relations.datelessArtistLinksByTrackContributions, - ]], - - ['artworks', [ - chunkTitles.artworks, - chunkItems.artworks, - relations.datelessArtistLinksByArtworkContributions, - ]], - - data.enableFlashesAndGames && - ['flashes', [ - chunkTitles.flashes, - chunkItems.flashes, - relations.datelessArtistLinksByFlashContributions, - ]], - ]).filter(Boolean) - .map(([key, [titles, items, datelessArtistLinks]]) => [ - key, - html.tags([ - html.tag('dl', - stitchArrays({ - title: titles, - items: items, - }).map(({title, items}) => [title, items])), - - !empty(datelessArtistLinks) && [ - html.tag('p', - language.$('listingPage.listArtists.byLatest.dateless.title')), - - html.tag('ul', - datelessArtistLinks.map(artistLink => - html.tag('li', - language.$('listingPage.listArtists.byLatest.dateless.item', { - artist: artistLink, - })))), - ], - ]), - ])); - + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), + + chunkAlbumLinks: + query.chunkThings + .map(thing => + (thing instanceof Album + ? relation('linkAlbum', thing) + : null)), + + chunkFlashLinks: + query.chunkThings + .map(thing => + (thing instanceof Flash + ? relation('linkFlash', thing) + : null)), + + chunkArtistLinks: + query.chunkArtists + .map(artists => artists + .map(artist => relation('linkArtist', artist))), + + datelessArtistLinks: + query.datelessArtists + .map(artist => relation('linkArtist', artist)), + }), + + data: (query) => ({ + chunkDates: query.chunkDates, + chunkArtistContributions: query.chunkArtistContributions, + }), + + generate(data, relations, {language}) { return relations.page.slots({ - type: 'custom', - content: - html.tag('div', {class: 'content-columns'}, [ - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$('listingPage.misc.trackContributors')), - - lists.tracks, - ]), - - html.tag('div', {class: 'column'}, [ - html.tag('h2', - language.$( - 'listingPage.misc.artContributors')), - - lists.artworks, - - lists.flashes && [ - html.tag('h2', - language.$('listingPage.misc.flashContributors')), - - lists.flashes, - ], - ]), - ]), + type: 'chunks', + + chunkTitles: + stitchArrays({ + albumLink: relations.chunkAlbumLinks, + flashLink: relations.chunkFlashLinks, + date: data.chunkDates, + }).map(({albumLink, flashLink, date}) => ({ + date: language.formatDate(date), + ...(albumLink + ? {stringsKey: 'album', album: albumLink} + : {stringsKey: 'flash', flash: flashLink}), + })) + .concat( + (empty(relations.datelessArtistLinks) + ? [] + : [{stringsKey: 'dateless'}])), + + chunkRows: + stitchArrays({ + artistLinks: relations.chunkArtistLinks, + contributions: data.chunkArtistContributions, + }).map(({artistLinks, contributions}) => + stitchArrays({ + artistLink: artistLinks, + contribution: contributions, + }).map(({artistLink, contribution}) => ({ + artist: artistLink, + stringsKey: + (contribution.has('track') && contribution.has('artwork') + ? 'tracksAndArt' + : contribution.has('track') + ? 'tracks' + : contribution.has('artwork') + ? 'art' + : null), + }))) + .concat( + (empty(relations.datelessArtistLinks) + ? [] + : [ + relations.datelessArtistLinks.map(artistLink => ({ + artist: artistLink, + })), + ])), }); }, }; diff --git a/src/strings-default.json b/src/strings-default.json index d170b86b..e893a5ce 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -389,9 +389,11 @@ "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution", "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})", "listingPage.listArtists.byLatest.chunk.title.flash": "{FLASH} ({DATE})", + "listingPage.listArtists.byLatest.chunk.title.dateless": "These artists' contributions aren't dated:", "listingPage.listArtists.byLatest.chunk.item": "{ARTIST}", - "listingPage.listArtists.byLatest.dateless.title": "These artists' contributions aren't dated:", - "listingPage.listArtists.byLatest.dateless.item": "{ARTIST}", + "listingPage.listArtists.byLatest.chunk.item.tracks": "{ARTIST} (tracks)", + "listingPage.listArtists.byLatest.chunk.item.tracksAndArt": "{ARTIST} (tracks, art)", + "listingPage.listArtists.byLatest.chunk.item.art": "{ARTIST} (art)", "listingPage.listGroups.byName.title": "Groups - by Name", "listingPage.listGroups.byName.title.short": "...by Name", "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", -- cgit 1.3.0-6-gf8a5 From c4ef4ced62d659d217873c6c48dd8038dbf765af Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 10:49:03 -0300 Subject: content: generateListingPage: show custom content above auto content This is towards enabling custom controls and/or accents on listings which are otherwise represented by rows or chunks. --- src/content/dependencies/generateListingPage.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 3c50bd59..f527f16f 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -129,6 +129,8 @@ export default { listings: language.formatUnitList(relations.seeAlsoLinks), })), + slots.content, + slots.type === 'rows' && html.tag(listTag, slots.rows.map(row => @@ -180,9 +182,6 @@ export default { formatListingString('chunk.item', row))))), ]), ]), - - slots.type === 'custom' && - slots.content, ], navLinkStyle: 'hierarchical', -- cgit 1.3.0-6-gf8a5 From c59545f5faafc826ff24ff779c20318ef14ae123 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 29 Oct 2023 09:27:23 -0300 Subject: content, client: generalize "random pages" listing to wiki dividing groups --- .../generateListRandomPageLinksGroupSection.js | 9 ++--- src/content/dependencies/listRandomPageLinks.js | 28 +++++--------- src/static/client2.js | 45 ++++++++++++++++++++-- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/content/dependencies/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js index 2a684b19..74872724 100644 --- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js +++ b/src/content/dependencies/generateListRandomPageLinksGroupSection.js @@ -23,10 +23,7 @@ export default { .map(() => relation('generateColorStyleVariables')), }), - data: (query, sprawl, group) => ({ - groupDirectory: - group.directory, - + data: (query) => ({ albumColors: query.albums .map(album => album.color), @@ -48,12 +45,12 @@ export default { randomAlbum: html.tag('a', - {href: '#', 'data-random': 'album-in-' + data.groupDirectory}, + {href: '#', 'data-random': 'album-in-group-dl'}, language.$('listingPage.other.randomPages.group.randomAlbum')), randomTrack: html.tag('a', - {href: '#', 'data-random': 'track-in-' + data.groupDirectory}, + {href: '#', 'data-random': 'track-in-group-dl'}, language.$('listingPage.other.randomPages.group.randomTrack')), })), diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 43bf7dd5..57ecb044 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -6,19 +6,16 @@ export default { extraDependencies: ['html', 'language', 'wikiData'], - sprawl({groupData}) { - return {groupData}; + sprawl({wikiInfo}) { + return {wikiInfo}; }, query(sprawl, spec) { - const group = directory => - sprawl.groupData.find(group => group.directory === directory); - return { spec, - officialGroup: group('official'), - fandomGroup: group('fandom'), - beyondGroup: group('beyond'), + + groups: + sprawl.wikiInfo.divideTrackListsByGroups, }; }, @@ -26,14 +23,9 @@ export default { return { page: relation('generateListingPage', query.spec), - officialSection: - relation('generateListRandomPageLinksGroupSection', query.officialGroup), - - fandomSection: - relation('generateListRandomPageLinksGroupSection', query.fandomGroup), - - beyondSection: - relation('generateListRandomPageLinksGroupSection', query.beyondGroup), + groupSections: + query.groups + .map(group => relation('generateListRandomPageLinksGroupSection', group)), }; }, @@ -81,9 +73,7 @@ export default { language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))), ])), - relations.officialSection, - relations.fandomSection, - relations.beyondSection, + relations.groupSections, ]), ], }); diff --git a/src/static/client2.js b/src/static/client2.js index 758d91a6..3a5f9c37 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -149,6 +149,47 @@ function addRandomLinkListeners() { a.href = openAlbum(pick(albumData).directory); break; + case 'track': + a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); + break; + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + a.href = openAlbum(pick(albumDirectories)); + break; + } + + case 'track-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + const filteredAlbumData = + albumData.filter(album => + albumDirectories.includes(album.directory)); + + a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); + break; + } + + /* Legacy links, for old versions * + * of generateListRandomPageLinksGroupSection */ + case 'album-in-official': a.href = openAlbum(pick(officialAlbumData).directory); break; @@ -161,9 +202,7 @@ function addRandomLinkListeners() { a.href = openAlbum(pick(beyondAlbumData).directory); break; - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; + /* End legacy links */ case 'track-in-album': a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); -- cgit 1.3.0-6-gf8a5 From 777fbd3535f80fb8fb28d80a5dc53efe44c6cb82 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 29 Oct 2023 09:49:56 -0300 Subject: content: linkTemplate: append provided style to own style --- src/content/dependencies/linkTemplate.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js index d9af726c..a361a4e7 100644 --- a/src/content/dependencies/linkTemplate.js +++ b/src/content/dependencies/linkTemplate.js @@ -64,6 +64,14 @@ export default { style = `--primary-color: ${primary}; --dim-color: ${dim}`; } + if (slots.attributes?.style) { + if (style) { + style += '; ' + slots.attributes.style; + } else { + style = slots.attributes.style; + } + } + if (slots.tooltip) { title = slots.tooltip; } -- cgit 1.3.0-6-gf8a5 From d2903c800b6a005a447cc26f9431fe5fe4fb08b6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 29 Oct 2023 09:51:01 -0300 Subject: content: adapt "random pages" to wikis without dividing groups --- .../generateListRandomPageLinksAlbumLink.js | 18 +++++++++ .../generateListRandomPageLinksAllAlbumsSection.js | 35 ++++++++++++++++ .../generateListRandomPageLinksGroupSection.js | 47 +++++----------------- src/content/dependencies/listRandomPageLinks.js | 40 +++++++++--------- src/strings-default.json | 7 ++-- 5 files changed, 89 insertions(+), 58 deletions(-) create mode 100644 src/content/dependencies/generateListRandomPageLinksAlbumLink.js create mode 100644 src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js new file mode 100644 index 00000000..b3560aca --- /dev/null +++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js @@ -0,0 +1,18 @@ +export default { + contentDependencies: ['linkAlbum'], + + data: (album) => + ({directory: album.directory}), + + relations: (relation, album) => + ({albumLink: relation('linkAlbum', album)}), + + generate: (data, relations) => + relations.albumLink.slots({ + anchor: true, + attributes: { + 'data-random': 'track-in-album', + 'style': `--album-directory: ${data.directory}`, + }, + }), +}; diff --git a/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js b/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js new file mode 100644 index 00000000..e03252c9 --- /dev/null +++ b/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js @@ -0,0 +1,35 @@ +import {sortChronologically} from '#wiki-data'; + +export default { + contentDependencies: ['generateListRandomPageLinksAlbumLink', 'linkGroup'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({albumData}) => ({albumData}), + + query: (sprawl) => ({ + albums: + sortChronologically(sprawl.albumData.slice()) + .filter(album => album.tracks.length > 1), + }), + + relations: (relation, query) => ({ + albumLinks: + query.albums + .map(album => relation('generateListRandomPageLinksAlbumLink', album)), + }), + + generate: (relations, {html, language}) => + html.tags([ + html.tag('dt', + language.$('listingPage.other.randomPages.fromAlbum')), + + html.tag('dd', + html.tag('ul', + relations.albumLinks + .map(albumLink => + html.tag('li', + language.$('listingPage.other.randomPages.album', { + album: albumLink, + }))))), + ]), +}; diff --git a/src/content/dependencies/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js index 74872724..d05be8f7 100644 --- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js +++ b/src/content/dependencies/generateListRandomPageLinksGroupSection.js @@ -1,8 +1,7 @@ -import {stitchArrays} from '#sugar'; import {sortChronologically} from '#wiki-data'; export default { - contentDependencies: ['generateColorStyleVariables', 'linkGroup'], + contentDependencies: ['generateListRandomPageLinksAlbumLink', 'linkGroup'], extraDependencies: ['html', 'language', 'wikiData'], sprawl: ({albumData}) => ({albumData}), @@ -18,61 +17,35 @@ export default { groupLink: relation('linkGroup', group), - albumColorVariables: + albumLinks: query.albums - .map(() => relation('generateColorStyleVariables')), + .map(album => relation('generateListRandomPageLinksAlbumLink', album)), }), - data: (query) => ({ - albumColors: - query.albums - .map(album => album.color), - - albumDirectories: - query.albums - .map(album => album.directory), - - albumNames: - query.albums - .map(album => album.name), - }), - - generate: (data, relations, {html, language}) => + generate: (relations, {html, language}) => html.tags([ html.tag('dt', - language.$('listingPage.other.randomPages.group', { + language.$('listingPage.other.randomPages.fromGroup', { group: relations.groupLink, randomAlbum: html.tag('a', {href: '#', 'data-random': 'album-in-group-dl'}, - language.$('listingPage.other.randomPages.group.randomAlbum')), + language.$('listingPage.other.randomPages.fromGroup.randomAlbum')), randomTrack: html.tag('a', {href: '#', 'data-random': 'track-in-group-dl'}, - language.$('listingPage.other.randomPages.group.randomTrack')), + language.$('listingPage.other.randomPages.fromGroup.randomTrack')), })), html.tag('dd', html.tag('ul', - stitchArrays({ - colorVariables: relations.albumColorVariables, - color: data.albumColors, - directory: data.albumDirectories, - name: data.albumNames, - }).map(({colorVariables, color, directory, name}) => + relations.albumLinks + .map(albumLink => html.tag('li', language.$('listingPage.other.randomPages.album', { - album: - html.tag('a', { - href: '#', - 'data-random': 'track-in-album', - style: - colorVariables.slot('color', color).content + - '; ' + - `--album-directory: ${directory}`, - }, name), + album: albumLink, }))))), ]), }; diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 57ecb044..ce90a153 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -1,33 +1,36 @@ +import {empty} from '#sugar'; + export default { contentDependencies: [ 'generateListingPage', + 'generateListRandomPageLinksAllAlbumsSection', 'generateListRandomPageLinksGroupSection', ], extraDependencies: ['html', 'language', 'wikiData'], - sprawl({wikiInfo}) { - return {wikiInfo}; - }, + sprawl: ({wikiInfo}) => ({wikiInfo}), - query(sprawl, spec) { - return { - spec, + query: ({wikiInfo: {divideTrackListsByGroups: groups}}, spec) => ({ + spec, + groups, + divideByGroups: !empty(groups), + }), - groups: - sprawl.wikiInfo.divideTrackListsByGroups, - }; - }, + relations: (relation, query) => ({ + page: relation('generateListingPage', query.spec), - relations(relation, query) { - return { - page: relation('generateListingPage', query.spec), + allAlbumsSection: + (query.divideByGroups + ? null + : relation('generateListRandomPageLinksAllAlbumsSection')), - groupSections: - query.groups - .map(group => relation('generateListRandomPageLinksGroupSection', group)), - }; - }, + groupSections: + (query.divideByGroups + ? query.groups + .map(group => relation('generateListRandomPageLinksGroupSection', group)) + : null), + }), generate(relations, {html, language}) { return relations.page.slots({ @@ -73,6 +76,7 @@ export default { language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))), ])), + relations.allAlbumsSection, relations.groupSections, ]), ], diff --git a/src/strings-default.json b/src/strings-default.json index 6c841e72..81bf5d48 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -493,9 +493,10 @@ "listingPage.other.randomPages.misc.atLeastTwoContributions": "at least 2 contributions", "listingPage.other.randomPages.misc.randomAlbumWholeSite": "Random Album (whole site)", "listingPage.other.randomPages.misc.randomTrackWholeSite": "Random Track (whole site)", - "listingPage.other.randomPages.group": "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})", - "listingPage.other.randomPages.group.randomAlbum": "Random Album", - "listingPage.other.randomPages.group.randomTrack": "Random Track", + "listingPage.other.randomPages.fromAlbum": "From an album:", + "listingPage.other.randomPages.fromGroup": "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})", + "listingPage.other.randomPages.fromGroup.randomAlbum": "Random Album", + "listingPage.other.randomPages.fromGroup.randomTrack": "Random Track", "listingPage.other.randomPages.album": "{ALBUM}", "listingPage.misc.trackContributors": "Track Contributors", "listingPage.misc.artContributors": "Art Contributors", -- cgit 1.3.0-6-gf8a5 From e71230340181a3b7b38ff05ba23504b264f5b26c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 29 Oct 2023 10:00:52 -0300 Subject: content: listRandomPageLinks: update chooseLinkLine wording --- src/content/dependencies/listRandomPageLinks.js | 10 +++++++++- src/strings-default.json | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index ce90a153..599a82d3 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -37,7 +37,15 @@ export default { type: 'custom', content: [ html.tag('p', - language.$('listingPage.other.randomPages.chooseLinkLine')), + language.$('listingPage.other.randomPages.chooseLinkLine', { + fromPart: + (empty(relations.groupSections) + ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups') + : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')), + + browserSupportPart: + language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'), + })), html.tag('p', {class: 'js-hide-once-data'}, diff --git a/src/strings-default.json b/src/strings-default.json index 81bf5d48..b0b68a57 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -485,7 +485,10 @@ "listingPage.other.allAdditionalFiles.file.withMultipleFiles": "{TITLE} ({FILES})", "listingPage.other.randomPages.title": "Random Pages", "listingPage.other.randomPages.title.short": "Random Pages", - "listingPage.other.randomPages.chooseLinkLine": "Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.", + "listingPage.other.randomPages.chooseLinkLine": "{FROM_PART} {BROWSER_SUPPORT_PART}", + "listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups": "Choose a link to go to a random page in that group or album!", + "listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups": "Choose a link to go to a random page in that album!", + "listingPage.other.randomPages.chooseLinkLine.browserSupportPart": "If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.", "listingPage.other.randomPages.dataLoadingLine": "(Data files are downloading in the background! Please wait for data to load.)", "listingPage.other.randomPages.dataLoadedLine": "(Data files have finished being downloaded. The links should work!)", "listingPage.other.randomPages.misc": "Miscellaneous:", -- cgit 1.3.0-6-gf8a5 From 150c414044662134ddf785e7411560e3a6051a03 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 14:50:13 -0400 Subject: content: listRandomPageLinks: don't hard-code parentheses --- src/content/dependencies/listRandomPageLinks.js | 23 ++++++++++++----------- src/strings-default.yaml | 6 ++++-- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 599a82d3..87e5f5aa 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -61,17 +61,18 @@ export default { html.tag('dd', html.tag('ul', [ - html.tag('li', [ - html.tag('a', - {href: '#', 'data-random': 'artist'}, - language.$('listingPage.other.randomPages.misc.randomArtist')), - - '(' + - html.tag('a', - {href: '#', 'data-random': 'artist-more-than-one-contrib'}, - language.$('listingPage.other.randomPages.misc.atLeastTwoContributions')) + - ')', - ]), + html.tag('li', + language.$('listingPage.other.randomPages.misc.randomArtist', { + mainLink: + html.tag('a', + {href: '#', 'data-random': 'artist'}, + language.$('listingPage.other.randomPages.misc.randomArtist.mainLink')), + + atLeastTwoContributions: + html.tag('a', + {href: '#', 'data-random': 'artist-more-than-one-contrib'}, + language.$('listingPage.other.randomPages.misc.randomArtist.atLeastTwoContributions')), + })), html.tag('li', html.tag('a', diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 6e975de7..bb244279 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -1574,8 +1574,10 @@ listingPage: misc: _: "Miscellaneous:" - randomArtist: "Random Artist" - atLeastTwoContributions: "at least 2 contributions" + randomArtist: + _: "{MAIN_LINK} ({AT_LEAST_TWO_CONTRIBUTIONS})" + mainLink: "Random Artist" + atLeastTwoContributions: "at least 2 contributions" randomAlbumWholeSite: "Random Album (whole site)" randomTrackWholeSite: "Random Track (whole site)" -- cgit 1.3.0-6-gf8a5 From 443c2e42ad2731e63f40c9575e2c27001ed55bae Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 15:48:36 -0400 Subject: content: generateListingPage: add chunkRowAttributes slot This refactors out the hard-coded 'rerelease' behavior. --- src/content/dependencies/generateListingPage.js | 24 ++++++++++++++++++------ src/content/dependencies/listTracksByDate.js | 9 ++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 45b7dc1b..403f891f 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -68,6 +68,7 @@ export default { chunkTitles: {validate: v => v.strictArrayOf(v.isObject)}, chunkRows: {validate: v => v.strictArrayOf(v.isObject)}, + chunkRowAttributes: {validate: v => v.strictArrayOf(v.optional(v.isObject))}, showSkipToSection: {type: 'boolean', default: false}, chunkIDs: {validate: v => v.strictArrayOf(v.isString)}, @@ -165,9 +166,17 @@ export default { stitchArrays({ title: slots.chunkTitles, - rows: slots.chunkRows, id: slots.chunkIDs, - }).map(({title, rows, id}) => [ + + rows: slots.chunkRows, + rowAttributes: slots.chunkRowAttributes, + }).map(({ + title, + id, + + rows, + rowAttributes, + }) => [ relations.chunkHeading .clone() .slots({ @@ -178,10 +187,13 @@ export default { html.tag('dd', html.tag(listTag, - rows.map(row => - html.tag('li', - {class: row.stringsKey === 'rerelease' && 'rerelease'}, - formatListingString('chunk.item', row))))), + stitchArrays({ + row: rows, + attributes: rowAttributes ?? rows.map(() => null), + }).map(({row, attributes}) => + html.tag('li', + attributes, + formatListingString('chunk.item', row))))), ]), ]), ], diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js index d6546e67..25beb739 100644 --- a/src/content/dependencies/listTracksByDate.js +++ b/src/content/dependencies/listTracksByDate.js @@ -71,8 +71,15 @@ export default { rerelease: rereleases, }).map(({trackLink, rerelease}) => (rerelease - ? {track: trackLink, stringsKey: 'rerelease'} + ? {stringsKey: 'rerelease', track: trackLink} : {track: trackLink}))), + + chunkRowAttributes: + data.rereleases.map(rereleases => + rereleases.map(rerelease => + (rerelease + ? {class: 'rerelease'} + : null))), }); }, }; -- cgit 1.3.0-6-gf8a5 From 44cebe7bfaf8f69ff6806e98524d3b5955f2cef2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 15:49:31 -0400 Subject: content: generateListingPage: specially handle 'href' row attribute But not that specially. --- src/content/dependencies/generateListingPage.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 403f891f..4c86431d 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -191,9 +191,14 @@ export default { row: rows, attributes: rowAttributes ?? rows.map(() => null), }).map(({row, attributes}) => - html.tag('li', - attributes, - formatListingString('chunk.item', row))))), + (attributes?.href + ? html.tag('li', + html.tag('a', + attributes, + formatListingString('chunk.item', row))) + : html.tag('li', + attributes, + formatListingString('chunk.item', row)))))), ]), ]), ], -- cgit 1.3.0-6-gf8a5 From 2a7c3b90a8d66cea9b0f041a33f4c145628587eb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 15:58:50 -0400 Subject: content: generateListingPage: code cleanup, add rowAttributes slot --- src/content/dependencies/generateListingPage.js | 90 +++++++++++++++---------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 4c86431d..3878d0eb 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -62,16 +62,38 @@ export default { }, slots: { - type: {validate: v => v.is('rows', 'chunks', 'custom')}, + type: { + validate: v => v.is('rows', 'chunks', 'custom'), + }, + + rows: { + validate: v => v.strictArrayOf(v.isObject), + }, + + rowAttributes: { + validate: v => v.strictArrayOf(v.optional(v.isObject)) + }, + + chunkTitles: { + validate: v => v.strictArrayOf(v.isObject), + }, + + chunkRows: { + validate: v => v.strictArrayOf(v.isObject), + }, - rows: {validate: v => v.strictArrayOf(v.isObject)}, + chunkRowAttributes: { + validate: v => v.strictArrayOf(v.optional(v.isObject)), + }, - chunkTitles: {validate: v => v.strictArrayOf(v.isObject)}, - chunkRows: {validate: v => v.strictArrayOf(v.isObject)}, - chunkRowAttributes: {validate: v => v.strictArrayOf(v.optional(v.isObject))}, + showSkipToSection: { + type: 'boolean', + default: false, + }, - showSkipToSection: {type: 'boolean', default: false}, - chunkIDs: {validate: v => v.strictArrayOf(v.isString)}, + chunkIDs: { + validate: v => v.strictArrayOf(v.isString), + }, listStyle: { validate: v => v.is('ordered', 'unordered'), @@ -82,11 +104,6 @@ export default { }, generate(data, relations, slots, {html, language}) { - const listTag = - (slots.listStyle === 'ordered' - ? 'ol' - : 'ul'); - const formatListingString = (contextStringsKey, options = {}) => { const baseStringsKey = `listingPage.${data.stringsKey}`; @@ -101,6 +118,24 @@ export default { return language.formatString(parts.join('.'), passOptions); }; + const formatRow = ({row, attributes}) => + (attributes?.href + ? html.tag('li', + html.tag('a', + attributes, + formatListingString('chunk.item', row))) + : html.tag('li', + attributes, + formatListingString('chunk.item', row))); + + const formatRowList = ({rows, rowAttributes}) => + html.tag( + (slots.listStyle === 'ordered' ? 'ol' : 'ul'), + stitchArrays({ + row: rows, + attributes: rowAttributes ?? rows.map(() => null), + }).map(formatRow)); + return relations.layout.slots({ title: formatListingString('title'), headingMode: 'sticky', @@ -133,10 +168,10 @@ export default { slots.content, slots.type === 'rows' && - html.tag(listTag, - slots.rows.map(row => - html.tag('li', - formatListingString('item', row)))), + formatRowList({ + rows: slots.rows, + rowAttributes: slots.rowAttributes, + }), slots.type === 'chunks' && html.tag('dl', [ @@ -167,16 +202,9 @@ export default { stitchArrays({ title: slots.chunkTitles, id: slots.chunkIDs, - rows: slots.chunkRows, rowAttributes: slots.chunkRowAttributes, - }).map(({ - title, - id, - - rows, - rowAttributes, - }) => [ + }).map(({title, id, rows, rowAttributes}) => [ relations.chunkHeading .clone() .slots({ @@ -186,19 +214,7 @@ export default { }), html.tag('dd', - html.tag(listTag, - stitchArrays({ - row: rows, - attributes: rowAttributes ?? rows.map(() => null), - }).map(({row, attributes}) => - (attributes?.href - ? html.tag('li', - html.tag('a', - attributes, - formatListingString('chunk.item', row))) - : html.tag('li', - attributes, - formatListingString('chunk.item', row)))))), + formatRowList({rows, rowAttributes})), ]), ]), ], -- cgit 1.3.0-6-gf8a5 From 7166db580d57504b5ec42d9d07078ea24f0b1149 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 16:01:05 -0400 Subject: content: listRandomPageLinks: port to chunks layout --- .../generateListRandomPageLinksAllAlbumsSection.js | 35 ---- .../generateListRandomPageLinksGroupSection.js | 51 ------ src/content/dependencies/listRandomPageLinks.js | 181 ++++++++++++++------- src/strings-default.yaml | 66 ++++---- 4 files changed, 156 insertions(+), 177 deletions(-) delete mode 100644 src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js delete mode 100644 src/content/dependencies/generateListRandomPageLinksGroupSection.js diff --git a/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js b/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js deleted file mode 100644 index e03252c9..00000000 --- a/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js +++ /dev/null @@ -1,35 +0,0 @@ -import {sortChronologically} from '#wiki-data'; - -export default { - contentDependencies: ['generateListRandomPageLinksAlbumLink', 'linkGroup'], - extraDependencies: ['html', 'language', 'wikiData'], - - sprawl: ({albumData}) => ({albumData}), - - query: (sprawl) => ({ - albums: - sortChronologically(sprawl.albumData.slice()) - .filter(album => album.tracks.length > 1), - }), - - relations: (relation, query) => ({ - albumLinks: - query.albums - .map(album => relation('generateListRandomPageLinksAlbumLink', album)), - }), - - generate: (relations, {html, language}) => - html.tags([ - html.tag('dt', - language.$('listingPage.other.randomPages.fromAlbum')), - - html.tag('dd', - html.tag('ul', - relations.albumLinks - .map(albumLink => - html.tag('li', - language.$('listingPage.other.randomPages.album', { - album: albumLink, - }))))), - ]), -}; diff --git a/src/content/dependencies/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js deleted file mode 100644 index d05be8f7..00000000 --- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js +++ /dev/null @@ -1,51 +0,0 @@ -import {sortChronologically} from '#wiki-data'; - -export default { - contentDependencies: ['generateListRandomPageLinksAlbumLink', 'linkGroup'], - extraDependencies: ['html', 'language', 'wikiData'], - - sprawl: ({albumData}) => ({albumData}), - - query: (sprawl, group) => ({ - albums: - sortChronologically(sprawl.albumData.slice()) - .filter(album => album.groups.includes(group)) - .filter(album => album.tracks.length > 1), - }), - - relations: (relation, query, sprawl, group) => ({ - groupLink: - relation('linkGroup', group), - - albumLinks: - query.albums - .map(album => relation('generateListRandomPageLinksAlbumLink', album)), - }), - - generate: (relations, {html, language}) => - html.tags([ - html.tag('dt', - language.$('listingPage.other.randomPages.fromGroup', { - group: relations.groupLink, - - randomAlbum: - html.tag('a', - {href: '#', 'data-random': 'album-in-group-dl'}, - language.$('listingPage.other.randomPages.fromGroup.randomAlbum')), - - randomTrack: - html.tag('a', - {href: '#', 'data-random': 'track-in-group-dl'}, - language.$('listingPage.other.randomPages.fromGroup.randomTrack')), - })), - - html.tag('dd', - html.tag('ul', - relations.albumLinks - .map(albumLink => - html.tag('li', - language.$('listingPage.other.randomPages.album', { - album: albumLink, - }))))), - ]), -}; diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 87e5f5aa..5e74b4ac 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -1,47 +1,102 @@ import {empty} from '#sugar'; +import {sortChronologically} from '#wiki-data'; export default { contentDependencies: [ 'generateListingPage', - 'generateListRandomPageLinksAllAlbumsSection', - 'generateListRandomPageLinksGroupSection', + 'generateListRandomPageLinksAlbumLink', + 'linkGroup', ], extraDependencies: ['html', 'language', 'wikiData'], sprawl: ({wikiInfo}) => ({wikiInfo}), - query: ({wikiInfo: {divideTrackListsByGroups: groups}}, spec) => ({ - spec, - groups, - divideByGroups: !empty(groups), - }), + query(sprawl, spec) { + const query = {spec}; - relations: (relation, query) => ({ - page: relation('generateListingPage', query.spec), + const groups = sprawl.wikiInfo.divideTrackListsByGroups; - allAlbumsSection: - (query.divideByGroups - ? null - : relation('generateListRandomPageLinksAllAlbumsSection')), + query.divideByGroups = !empty(groups); - groupSections: - (query.divideByGroups - ? query.groups - .map(group => relation('generateListRandomPageLinksGroupSection', group)) - : null), - }), + if (query.divideByGroups) { + query.groups = groups; + + query.groupAlbums = + groups + .map(group => + group.albums.filter(album => album.tracks.length > 1)); + } else { + query.undividedAlbums = + sortChronologically(sprawl.albumData.slice()) + .filter(album => album.tracks.length > 1); + } + + return query; + }, + + relations(relation, query) { + const relations = {}; + + relations.page = + relation('generateListingPage', query.spec); + + if (query.divideByGroups) { + relations.groupLinks = + query.groups + .map(group => relation('linkGroup', group)); + + relations.groupAlbumLinks = + query.groupAlbums + .map(albums => albums + .map(album => + relation('generateListRandomPageLinksAlbumLink', album))); + } else { + relations.undividedAlbumLinks = + query.undividedAlbums + .map(album => + relation('generateListRandomPageLinksAlbumLink', album)); + } + + return relations; + }, generate(relations, {html, language}) { + const miscellaneousChunkRows = [ + { + stringsKey: 'randomArtist', + + mainLink: + html.tag('a', + {href: '#', 'data-random': 'artist'}, + language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')), + + atLeastTwoContributions: + html.tag('a', + {href: '#', 'data-random': 'artist-more-than-one-contrib'}, + language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')), + }, + + {stringsKey: 'randomAlbumWholeSite'}, + {stringsKey: 'randomTrackWholeSite'}, + ]; + + const miscellaneousChunkRowAttributes = [ + null, + {href: '#', 'data-random': 'album'}, + {href: '#','data-random': 'track'}, + ]; + return relations.page.slots({ - type: 'custom', + type: 'chunks', + content: [ html.tag('p', language.$('listingPage.other.randomPages.chooseLinkLine', { fromPart: - (empty(relations.groupSections) - ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups') - : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')), + (relations.groupLinks + ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups') + : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')), browserSupportPart: language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'), @@ -54,40 +109,54 @@ export default { html.tag('p', {class: 'js-show-once-data'}, language.$('listingPage.other.randomPages.dataLoadedLine')), + ], + + chunkTitles: [ + {stringsKey: 'misc'}, + + ... + (relations.groupLinks + ? relations.groupLinks.map(groupLink => ({ + stringsKey: 'fromGroup', + + group: groupLink, + + randomAlbum: + html.tag('a', + {href: '#', 'data-random': 'album-in-group-dl'}, + language.$('listingPage.other.randomPages.chunk.title.fromGroup.randomAlbum')), + + randomTrack: + html.tag('a', + {href: '#', 'data-random': 'track-in-group-dl'}, + language.$('listingPage.other.randomPages.chunk.title.fromGroup.randomTrack')), + })) + : [{stringsKey: 'fromAlbum'}]), + ], + + chunkRows: [ + miscellaneousChunkRows, + + ... + (relations.groupAlbumLinks + ? relations.groupAlbumLinks.map(albumLinks => + albumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + }))) + : relations.albumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + }))), + ], - html.tag('dl', [ - html.tag('dt', - language.$('listingPage.other.randomPages.misc')), - - html.tag('dd', - html.tag('ul', [ - html.tag('li', - language.$('listingPage.other.randomPages.misc.randomArtist', { - mainLink: - html.tag('a', - {href: '#', 'data-random': 'artist'}, - language.$('listingPage.other.randomPages.misc.randomArtist.mainLink')), - - atLeastTwoContributions: - html.tag('a', - {href: '#', 'data-random': 'artist-more-than-one-contrib'}, - language.$('listingPage.other.randomPages.misc.randomArtist.atLeastTwoContributions')), - })), - - html.tag('li', - html.tag('a', - {href: '#', 'data-random': 'album'}, - language.$('listingPage.other.randomPages.misc.randomAlbumWholeSite'))), - - html.tag('li', - html.tag('a', - {href: '#', 'data-random': 'track'}, - language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))), - ])), - - relations.allAlbumsSection, - relations.groupSections, - ]), + chunkRowAttributes: [ + miscellaneousChunkRowAttributes, + ... + (relations.groupAlbumLinks + ? relations.groupAlbumLinks.map(albumLinks => + albumLinks.map(() => null)) + : [relations.albumLinks.map(() => null)]), ], }); }, diff --git a/src/strings-default.yaml b/src/strings-default.yaml index bb244279..86a46e68 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -1568,43 +1568,39 @@ listingPage: dataLoadedLine: >- (Data files have finished being downloaded. The links should work!) - # misc: - # The first chunk in the list includes general links which - # bring you to some random page across the whole site! - - misc: - _: "Miscellaneous:" - randomArtist: - _: "{MAIN_LINK} ({AT_LEAST_TWO_CONTRIBUTIONS})" - mainLink: "Random Artist" - atLeastTwoContributions: "at least 2 contributions" - randomAlbumWholeSite: "Random Album (whole site)" - randomTrackWholeSite: "Random Track (whole site)" - - # fromGroup: - # Provided the wiki has "Divide Track Lists By Groups" set, - # the remaining chunks are one for each of those groups, each - # with a list of links for albums from the group that bring - # you to a random track from the chosen album. - - fromGroup: - _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})" - randomAlbum: "Random Album" - randomTrack: "Random Track" - - # fromAlbum: - # If the wiki doesn't have "Divide Track Lists By Groups", - # all albums across the wiki are grouped in one list. - # (There aren't "random album" and "random track" links like - # for groups because those are already included at the top, - # under the "miscellaneous" chunk.) - - fromAlbum: "From an album:" + chunk: - # album: - # Album entries under each group. + title: + misc: "Miscellaneous:" + + # fromAlbum: + # If the wiki hasn't got "Divide Track Lists By Groups" + # set, all albums across the wiki are grouped in one + # long chunk. + + fromAlbum: "From an album:" + + # fromGroup: + # If the wiki does have "Divide Track Lists By Groups" + # set, there's one chunk past Miscellaneous for each of + # those groups, listing all the albums from that group, + # each of which links to a random track from that album. + + fromGroup: + _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})" + randomAlbum: "Random Album" + randomTrack: "Random Track" + + item: + album: "{ALBUM}" + + randomArtist: + _: "{MAIN_LINK} ({AT_LEAST_TWO_CONTRIBUTIONS})" + mainLink: "Random Artist" + atLeastTwoContributions: "at least 2 contributions" - album: "{ALBUM}" + randomAlbumWholeSite: "Random Album (whole site)" + randomTrackWholeSite: "Random Track (whole site)" # # newsIndex: -- cgit 1.3.0-6-gf8a5 From b8e612f9723ef1b890a1af8745e3f165220ce9d1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:31:57 -0400 Subject: content, client, css: accents in content headings --- src/content/dependencies/generateContentHeading.js | 26 +++++++++++++++++++--- src/static/client2.js | 4 ++++ src/static/site5.css | 6 +++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js index ccaf1076..56f68cb3 100644 --- a/src/content/dependencies/generateContentHeading.js +++ b/src/content/dependencies/generateContentHeading.js @@ -1,19 +1,39 @@ export default { extraDependencies: ['html'], + contentDependencies: ['generateColorStyleVariables'], + + relations: (relation) => ({ + colorVariables: relation('generateColorStyleVariables'), + }), slots: { title: {type: 'html'}, + accent: {type: 'html'}, + + color: {validate: v => v.isColor}, + id: {type: 'string'}, tag: {type: 'string', default: 'p'}, }, - generate(slots, {html}) { + generate(relations, slots, {html}) { return html.tag(slots.tag, { class: 'content-heading', id: slots.id, tabindex: '0', - }, - slots.title); + + style: + slots.color && + relations.colorVariables + .slot('color', slots.color) + .content, + }, [ + slots.title, + + html.tag('span', + {[html.onlyIfContent]: true, class: 'content-heading-accent'}, + slots.accent), + ]); } } diff --git a/src/static/client2.js b/src/static/client2.js index 28882a88..0ec052bd 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -879,6 +879,10 @@ function updateStickySubheadingContent(index) { } for (const child of closestHeading.childNodes) { + if (child.classList?.contains('content-heading-accent')) { + continue; + } + if (child.tagName === 'A') { for (const grandchild of child.childNodes) { stickySubheading.appendChild(grandchild.cloneNode(true)); diff --git a/src/static/site5.css b/src/static/site5.css index 0eb7dcda..afce9b0f 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1275,6 +1275,12 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } +.content-heading .content-heading-accent { + font-weight: normal; + font-size: 1rem; + margin-left: 0.25em; +} + h3.content-heading { clear: both; } -- cgit 1.3.0-6-gf8a5 From 869548723002ebf2f3a501c4105cdf6db7ac8aa7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 16:33:20 -0400 Subject: content: generateListingPage: formatListingString cleanup --- src/content/dependencies/generateListingPage.js | 54 +++++++++++++++++-------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 3878d0eb..6eee45b8 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -104,29 +104,43 @@ export default { }, generate(data, relations, slots, {html, language}) { - const formatListingString = (contextStringsKey, options = {}) => { - const baseStringsKey = `listingPage.${data.stringsKey}`; - - const parts = [baseStringsKey, contextStringsKey]; - - const {stringsKey, ...passOptions} = options; + function formatListingString({ + context, + provided = {}, + }) { + const parts = ['listingPage', data.stringsKey]; + + if (Array.isArray(context)) { + parts.push(...context); + } else { + parts.push(context); + } - if (stringsKey) { - parts.push(options.stringsKey); + if (provided.stringsKey) { + parts.push(provided.stringsKey); } - return language.formatString(parts.join('.'), passOptions); - }; + const options = {...provided}; + delete options.stringsKey; + + return language.formatString(...parts, options); + } const formatRow = ({row, attributes}) => (attributes?.href ? html.tag('li', html.tag('a', attributes, - formatListingString('chunk.item', row))) + formatListingString({ + context: 'chunk.item', + provided: row, + }))) : html.tag('li', attributes, - formatListingString('chunk.item', row))); + formatListingString({ + context: 'chunk.item', + provided: row, + }))); const formatRowList = ({rows, rowAttributes}) => html.tag( @@ -137,7 +151,8 @@ export default { }).map(formatRow)); return relations.layout.slots({ - title: formatListingString('title'), + title: formatListingString({context: 'title'}), + headingMode: 'sticky', mainContent: [ @@ -193,8 +208,10 @@ export default { hash: id, content: html.normalize( - formatListingString('chunk.title', title) - .toString() + formatListingString({ + context: 'chunk.title', + provided: title, + }).toString() .replace(/:$/, '')), }))))), ], @@ -209,8 +226,13 @@ export default { .clone() .slots({ tag: 'dt', - title: formatListingString('chunk.title', title), id, + + title: + formatListingString({ + context: 'chunk.title', + provided: title, + }), }), html.tag('dd', -- cgit 1.3.0-6-gf8a5 From f187e32c858e46af9ee2717a20ae4095f0fef325 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 16:34:15 -0400 Subject: content: generateListingPage: add chunkTitleAccents slot --- src/content/dependencies/generateListingPage.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 6eee45b8..b3d6899d 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -78,6 +78,10 @@ export default { validate: v => v.strictArrayOf(v.isObject), }, + chunkTitleAccents: { + validate: v => v.strictArrayOf(v.optional(v.isObject)), + }, + chunkRows: { validate: v => v.strictArrayOf(v.isObject), }, @@ -218,10 +222,11 @@ export default { stitchArrays({ title: slots.chunkTitles, + titleAccent: slots.chunkTitleAccents, id: slots.chunkIDs, rows: slots.chunkRows, rowAttributes: slots.chunkRowAttributes, - }).map(({title, id, rows, rowAttributes}) => [ + }).map(({title, titleAccent, id, rows, rowAttributes}) => [ relations.chunkHeading .clone() .slots({ @@ -233,6 +238,13 @@ export default { context: 'chunk.title', provided: title, }), + + accent: + titleAccent && + formatListingString({ + context: ['chunk.title', title.stringsKey, 'accent'], + provided: titleAccent, + }), }), html.tag('dd', -- cgit 1.3.0-6-gf8a5 From 082f693274a62c9f826fd84def417f243c21d612 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 16:35:10 -0400 Subject: content: listRandomPageLinks: use chunk title accents --- src/content/dependencies/listRandomPageLinks.js | 16 ++++++++++++---- src/strings-default.yaml | 9 ++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 5e74b4ac..089289f7 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -118,20 +118,28 @@ export default { (relations.groupLinks ? relations.groupLinks.map(groupLink => ({ stringsKey: 'fromGroup', - group: groupLink, + })) + : [{stringsKey: 'fromAlbum'}]), + ], + chunkTitleAccents: [ + null, + + ... + (relations.groupLinks + ? relations.groupLinks.map(() => ({ randomAlbum: html.tag('a', {href: '#', 'data-random': 'album-in-group-dl'}, - language.$('listingPage.other.randomPages.chunk.title.fromGroup.randomAlbum')), + language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')), randomTrack: html.tag('a', {href: '#', 'data-random': 'track-in-group-dl'}, - language.$('listingPage.other.randomPages.chunk.title.fromGroup.randomTrack')), + language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')), })) - : [{stringsKey: 'fromAlbum'}]), + : [null]), ], chunkRows: [ diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 86a46e68..a21758e7 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -1587,9 +1587,12 @@ listingPage: # each of which links to a random track from that album. fromGroup: - _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})" - randomAlbum: "Random Album" - randomTrack: "Random Track" + _: "From {GROUP}:" + + accent: + _: "({RANDOM_ALBUM}, {RANDOM_TRACK})" + randomAlbum: "Random Album" + randomTrack: "Random Track" item: album: "{ALBUM}" -- cgit 1.3.0-6-gf8a5 From f6a0bf1d7b4652a7dd04ed3340010ee2a6e47b7f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 16:40:18 -0400 Subject: content: listRandomPageLinks: show skip to section --- src/content/dependencies/generateListingPage.js | 2 +- src/content/dependencies/listRandomPageLinks.js | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index b3d6899d..95c039eb 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -96,7 +96,7 @@ export default { }, chunkIDs: { - validate: v => v.strictArrayOf(v.isString), + validate: v => v.strictArrayOf(v.optional(v.isString)), }, listStyle: { diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 089289f7..375a72d7 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -61,7 +61,19 @@ export default { return relations; }, - generate(relations, {html, language}) { + data(query) { + const data = {}; + + if (query.divideByGroups) { + data.groupDirectories = + query.groups + .map(group => group.directory); + } + + return data; + }, + + generate(data, relations, {html, language}) { const miscellaneousChunkRows = [ { stringsKey: 'randomArtist', @@ -111,6 +123,13 @@ export default { language.$('listingPage.other.randomPages.dataLoadedLine')), ], + showSkipToSection: true, + + chunkIDs: [ + null, + ...data.groupDirectories, + ], + chunkTitles: [ {stringsKey: 'misc'}, -- cgit 1.3.0-6-gf8a5 From 1d991bb4bc877363532971a74f70e55939c637bb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 20:16:30 -0400 Subject: upd8, data, test: export internal strings path cleanly, fix tests --- src/data/language.js | 9 +++++++++ src/repl.js | 9 ++------- src/upd8.js | 7 ++----- test/lib/content-function.js | 4 ++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/data/language.js b/src/data/language.js index 6ffc31e0..3fc14da7 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -1,6 +1,7 @@ import EventEmitter from 'node:events'; import {readFile} from 'node:fs/promises'; import path from 'node:path'; +import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. @@ -18,6 +19,14 @@ import { const {Language} = T; +export const DEFAULT_STRINGS_FILE = 'strings-default.yaml'; + +export const internalDefaultStringsFile = + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../', + DEFAULT_STRINGS_FILE); + export function processLanguageSpec(spec, {existingCode = null} = {}) { const { 'meta.languageCode': code, diff --git a/src/repl.js b/src/repl.js index 7a6f5c45..3f5d752a 100644 --- a/src/repl.js +++ b/src/repl.js @@ -5,7 +5,7 @@ import {fileURLToPath} from 'node:url'; import {logError, logWarn, parseOptions} from '#cli'; import {isMain} from '#node-utils'; -import {processLanguageFile} from '#language'; +import {internalDefaultStringsFile, processLanguageFile} from '#language'; import {bindOpts, showAggregate} from '#sugar'; import {generateURLs, urlSpec} from '#urls'; import {quickLoadAllFromYAML} from '#yaml'; @@ -16,8 +16,6 @@ import * as serialize from '#serialize'; import * as sugar from '#sugar'; import * as wikiDataUtils from '#wiki-data'; -import {DEFAULT_STRINGS_FILE} from './upd8.js'; - const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function getContextAssignments({ @@ -45,10 +43,7 @@ export async function getContextAssignments({ let language; try { - language = await processLanguageFile( - path.join( - path.dirname(fileURLToPath(import.meta.url)), - DEFAULT_STRINGS_FILE)); + language = await processLanguageFile(internalDefaultStringsFile); } catch (error) { console.error(error); logWarn`Failed to create Language object`; diff --git a/src/upd8.js b/src/upd8.js index 24d0b92b..db73c412 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -39,7 +39,8 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; import {displayCompositeCacheAnalysis} from '#composite'; -import {processLanguageFile, watchLanguageFile} from '#language'; +import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile} + from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; import {empty, showAggregate, withEntries} from '#sugar'; @@ -93,8 +94,6 @@ try { const BUILD_TIME = new Date(); -export const DEFAULT_STRINGS_FILE = 'strings-default.yaml'; - const STATUS_NOT_STARTED = `not started`; const STATUS_NOT_APPLICABLE = `not applicable`; const STATUS_STARTED_NOT_DONE = `started but not yet done`; @@ -1104,8 +1103,6 @@ async function main() { let internalDefaultLanguage; let internalDefaultLanguageWatcher; - const internalDefaultStringsFile = path.join(__dirname, DEFAULT_STRINGS_FILE); - let errorLoadingInternalDefaultLanguage = false; if (noLanguageReloading) { diff --git a/test/lib/content-function.js b/test/lib/content-function.js index 5cb499b1..24363e60 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -8,7 +8,7 @@ import {getColors} from '#colors'; import {quickLoadContentDependencies} from '#content-dependencies'; import {quickEvaluate} from '#content-function'; import * as html from '#html'; -import {processLanguageFile} from '#language'; +import {internalDefaultStringsFile, processLanguageFile} from '#language'; import {empty, showAggregate} from '#sugar'; import {generateURLs, thumb, urlSpec} from '#urls'; @@ -22,7 +22,7 @@ export function testContentFunctions(t, message, fn) { t.test(message, async t => { let loadedContentDependencies; - const language = await processLanguageFile('./src/strings-default.json'); + const language = await processLanguageFile(internalDefaultStringsFile); const mocks = []; const evaluate = ({ -- cgit 1.3.0-6-gf8a5 From 0bc5cfceebed54f357369daec80ee43e2bf7cb76 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 20:23:52 -0400 Subject: test: provide some sensible defaults for more extraDependencies --- test/lib/content-function.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/lib/content-function.js b/test/lib/content-function.js index 24363e60..a4c5dac1 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -50,8 +50,15 @@ export function testContentFunctions(t, message, fn) { thumb, to, urls, + + pagePath: ['home'], appendIndexHTML: false, getColors: c => getColors(c, {chroma}), + + wikiData: { + wikiInfo: {}, + }, + ...extraDependencies, }, }); -- cgit 1.3.0-6-gf8a5 From 6f9d122f9d1ad5bb7e0aad917bbc1ff218e9b4df Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:46:45 -0400 Subject: content: listRandomPageLinks: general syntax/slot fixes --- src/content/dependencies/listRandomPageLinks.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 375a72d7..0b904019 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -10,7 +10,7 @@ export default { extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({wikiInfo}), + sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}), query(sprawl, spec) { const query = {spec}; @@ -125,10 +125,10 @@ export default { showSkipToSection: true, - chunkIDs: [ - null, - ...data.groupDirectories, - ], + chunkIDs: + (data.groupDirectories + ? [null, ...data.groupDirectories] + : null), chunkTitles: [ {stringsKey: 'misc'}, @@ -171,10 +171,12 @@ export default { stringsKey: 'album', album: albumLink, }))) - : relations.albumLinks.map(albumLink => ({ - stringsKey: 'album', - album: albumLink, - }))), + : [ + relations.undividedAlbumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + })), + ]), ], chunkRowAttributes: [ @@ -183,7 +185,7 @@ export default { (relations.groupAlbumLinks ? relations.groupAlbumLinks.map(albumLinks => albumLinks.map(() => null)) - : [relations.albumLinks.map(() => null)]), + : [relations.undividedAlbumLinks.map(() => null)]), ], }); }, -- cgit 1.3.0-6-gf8a5 From 2de59682e52cd9121e53aec94a27ba78a09abb3e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:47:16 -0400 Subject: content: generateFlashActSidebar: try to behave without hsmusic data --- src/content/dependencies/generateFlashActSidebar.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js index bd6063c9..80072483 100644 --- a/src/content/dependencies/generateFlashActSidebar.js +++ b/src/content/dependencies/generateFlashActSidebar.js @@ -1,5 +1,6 @@ import find from '#find'; import {stitchArrays} from '#sugar'; +import {filterMultipleArrays} from '#wiki-data'; export default { contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'], @@ -11,10 +12,12 @@ export default { query(sprawl, act, flash) { const findFlashAct = directory => - find.flashAct(directory, sprawl.flashActData, {mode: 'error'}); + find.flashAct(directory, sprawl.flashActData, {mode: 'quiet'}); + + const homestuckSide1 = findFlashAct('flash-act:a1'); const sideFirstActs = [ - findFlashAct('flash-act:a1'), + sprawl.flashActData[0], findFlashAct('flash-act:a6a1'), findFlashAct('flash-act:hiveswap'), findFlashAct('flash-act:cool-and-new-web-comic'), @@ -22,7 +25,9 @@ export default { ]; const sideNames = [ - `Side 1 (Acts 1-5)`, + (homestuckSide1 + ? `Side 1 (Acts 1-5)` + : `All flashes & games`), `Side 2 (Acts 6-7)`, `Additional Canon`, `Fan Adventures`, @@ -30,13 +35,18 @@ export default { ]; const sideColors = [ - '#4ac925', + (homestuckSide1 + ? '#4ac925' + : null), '#3796c6', '#f2a400', '#c466ff', '#32c7fe', ]; + filterMultipleArrays(sideFirstActs, sideNames, sideColors, + firstAct => firstAct); + const sideFirstActIndexes = sideFirstActs .map(act => sprawl.flashActData.indexOf(act)); -- cgit 1.3.0-6-gf8a5 From ad943caefcacf62347199a73a90dc704cd8e369c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:47:44 -0400 Subject: content: generateListingPage: fix row-based listings... oops... --- src/content/dependencies/generateListingPage.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 95c039eb..2050d62d 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -1,4 +1,4 @@ -import {empty, stitchArrays} from '#sugar'; +import {bindOpts, empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ @@ -130,29 +130,33 @@ export default { return language.formatString(...parts, options); } - const formatRow = ({row, attributes}) => + const formatRow = ({context, row, attributes}) => (attributes?.href ? html.tag('li', html.tag('a', attributes, formatListingString({ - context: 'chunk.item', + context, provided: row, }))) : html.tag('li', attributes, formatListingString({ - context: 'chunk.item', + context, provided: row, }))); - const formatRowList = ({rows, rowAttributes}) => + const formatRowList = ({context, rows, rowAttributes}) => html.tag( (slots.listStyle === 'ordered' ? 'ol' : 'ul'), stitchArrays({ row: rows, attributes: rowAttributes ?? rows.map(() => null), - }).map(formatRow)); + }).map( + bindOpts(formatRow, { + [bindOpts.bindIndex]: 0, + context, + }))); return relations.layout.slots({ title: formatListingString({context: 'title'}), @@ -188,6 +192,7 @@ export default { slots.type === 'rows' && formatRowList({ + context: 'item', rows: slots.rows, rowAttributes: slots.rowAttributes, }), @@ -248,7 +253,11 @@ export default { }), html.tag('dd', - formatRowList({rows, rowAttributes})), + formatRowList({ + context: 'chunk.item', + rows, + rowAttributes, + })), ]), ]), ], -- cgit 1.3.0-6-gf8a5 From f7007f0a090f22929b450ac816757c49e17b9ef1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:48:07 -0400 Subject: content: generatePageLayout: don't assume custom footer content --- src/content/dependencies/generatePageLayout.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 72dfbae5..5fa6e751 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -85,8 +85,10 @@ export default { relations.stickyHeadingContainer = relation('generateStickyHeadingContainer'); - relations.defaultFooterContent = - relation('transformContent', sprawl.footerContent); + if (sprawl.footerContent) { + relations.defaultFooterContent = + relation('transformContent', sprawl.footerContent); + } relations.colorStyleRules = relation('generateColorStyleRules'); @@ -231,7 +233,7 @@ export default { let footerContent = slots.footerContent; - if (html.isBlank(footerContent)) { + if (html.isBlank(footerContent) && relations.defaultFooterContent) { footerContent = relations.defaultFooterContent .slot('mode', 'multiline'); } -- cgit 1.3.0-6-gf8a5 From 3f236319355b093b336e70119a7127bd23693ec2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:48:26 -0400 Subject: content: generateWikiHomeAlbumsRow: support albums without covers --- src/content/dependencies/generateWikiHomeAlbumsRow.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js index cb0860f5..2c6a147e 100644 --- a/src/content/dependencies/generateWikiHomeAlbumsRow.js +++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js @@ -11,7 +11,7 @@ export default { 'transformContent', ], - extraDependencies: ['wikiData'], + extraDependencies: ['language', 'wikiData'], sprawl({albumData}, row) { const sprawl = {}; @@ -90,12 +90,14 @@ export default { data.paths = sprawl.albums .map(album => - ['media.albumCover', album.directory, album.coverArtFileExtension]); + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); return data; }, - generate(data, relations) { + generate(data, relations, {language}) { // Grids and carousels share some slots! Very convenient. const commonSlots = {}; @@ -106,8 +108,15 @@ export default { stitchArrays({ image: relations.images, path: data.paths, - }).map(({image, path}) => - image.slot('path', path)); + name: data.names, + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), + })); commonSlots.actionLinks = (relations.actionLinks -- cgit 1.3.0-6-gf8a5 From 75ec07ac18cb91eb2e019aefce8f60488d794de1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:48:48 -0400 Subject: data: provide default wiki color in data, not css Fixes #169! --- src/data/things/wiki-info.js | 12 +++++++++--- src/static/site5.css | 8 +------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 89053d62..3db9727b 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,9 +1,8 @@ import {input} from '#composite'; import find from '#find'; -import {isLanguageCode, isName, isURL} from '#validators'; +import {isColor, isLanguageCode, isName, isURL} from '#validators'; import { - color, flag, name, referenceList, @@ -32,7 +31,14 @@ export class WikiInfo extends Thing { }, }, - color: color(), + color: { + flags: {update: true, expose: true}, + update: {validate: isColor}, + + expose: { + transform: color => color ?? '#0088ff', + }, + }, // One-line description used for tag. description: simpleString(), diff --git a/src/static/site5.css b/src/static/site5.css index afce9b0f..014e6d25 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -3,13 +3,7 @@ * no need to re-run upd8.js when tweaking values here. Handy! */ -:root { - --primary-color: #0088ff; -} - -/* Layout - Common - * - */ +/* Layout - Common */ body { margin: 10px; -- cgit 1.3.0-6-gf8a5 From 95b1ed2f163c1a2be7b6582499cecb8963548155 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 15:03:21 -0400 Subject: content: generateFlashIndexPage: don't show jump links if empty --- src/content/dependencies/generateFlashIndexPage.js | 38 ++++++++++++---------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js index ad1dab94..5fc62ab3 100644 --- a/src/content/dependencies/generateFlashIndexPage.js +++ b/src/content/dependencies/generateFlashIndexPage.js @@ -1,4 +1,4 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ @@ -95,23 +95,25 @@ export default { mainClasses: ['flash-index'], mainContent: [ - html.tag('p', - {class: 'quick-info'}, - language.$('misc.jumpTo')), - - html.tag('ul', - {class: 'quick-info'}, - stitchArrays({ - colorVariables: relations.jumpLinkColorVariables, - anchor: data.jumpLinkAnchors, - color: data.jumpLinkColors, - label: data.jumpLinkLabels, - }).map(({colorVariables, anchor, color, label}) => - html.tag('li', - html.tag('a', { - href: '#' + anchor, - style: colorVariables.slot('color', color).content, - }, label)))), + !empty(data.jumpLinkLabels) && [ + html.tag('p', + {class: 'quick-info'}, + language.$('misc.jumpTo')), + + html.tag('ul', + {class: 'quick-info'}, + stitchArrays({ + colorVariables: relations.jumpLinkColorVariables, + anchor: data.jumpLinkAnchors, + color: data.jumpLinkColors, + label: data.jumpLinkLabels, + }).map(({colorVariables, anchor, color, label}) => + html.tag('li', + html.tag('a', { + href: '#' + anchor, + style: colorVariables.slot('color', color).content, + }, label)))), + ], stitchArrays({ colorVariables: relations.actColorVariables, -- cgit 1.3.0-6-gf8a5 From 51097802bd2f9d58d4062235858f588a4cf58d93 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 15:16:19 -0400 Subject: content: generateFooterLocalizationLinks: refactor + hide if empty --- .../generateFooterLocalizationLinks.js | 53 ++++++++++++++-------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js index 5df83566..86e6c61a 100644 --- a/src/content/dependencies/generateFooterLocalizationLinks.js +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -1,3 +1,6 @@ +import {stitchArrays} from '#sugar'; +import {sortByName} from '#wiki-data'; + export default { extraDependencies: [ 'defaultLanguage', @@ -16,25 +19,37 @@ export default { pagePath, to, }) { - const links = Object.entries(languages) - .filter(([code, language]) => code !== 'default' && !language.hidden) - .map(([code, language]) => language) - .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) - .map((language) => - html.tag('span', - html.tag('a', - { - href: - language === defaultLanguage - ? to( - 'localizedDefaultLanguage.' + pagePath[0], - ...pagePath.slice(1)) - : to( - 'localizedWithBaseDirectory.' + pagePath[0], - language.code, - ...pagePath.slice(1)), - }, - language.name))); + const switchableLanguages = + Object.entries(languages) + .filter(([code, language]) => code !== 'default' && !language.hidden) + .map(([code, language]) => language); + + if (switchableLanguages.length <= 1) { + return html.blank(); + } + + sortByName(switchableLanguages); + + const [pagePathSubkey, ...pagePathArgs] = pagePath; + + const linkPaths = + switchableLanguages.map(language => + (language === defaultLanguage + ? (['localizedDefaultLanguage.' + pagePathSubkey, + ...pagePathArgs]) + : (['localizedWithBaseDirectory.' + pagePathSubkey, + language.code, + ...pagePathArgs]))); + + const links = + stitchArrays({ + language: switchableLanguages, + linkPath: linkPaths, + }).map(({language, linkPath}) => + html.tag('span', + html.tag('a', + {href: to(...linkPath)}, + language.name))); return html.tag('div', {class: 'footer-localization-links'}, language.$('misc.uiLanguage', { -- cgit 1.3.0-6-gf8a5 From 61c8aa5c17ee6fd96f2e72f8d8a47eb0878ffa7e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 15:25:54 -0400 Subject: upd8: load custom languages from yaml as well as json --- src/upd8.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/upd8.js b/src/upd8.js index db73c412..ff7d7c5c 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -1179,7 +1179,9 @@ async function main() { }); const languageDataFiles = await traverse(langPath, { - filterFile: name => path.extname(name) === '.json', + filterFile: name => + path.extname(name) === '.json' || + path.extname(name) === '.yaml', pathStyle: 'device', }); -- cgit 1.3.0-6-gf8a5 From 52cc83065f41472a4c32c2003b0a715a66d4739a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 13 Nov 2023 16:36:36 -0400 Subject: content: generateWikiHomeAlbumsRow: handle no names in carousel --- src/content/dependencies/generateWikiHomeAlbumsRow.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js index 2c6a147e..a19f104c 100644 --- a/src/content/dependencies/generateWikiHomeAlbumsRow.js +++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js @@ -108,14 +108,15 @@ export default { stitchArrays({ image: relations.images, path: data.paths, - name: data.names, + name: data.names ?? data.paths.slice().fill(null), }).map(({image, path, name}) => image.slots({ path, missingSourceContent: - language.$('misc.albumGrid.noCoverArt', { - album: name, - }), + name && + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), })); commonSlots.actionLinks = -- cgit 1.3.0-6-gf8a5 From b053fa14052aaa24883e73a3f899016f963b5d43 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 21:45:08 -0400 Subject: data: expose CacheableObject directly via #cacheable-object import --- package.json | 1 + src/data/things/index.js | 5 ----- src/data/yaml.js | 8 +++----- src/find.js | 2 +- src/gen-thumbs.js | 2 +- src/repl.js | 3 ++- src/upd8.js | 2 +- test/unit/data/cacheable-object.js | 2 +- 8 files changed, 10 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 194c4060..34b4ebf0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "eslint src && node src/upd8.js" }, "imports": { + "#cacheable-object": "./src/data/things/cacheable-object.js", "#colors": "./src/util/colors.js", "#composite": "./src/data/things/composite.js", "#composite/control-flow": "./src/data/composite/control-flow/index.js", diff --git a/src/data/things/index.js b/src/data/things/index.js index 4ea1f007..d1143b0a 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -22,11 +22,6 @@ import * as wikiInfoClasses from './wiki-info.js'; export {default as Thing} from './thing.js'; -export { - default as CacheableObject, - CacheableObjectPropertyValueError, -} from './cacheable-object.js'; - const allClassLists = { 'album.js': albumClasses, 'art-tag.js': artTagClasses, diff --git a/src/data/yaml.js b/src/data/yaml.js index 1d35bae8..986f25d1 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,15 +7,13 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; +import CacheableObject, {CacheableObjectPropertyValueError} + from '#cacheable-object'; import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; -import T, { - CacheableObject, - CacheableObjectPropertyValueError, - Thing, -} from '#things'; +import T, {Thing} from '#things'; import { annotateErrorWithFile, diff --git a/src/find.js b/src/find.js index dfcaa9aa..4d3e996a 100644 --- a/src/find.js +++ b/src/find.js @@ -1,8 +1,8 @@ import {inspect} from 'node:util'; +import CacheableObject from '#cacheable-object'; import {colors, logWarn} from '#cli'; import {typeAppearance} from '#sugar'; -import {CacheableObject} from '#things'; function warnOrThrow(mode, message) { if (mode === 'error') { diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 1bbcb9c1..e6c1f5c2 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -101,8 +101,8 @@ import { import dimensionsOf from 'image-size'; +import CacheableObject from '#cacheable-object'; import {delay, empty, queue, unique} from '#sugar'; -import {CacheableObject} from '#things'; import {sortByName} from '#wiki-data'; import { diff --git a/src/repl.js b/src/repl.js index ead01567..26879be4 100644 --- a/src/repl.js +++ b/src/repl.js @@ -11,7 +11,8 @@ import {generateURLs, urlSpec} from '#urls'; import {quickLoadAllFromYAML} from '#yaml'; import _find, {bindFind} from '#find'; -import thingConstructors, {CacheableObject} from '#things'; +import CacheableObject from '#cacheable-object'; +import thingConstructors from '#things'; import * as serialize from '#serialize'; import * as sugar from '#sugar'; import * as wikiDataUtils from '#wiki-data'; diff --git a/src/upd8.js b/src/upd8.js index 408ad884..3d7da800 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -38,12 +38,12 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; +import CacheableObject from '#cacheable-object'; import {displayCompositeCacheAnalysis} from '#composite'; import {processLanguageFile} from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; import {empty, showAggregate, withEntries} from '#sugar'; -import {CacheableObject} from '#things'; import {generateURLs, urlSpec} from '#urls'; import {sortByName} from '#wiki-data'; diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js index 57e562d8..5ed9a4a9 100644 --- a/test/unit/data/cacheable-object.js +++ b/test/unit/data/cacheable-object.js @@ -1,6 +1,6 @@ import t from 'tap'; -import {CacheableObject} from '#things'; +import CacheableObject from '#cacheable-object'; function newCacheableObject(PD) { return new (class extends CacheableObject { -- cgit 1.3.0-6-gf8a5 From 764b6a3a2c89d0e4d948862bfeb546d33bbf45e6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 21:45:35 -0400 Subject: data: generic composite dependency comments --- src/data/composite/control-flow/index.js | 5 +++++ src/data/composite/data/index.js | 5 +++++ src/data/composite/wiki-data/index.js | 7 +++++++ src/data/composite/wiki-properties/index.js | 5 +++++ 4 files changed, 22 insertions(+) diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js index dfc53db7..7fad88b2 100644 --- a/src/data/composite/control-flow/index.js +++ b/src/data/composite/control-flow/index.js @@ -1,3 +1,8 @@ +// #composite/control-flow +// +// No entries depend on any other entries, except siblings in this directory. +// + export {default as exitWithoutDependency} from './exitWithoutDependency.js'; export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js'; export {default as exposeConstant} from './exposeConstant.js'; diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index ecd05129..db1c37cc 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -1,3 +1,8 @@ +// #composite/data +// +// Entries here may depend on entries in #composite/control-flow. +// + export {default as excludeFromList} from './excludeFromList.js'; export {default as fillMissingListItems} from './fillMissingListItems.js'; export {default as withFlattenedList} from './withFlattenedList.js'; diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 1d0400fc..df50a2db 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -1,6 +1,13 @@ +// #composite/wiki-data +// +// Entries here may depend on entries in #composite/control-flow and in +// #composite/data. +// + export {default as exitWithoutContribs} from './exitWithoutContribs.js'; export {default as inputThingClass} from './inputThingClass.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; export {default as withResolvedContribs} from './withResolvedContribs.js'; export {default as withResolvedReference} from './withResolvedReference.js'; export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 2462b047..3a8b51d5 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -1,3 +1,8 @@ +// #composite/wiki-properties +// +// Entries here may depend on entries in #composite/control-flow, +// #composite/data, and #composite/wiki-data. + export {default as additionalFiles} from './additionalFiles.js'; export {default as color} from './color.js'; export {default as commentary} from './commentary.js'; -- cgit 1.3.0-6-gf8a5 From 6deea0629a3f3b9985d205d2f3a048893ea938c9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 22:20:45 -0400 Subject: data, test: withParsedCommentaryEntries --- .../wiki-data/withParsedCommentaryEntries.js | 181 +++++++++++++++++++++ src/data/composite/wiki-properties/commentary.js | 32 +++- .../wiki-data/withParsedCommentaryEntries.js | 102 ++++++++++++ 3 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 src/data/composite/wiki-data/withParsedCommentaryEntries.js create mode 100644 test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js new file mode 100644 index 00000000..5bd72dc9 --- /dev/null +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -0,0 +1,181 @@ +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {stitchArrays} from '#sugar'; +import {isCommentary} from '#validators'; + +import {fillMissingListItems, withPropertiesFromList} from '#composite/data'; + +import withResolvedReferenceList from './withResolvedReferenceList.js'; + +// Matches in roughly the format: +// +// artistReference: (annotation, date) +// +// where capturing group "annotation" can be any text at all, except that the +// last entry (past a comma or the only content within parentheses), if parsed +// as a date, is the capturing group "date". "Parsing as a date" means one of +// these formats: +// +// * "25 December 2019" - one or two number digits, followed by any text, +// followed by four number digits +// * "12/25/2019" - one or two number digits, a slash, one or two number +// digits, a slash, and two to four number digits +// +// The artist reference can optionally be boldface (in ), which will be +// captured as non-null in "boldfaceArtist". Otherwise it is all the characters +// between and and is captured in "artistReference" and is either the +// name of an artist or an "artist:directory"-style reference. +// +export const commentaryRegex = + /^(?)?(?.+):(?:<\/b>)?<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; + +export default templateCompositeFrom({ + annotation: `withParsedCommentaryEntries`, + + inputs: { + from: input({validate: isCommentary}), + }, + + outputs: ['#parsedCommentaryEntries'], + + steps: () => [ + { + dependencies: [input('from')], + + compute: (continuation, { + [input('from')]: commentaryText, + }) => continuation({ + ['#rawMatches']: + Array.from(commentaryText.matchAll(commentaryRegex)), + }), + }, + + withPropertiesFromList({ + list: '#rawMatches', + properties: input.value([ + '0', // The entire match as a string. + 'groups', + 'index', + ]), + }).outputs({ + '#rawMatches.0': '#rawMatches.text', + '#rawMatches.groups': '#rawMatches.groups', + '#rawMatches.index': '#rawMatches.startIndex', + }), + + { + dependencies: [ + '#rawMatches.text', + '#rawMatches.startIndex', + ], + + compute: (continuation, { + ['#rawMatches.text']: text, + ['#rawMatches.startIndex']: startIndex, + }) => continuation({ + ['#rawMatches.endIndex']: + stitchArrays({text, startIndex}) + .map(({text, startIndex}) => startIndex + text.length), + }), + }, + + { + dependencies: [ + input('from'), + '#rawMatches.startIndex', + '#rawMatches.endIndex', + ], + + compute: (continuation, { + [input('from')]: commentaryText, + ['#rawMatches.startIndex']: startIndex, + ['#rawMatches.endIndex']: endIndex, + }) => continuation({ + ['#entries.body']: + stitchArrays({startIndex, endIndex}) + .map(({endIndex}, index, stitched) => + (index === stitched.length - 1 + ? commentaryText.slice(endIndex) + : commentaryText.slice( + endIndex, + stitched[index + 1].startIndex))) + .map(body => body.trim()), + }), + }, + + withPropertiesFromList({ + list: '#rawMatches.groups', + prefix: input.value('#entries'), + properties: input.value([ + 'artistReference', + 'boldfaceArtist', + 'annotation', + 'date', + ]), + }), + + // The artistReference group will always have a value, since it's required + // for the line to match in the first place. + + withResolvedReferenceList({ + list: '#entries.artistReference', + data: 'artistData', + find: input.value(find.artist), + notFoundMode: input.value('null'), + }).outputs({ + '#resolvedReferenceList': '#entries.artist', + }), + + { + dependencies: ['#entries.boldfaceArtist'], + compute: (continuation, { + ['#entries.boldfaceArtist']: boldfaceArtist, + }) => continuation({ + ['#entries.boldfaceArtist']: + boldfaceArtist.map(boldface => boldface ? true : false), + }), + }, + + fillMissingListItems({ + list: '#entries.annotation', + fill: input.value(null), + }), + + { + dependencies: ['#entries.date'], + compute: (continuation, { + ['#entries.date']: date, + }) => continuation({ + ['#entries.date']: + date.map(date => date ? new Date(date) : null), + }), + }, + + { + dependencies: [ + '#entries.artist', + '#entries.boldfaceArtist', + '#entries.annotation', + '#entries.date', + '#entries.body', + ], + + compute: (continuation, { + ['#entries.artist']: artist, + ['#entries.boldfaceArtist']: boldfaceArtist, + ['#entries.annotation']: annotation, + ['#entries.date']: date, + ['#entries.body']: body, + }) => continuation({ + ['#parsedCommentaryEntries']: + stitchArrays({ + artist, + boldfaceArtist, + annotation, + date, + body, + }), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js index fbea9d5c..cd6b7ac4 100644 --- a/src/data/composite/wiki-properties/commentary.js +++ b/src/data/composite/wiki-properties/commentary.js @@ -1,12 +1,30 @@ // Artist commentary! Generally present on tracks and albums. +import {input, templateCompositeFrom} from '#composite'; import {isCommentary} from '#validators'; -// TODO: Not templateCompositeFrom. +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withParsedCommentaryEntries} from '#composite/wiki-data'; -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isCommentary}, - }; -} +export default templateCompositeFrom({ + annotation: `commentary`, + + compose: false, + + steps: () => [ + exitWithoutDependency({ + dependency: input.updateValue({validate: isCommentary}), + mode: input.value('falsy'), + value: input.value(null), + }), + + withParsedCommentaryEntries({ + from: input.updateValue(), + }), + + exposeDependency({ + dependency: '#parsedCommentaryEntries', + }), + ], +}); diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js new file mode 100644 index 00000000..7b456449 --- /dev/null +++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -0,0 +1,102 @@ +import t from 'tap'; + +import {compositeFrom, input} from '#composite'; +import thingConstructors from '#things'; + +import {exposeDependency} from '#composite/control-flow'; +import {withParsedCommentaryEntries} from '#composite/wiki-data'; + +const {Artist} = thingConstructors; + +const composite = compositeFrom({ + compose: false, + + steps: [ + withParsedCommentaryEntries({ + from: 'from', + }), + + exposeDependency({dependency: '#parsedCommentaryEntries'}), + ], +}); + +function stubArtist(artistName = `Test Artist`) { + const artist = new Artist(); + artist.name = artistName; + + return artist; +} + +t.test(`withParsedCommentaryEntries: basic behavior`, t => { + t.plan(3); + + const artist1 = stubArtist(`Mobius Trip`); + const artist2 = stubArtist(`Hadron Kaleido`); + + const artistData = [artist1, artist2]; + + t.match(composite, { + expose: { + dependencies: ['from', 'artistData'], + }, + }); + + t.match(composite.expose.compute({ + artistData, + from: + `Mobius Trip:\n` + + `Some commentary.\n` + + `Very cool.\n`, + }), [ + { + artist: artist1, + boldfaceArtist: false, + annotation: null, + date: null, + body: `Some commentary.\nVery cool.`, + }, + ]); + + t.match(composite.expose.compute({ + artistData, + from: + `Mobius Trip: (music, art, 12 January 2015)\n` + + `First commentary entry.\n` + + `Very cool.\n` + + `Hadron Kaleido: (moral support, 4/4/2022)\n` + + `Second commentary entry. Yes. So cool.\n` + + `Mystery Artist: (pingas)\n` + + `Oh no.. Oh dear...\n` + + `Mobius Trip:\n` + + `And back around we go.`, + }), [ + { + artist: artist1, + boldfaceArtist: false, + annotation: `music, art`, + date: new Date('12 January 2015'), + body: `First commentary entry.\nVery cool.`, + }, + { + artist: artist2, + boldfaceArtist: true, + annotation: `moral support`, + date: new Date('4 April 2022'), + body: `Second commentary entry. Yes. So cool.`, + }, + { + artist: null, + boldfaceArtist: false, + annotation: `pingas`, + date: null, + body: `Oh no.. Oh dear...`, + }, + { + artist: artist1, + boldfaceArtist: false, + annotation: null, + date: null, + body: `And back around we go.`, + }, + ]); +}); -- cgit 1.3.0-6-gf8a5 From 09a4af31a3f8207dfe114926b0dbf27eeddf7de9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 22:26:25 -0400 Subject: data: commentatorArtists: use withParsedCommentaryEntries --- .../wiki-properties/commentatorArtists.js | 41 +++++++--------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index 52aeb868..8720e66d 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -1,13 +1,12 @@ -// This one's kinda tricky: it parses artist "references" from the -// commentary content, and finds the matching artist for each reference. +// List of artists referenced in commentary entries. // This is mostly useful for credits and listings on artist pages. import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; import {unique} from '#sugar'; import {exitWithoutDependency} from '#composite/control-flow'; -import {withResolvedReferenceList} from '#composite/wiki-data'; +import {withPropertyFromList} from '#composite/data'; +import {withParsedCommentaryEntries} from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `commentatorArtists`, @@ -21,35 +20,21 @@ export default templateCompositeFrom({ value: input.value([]), }), - { - dependencies: ['commentary'], - compute: (continuation, {commentary}) => - continuation({ - '#artistRefs': - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/(?.*?):<\/i>/g)) - .map(({groups: {who}}) => who), - }), - }, + withParsedCommentaryEntries({ + from: 'commentary', + }), - withResolvedReferenceList({ - list: '#artistRefs', - data: 'artistData', - find: input.value(find.artist), + withPropertyFromList({ + list: '#parsedCommentaryEntries', + property: input.value('artist'), }).outputs({ - '#resolvedReferenceList': '#artists', + '#parsedCommentaryEntries.artist': '#artists', }), { - flags: {expose: true}, - - expose: { - dependencies: ['#artists'], - compute: ({'#artists': artists}) => - unique(artists), - }, + dependencies: ['#artists'], + compute: ({'#artists': artists}) => + unique(artists.filter(artist => artist !== null)), }, ], }); -- cgit 1.3.0-6-gf8a5 From 362dc0619b93d74ad34df1bfbfd9ebc632fa5156 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 22:49:51 -0400 Subject: data, yaml: catch commentary artist ref errors --- .../wiki-data/withParsedCommentaryEntries.js | 23 +-------- src/data/yaml.js | 55 ++++++++++++++++------ src/util/wiki-data.js | 25 ++++++++++ 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js index 5bd72dc9..9e33cdac 100644 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -2,33 +2,12 @@ import {input, templateCompositeFrom} from '#composite'; import find from '#find'; import {stitchArrays} from '#sugar'; import {isCommentary} from '#validators'; +import {commentaryRegex} from '#wiki-data'; import {fillMissingListItems, withPropertiesFromList} from '#composite/data'; import withResolvedReferenceList from './withResolvedReferenceList.js'; -// Matches in roughly the format: -// -// artistReference: (annotation, date) -// -// where capturing group "annotation" can be any text at all, except that the -// last entry (past a comma or the only content within parentheses), if parsed -// as a date, is the capturing group "date". "Parsing as a date" means one of -// these formats: -// -// * "25 December 2019" - one or two number digits, followed by any text, -// followed by four number digits -// * "12/25/2019" - one or two number digits, a slash, one or two number -// digits, a slash, and two to four number digits -// -// The artist reference can optionally be boldface (in ), which will be -// captured as non-null in "boldfaceArtist". Otherwise it is all the characters -// between and and is captured in "artistReference" and is either the -// name of an artist or an "artist:directory"-style reference. -// -export const commentaryRegex = - /^(?)?(?.+):(?:<\/b>)?<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; - export default templateCompositeFrom({ annotation: `withParsedCommentaryEntries`, diff --git a/src/data/yaml.js b/src/data/yaml.js index 986f25d1..843e70b3 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -28,6 +28,7 @@ import { } from '#sugar'; import { + commentaryRegex, sortAlbumsTracksChronologically, sortAlphabetically, sortChronologically, @@ -1616,6 +1617,7 @@ export function filterReferenceErrors(wikiData) { bannerArtistContribs: '_contrib', groups: 'group', artTags: 'artTag', + commentary: '_commentary', }], ['trackData', processTrackDocument, { @@ -1626,6 +1628,7 @@ export function filterReferenceErrors(wikiData) { sampledTracks: '_trackNotRerelease', artTags: 'artTag', originalReleaseTrack: '_trackNotRerelease', + commentary: '_commentary', }], ['groupCategoryData', processGroupCategoryDocument, { @@ -1675,7 +1678,19 @@ export function filterReferenceErrors(wikiData) { nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { - const value = CacheableObject.getUpdateValue(thing, property); + let value = CacheableObject.getUpdateValue(thing, property); + let writeProperty = true; + + switch (findFnKey) { + case '_commentary': + if (value) { + value = + Array.from(value.matchAll(commentaryRegex)) + .map(({groups}) => groups.artistReference); + } + writeProperty = false; + break; + } if (value === undefined) { push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); @@ -1688,19 +1703,25 @@ export function filterReferenceErrors(wikiData) { let findFn; + const findArtistOrAlias = artistRef => { + const alias = find.artist(artistRef, wikiData.artistAliasData, {mode: 'quiet'}); + if (alias) { + // No need to check if the original exists here. Aliases are automatically + // created from a field on the original, so the original certainly exists. + const original = alias.aliasedArtist; + throw new Error(`Reference ${colors.red(artistRef)} is to an alias, should be ${colors.green(original.name)}`); + } + + return boundFind.artist(artistRef); + }; + switch (findFnKey) { - case '_contrib': - findFn = contribRef => { - const alias = find.artist(contribRef.who, wikiData.artistAliasData, {mode: 'quiet'}); - if (alias) { - // No need to check if the original exists here. Aliases are automatically - // created from a field on the original, so the original certainly exists. - const original = alias.aliasedArtist; - throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`); - } + case '_commentary': + findFn = findArtistOrAlias; + break; - return boundFind.artist(contribRef.who); - }; + case '_contrib': + findFn = contribRef => findArtistOrAlias(contribRef.who); break; case '_homepageSourceGroup': @@ -1781,8 +1802,10 @@ export function filterReferenceErrors(wikiData) { ? `Reference errors` + fieldPropertyMessage + findFnMessage : `Reference error` + fieldPropertyMessage + findFnMessage); + let newPropertyValue = value; + if (Array.isArray(value)) { - thing[property] = filter( + newPropertyValue = filter( value, decorateErrorWithIndex(suppress(findFn)), {message: errorMessage}); @@ -1792,11 +1815,15 @@ export function filterReferenceErrors(wikiData) { try { call(findFn, value); } catch (error) { - thing[property] = null; + newPropertyValue = null; throw error; } })); } + + if (writeProperty) { + thing[property] = newPropertyValue; + } } }); } diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 0790ae91..5ab01225 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -629,6 +629,31 @@ export function sortFlashesChronologically(data, { // Specific data utilities +// Matches heading details from commentary data in roughly the format: +// +// artistReference: (annotation, date) +// +// where capturing group "annotation" can be any text at all, except that the +// last entry (past a comma or the only content within parentheses), if parsed +// as a date, is the capturing group "date". "Parsing as a date" means one of +// these formats: +// +// * "25 December 2019" - one or two number digits, followed by any text, +// followed by four number digits +// * "12/25/2019" - one or two number digits, a slash, one or two number +// digits, a slash, and two to four number digits +// +// The artist reference can optionally be boldface (in ), which will be +// captured as non-null in "boldfaceArtist". Otherwise it is all the characters +// between and and is captured in "artistReference" and is either the +// name of an artist or an "artist:directory"-style reference. +// +// This regular expression *doesn't* match bodies, which will need to be parsed +// out of the original string based on the indices matched using this. +// +export const commentaryRegex = + /^(?)?(?.+):(?:<\/b>)?<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; + export function filterAlbumsByCommentary(albums) { return albums .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); -- cgit 1.3.0-6-gf8a5 From f754a8d9187e435a761db31b5053aa2e7ba22e13 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 23:36:37 -0400 Subject: data, test: boldfaceArtist -> artistDisplayText --- .../wiki-data/withParsedCommentaryEntries.js | 21 ++++++++------------- src/util/wiki-data.js | 12 ++++++------ .../wiki-data/withParsedCommentaryEntries.js | 18 +++++++++--------- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js index 9e33cdac..7b1c9484 100644 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -87,7 +87,7 @@ export default templateCompositeFrom({ prefix: input.value('#entries'), properties: input.value([ 'artistReference', - 'boldfaceArtist', + 'artistDisplayText', 'annotation', 'date', ]), @@ -105,15 +105,10 @@ export default templateCompositeFrom({ '#resolvedReferenceList': '#entries.artist', }), - { - dependencies: ['#entries.boldfaceArtist'], - compute: (continuation, { - ['#entries.boldfaceArtist']: boldfaceArtist, - }) => continuation({ - ['#entries.boldfaceArtist']: - boldfaceArtist.map(boldface => boldface ? true : false), - }), - }, + fillMissingListItems({ + list: '#entries.artistDisplayText', + fill: input.value(null), + }), fillMissingListItems({ list: '#entries.annotation', @@ -133,7 +128,7 @@ export default templateCompositeFrom({ { dependencies: [ '#entries.artist', - '#entries.boldfaceArtist', + '#entries.artistDisplayText', '#entries.annotation', '#entries.date', '#entries.body', @@ -141,7 +136,7 @@ export default templateCompositeFrom({ compute: (continuation, { ['#entries.artist']: artist, - ['#entries.boldfaceArtist']: boldfaceArtist, + ['#entries.artistDisplayText']: artistDisplayText, ['#entries.annotation']: annotation, ['#entries.date']: date, ['#entries.body']: body, @@ -149,7 +144,7 @@ export default templateCompositeFrom({ ['#parsedCommentaryEntries']: stitchArrays({ artist, - boldfaceArtist, + artistDisplayText, annotation, date, body, diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 5ab01225..75a141d3 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -629,9 +629,10 @@ export function sortFlashesChronologically(data, { // Specific data utilities -// Matches heading details from commentary data in roughly the format: +// Matches heading details from commentary data in roughly the formats: // // artistReference: (annotation, date) +// artistReference|artistDisplayText: (annotation, date) // // where capturing group "annotation" can be any text at all, except that the // last entry (past a comma or the only content within parentheses), if parsed @@ -643,16 +644,15 @@ export function sortFlashesChronologically(data, { // * "12/25/2019" - one or two number digits, a slash, one or two number // digits, a slash, and two to four number digits // -// The artist reference can optionally be boldface (in ), which will be -// captured as non-null in "boldfaceArtist". Otherwise it is all the characters -// between and and is captured in "artistReference" and is either the -// name of an artist or an "artist:directory"-style reference. +// Capturing group "artistReference" is all the characters between and +// (apart from the pipe and "artistDisplayText" text, if present), and is either +// the name of an artist or an "artist:directory"-style reference. // // This regular expression *doesn't* match bodies, which will need to be parsed // out of the original string based on the indices matched using this. // export const commentaryRegex = - /^(?)?(?.+):(?:<\/b>)?<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; + /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; export function filterAlbumsByCommentary(albums) { return albums diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js index 7b456449..50570de6 100644 --- a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -50,7 +50,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => { }), [ { artist: artist1, - boldfaceArtist: false, + artistDisplayText: null, annotation: null, date: null, body: `Some commentary.\nVery cool.`, @@ -60,40 +60,40 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => { t.match(composite.expose.compute({ artistData, from: - `Mobius Trip: (music, art, 12 January 2015)\n` + + `Mobius Trip|Moo-bius Trip: (music, art, 12 January 2015)\n` + `First commentary entry.\n` + `Very cool.\n` + - `Hadron Kaleido: (moral support, 4/4/2022)\n` + + `Hadron Kaleido|[[artist:hadron-kaleido|The Ol' Hadron]]: (moral support, 4/4/2022)\n` + `Second commentary entry. Yes. So cool.\n` + - `Mystery Artist: (pingas)\n` + + `Mystery Artist: (pingas, August 25, 2023)\n` + `Oh no.. Oh dear...\n` + `Mobius Trip:\n` + `And back around we go.`, }), [ { artist: artist1, - boldfaceArtist: false, + artistDisplayText: `Moo-bius Trip`, annotation: `music, art`, date: new Date('12 January 2015'), body: `First commentary entry.\nVery cool.`, }, { artist: artist2, - boldfaceArtist: true, + artistDisplayText: `[[artist:hadron-kaleido|The Ol' Hadron]]`, annotation: `moral support`, date: new Date('4 April 2022'), body: `Second commentary entry. Yes. So cool.`, }, { artist: null, - boldfaceArtist: false, + artistDisplayText: null, annotation: `pingas`, - date: null, + date: new Date('25 August 2023'), body: `Oh no.. Oh dear...`, }, { artist: artist1, - boldfaceArtist: false, + artistDisplayText: null, annotation: null, date: null, body: `And back around we go.`, -- cgit 1.3.0-6-gf8a5 From f2a31006efa7c4d9c7c15823adc70cc40c46dedd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 10:50:54 -0400 Subject: data: fix commentary entry serialization --- src/data/serialize.js | 4 ++++ src/data/things/album.js | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/data/serialize.js b/src/data/serialize.js index 52aacb07..8cac3309 100644 --- a/src/data/serialize.js +++ b/src/data/serialize.js @@ -19,6 +19,10 @@ export function toContribRefs(contribs) { return contribs?.map(({who, what}) => ({who: toRef(who), what})); } +export function toCommentaryRefs(entries) { + return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props})); +} + // Interface export const serializeDescriptors = Symbol(); diff --git a/src/data/things/album.js b/src/data/things/album.js index af3eb042..63ec1140 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -181,7 +181,8 @@ export class Album extends Thing { hasTrackArt: S.id, isListedOnHomepage: S.id, - commentary: S.id, + commentary: S.toCommentaryRefs, + additionalFiles: S.id, tracks: S.toRefs, -- cgit 1.3.0-6-gf8a5 From d019852fc5dcfa2a7686c17ec1bc9c4877ad5832 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 11:27:22 -0400 Subject: content, css: generateCommentarySection{Entry}? --- src/content/dependencies/generateAlbumInfoPage.js | 22 ++----- .../dependencies/generateCommentarySection.js | 29 ++++++++ .../dependencies/generateCommentarySectionEntry.js | 77 ++++++++++++++++++++++ src/content/dependencies/generateTrackInfoPage.js | 22 ++----- src/static/site5.css | 11 ++++ src/strings-default.yaml | 20 ++++-- 6 files changed, 141 insertions(+), 40 deletions(-) create mode 100644 src/content/dependencies/generateCommentarySection.js create mode 100644 src/content/dependencies/generateCommentarySectionEntry.js diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index 5fe27caf..90a120ca 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -17,6 +17,7 @@ export default { 'generateAlbumStyleRules', 'generateAlbumTrackList', 'generateChronologyLinks', + 'generateCommentarySection', 'generateContentHeading', 'generatePageLayout', 'linkAlbum', @@ -126,13 +127,8 @@ export default { // Section: Artist commentary if (album.commentary) { - const artistCommentary = sections.artistCommentary = {}; - - artistCommentary.heading = - relation('generateContentHeading'); - - artistCommentary.content = - relation('transformContent', album.commentary); + sections.artistCommentary = + relation('generateCommentarySection', album.commentary); } return relations; @@ -235,17 +231,7 @@ export default { 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')), - ], + sec.artistCommentary, ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js new file mode 100644 index 00000000..d08c3c90 --- /dev/null +++ b/src/content/dependencies/generateCommentarySection.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: [ + 'transformContent', + 'generateCommentarySectionEntry', + 'generateContentHeading', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + entries: + entries.map(entry => + relation('generateCommentarySectionEntry', entry)), + }), + + generate: (relations, {html, language}) => + html.tags([ + relations.heading + .slots({ + id: 'artist-commentary', + title: language.$('misc.artistCommentary') + }), + + relations.entries, + ]), +}; diff --git a/src/content/dependencies/generateCommentarySectionEntry.js b/src/content/dependencies/generateCommentarySectionEntry.js new file mode 100644 index 00000000..22e8fd1e --- /dev/null +++ b/src/content/dependencies/generateCommentarySectionEntry.js @@ -0,0 +1,77 @@ +export default { + contentDependencies: ['linkArtist', 'transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + artistLink: + (entry.artist && !entry.artistDisplayText + ? relation('linkArtist', entry.artist) + : null), + + artistsContent: + (entry.artistDisplayText + ? relation('transformContent', entry.artistDisplayText) + : null), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + + bodyContent: + (entry.body + ? relation('transformContent', entry.body) + : null), + }), + + data: (entry) => ({ + date: entry.date, + }), + + generate(data, relations, {html, language}) { + const artistsSpan = + html.tag('span', {class: 'commentary-entry-artists'}, + (relations.artistsContent + ? relations.artistsContent.slot('mode', 'inline') + : relations.artistLink + ? relations.artistLink + : language.$('misc.artistCommentary.noArtist'))); + + const accentParts = ['misc.artistCommentary.entry.title.accent']; + const accentOptions = {}; + + if (relations.annotationContent) { + accentParts.push('withAnnotation'); + accentOptions.annotation = + relations.annotationContent.slot('mode', 'inline'); + } + + if (data.date) { + accentParts.push('withDate'); + accentOptions.date = + language.formatDate(data.date); + } + + const accent = + (accentParts.length > 1 + ? html.tag('span', {class: 'commentary-entry-accent'}, + language.$(...accentParts, accentOptions)) + : null); + + const titleParts = ['misc.artistCommentary.entry.title']; + const titleOptions = {artists: artistsSpan}; + + if (accent) { + titleParts.push('withAccent'); + titleOptions.accent = accent; + } + + return html.tags([ + html.tag('p', {class: 'commentary-entry-heading'}, + language.$(...titleParts, titleOptions)), + + html.tag('blockquote', {class: 'commentary-entry-body'}, + relations.bodyContent.slot('mode', 'multiline')), + ]); + }, +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 93334948..200cf054 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -11,6 +11,7 @@ export default { 'generateAlbumSidebar', 'generateAlbumStyleRules', 'generateChronologyLinks', + 'generateCommentarySection', 'generateContentHeading', 'generateContributionList', 'generatePageLayout', @@ -268,13 +269,8 @@ export default { // Section: Artist commentary if (track.commentary) { - const artistCommentary = sections.artistCommentary = {}; - - artistCommentary.heading = - relation('generateContentHeading'); - - artistCommentary.content = - relation('transformContent', track.commentary); + sections.artistCommentary = + relation('generateCommentarySection', track.commentary); } return relations; @@ -491,17 +487,7 @@ export default { 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')), - ], + sec.artistCommentary, ], navLinkStyle: 'hierarchical', diff --git a/src/static/site5.css b/src/static/site5.css index 014e6d25..1ffe5044 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -537,6 +537,17 @@ p .current { margin-top: 5px; } +.commentary-entry-heading { + margin-left: 15px; + padding-left: 5px; + padding-bottom: 0.2em; + border-bottom: 1px dotted var(--primary-color); +} + +.commentary-entry-accent { + font-style: oblique; +} + .commentary-art { float: right; width: 30%; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index a21758e7..eccfc80c 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -273,10 +273,6 @@ releaseInfo: _: "Read {LINK}." link: "artist commentary" - artistCommentary: - _: "Artist commentary:" - seeOriginalRelease: "See {ORIGINAL}!" - additionalFiles: heading: "View or download {ADDITIONAL_FILES}:" @@ -349,6 +345,22 @@ misc: artistAvatar: "artist avatar" flashArt: "flash art" + # artistCommentary: + + artistCommentary: + _: "Artist commentary:" + + entry: + title: + _: "{ARTISTS}:" + withAccent: "{ARTISTS}: {ACCENT}" + accent: + withAnnotation: "({ANNOTATION})" + withDate: ({DATE})" + withAnnotation.withDate: "({ANNOTATION}, {DATE})" + + seeOriginalRelease: "See {ORIGINAL}!" + # artistLink: # Artist links have special accents which are made conditionally # present in a variety of places across the wiki. -- cgit 1.3.0-6-gf8a5 From ecc084546ca136bcab4bfce25e8291c3de565e67 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:06:33 -0400 Subject: content: generatePageLayout: sidebar column & section class slots --- src/content/dependencies/generatePageLayout.js | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 5fa6e751..3bf0c9f0 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -6,12 +6,19 @@ function sidebarSlots(side) { // if specified. [side + 'Content']: {type: 'html'}, - // Multiple is an array of {content: (HTML)} objects. Each of these - // will generate one sidebar section. + // A single class to apply to the whole sidebar. If specifying multiple + // sections, this be added to the containing sidebar-column - specify a + // class on each section if that's more suitable. + [side + 'Class']: {type: 'string'}, + + // Multiple is an array of objects, each specifying content (HTML) and + // optionally class (a string). Each of these will generate one sidebar + // section. [side + 'Multiple']: { validate: v => v.sparseArrayOf( v.validateProperties({ + class: v.optional(v.isString), content: v.isHTML, })), }, @@ -27,6 +34,7 @@ function sidebarSlots(side) { // the whole section's containing box (or the sidebar column as a whole). [side + 'StickyMode']: { validate: v => v.is('last', 'column', 'static'), + default: 'static', }, // Collapsing sidebars disappear when the viewport is sufficiently @@ -354,6 +362,7 @@ export default { const generateSidebarHTML = (side, id) => { const content = slots[side + 'Content']; + const topClass = slots[side + 'Class']; const multiple = slots[side + 'Multiple']; const stickyMode = slots[side + 'StickyMode']; const wide = slots[side + 'Wide']; @@ -363,20 +372,18 @@ export default { let sidebarContent = html.blank(); if (!html.isBlank(content)) { - sidebarClasses = ['sidebar']; + sidebarClasses = ['sidebar', topClass]; sidebarContent = content; } else if (multiple) { - sidebarClasses = ['sidebar-multiple']; + sidebarClasses = ['sidebar-multiple', topClass]; sidebarContent = multiple .filter(Boolean) - .map(({content}) => - html.tag('div', - { - [html.onlyIfContent]: true, - class: 'sidebar', - }, - content)); + .map(box => + html.tag('div', { + [html.onlyIfContent]: true, + class: ['sidebar', box.class], + }, box.content)); } if (html.isBlank(sidebarContent)) { -- cgit 1.3.0-6-gf8a5 From e9d4cd7fb8bb9c55f3dd90b36eec7e246bc2589e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:15:37 -0400 Subject: content: add classes to various sidebar boxes --- .../dependencies/generateAlbumCommentaryPage.js | 1 + src/content/dependencies/generateAlbumSidebar.js | 8 ++++++-- .../dependencies/generateFlashActSidebar.js | 24 +++++++++++++--------- src/content/dependencies/generateGroupSidebar.js | 1 + src/content/dependencies/generateListingSidebar.js | 1 + .../dependencies/generateWikiHomeNewsBox.js | 1 + src/content/dependencies/generateWikiHomePage.js | 1 + 7 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 3ad1549e..e2415516 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -201,6 +201,7 @@ export default { ], leftSidebarStickyMode: 'column', + leftSidebarClass: 'commentary-track-list-sidebar-box', leftSidebarContent: [ html.tag('h1', relations.sidebarAlbumLink), relations.sidebarTrackSections.map(section => diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js index a84f4357..5ef4501b 100644 --- a/src/content/dependencies/generateAlbumSidebar.js +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -30,6 +30,7 @@ export default { generate(data, relations, {html}) { const trackListBox = { + class: 'track-list-sidebar-box', content: html.tags([ html.tag('h1', relations.albumLink), @@ -40,8 +41,10 @@ export default { if (data.isAlbumPage) { const groupBoxes = relations.groupBoxes - .map(content => content.slot('mode', 'album')) - .map(content => ({content})); + .map(content => ({ + class: 'individual-group-sidebar-box', + content: content.slot('mode', 'album'), + })); return { leftSidebarMultiple: [ @@ -52,6 +55,7 @@ export default { } const conjoinedGroupBox = { + class: 'conjoined-group-sidebar-box', content: relations.groupBoxes .flatMap((content, i, {length}) => [ diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js index 80072483..29379644 100644 --- a/src/content/dependencies/generateFlashActSidebar.js +++ b/src/content/dependencies/generateFlashActSidebar.js @@ -137,7 +137,7 @@ export default { }), generate(data, relations, {getColors, html, language}) { - const currentActBox = html.tags([ + const currentActBoxContent = html.tags([ html.tag('h1', relations.currentActLink), html.tag('details', @@ -160,7 +160,7 @@ export default { ]), ]); - const sideMapBox = html.tags([ + const sideMapBoxContent = html.tags([ html.tag('h1', relations.flashIndexLink), stitchArrays({ @@ -188,17 +188,21 @@ export default { ])), ]); + const sideMapBox = { + class: 'flash-act-map-sidebar-box', + content: sideMapBoxContent, + }; + + const currentActBox = { + class: 'flash-current-act-sidebar-box', + content: currentActBoxContent, + }; + return { leftSidebarMultiple: (data.isFlashActPage - ? [ - {content: sideMapBox}, - {content: currentActBox}, - ] - : [ - {content: currentActBox}, - {content: sideMapBox}, - ]), + ? [sideMapBox, currentActBox] + : [currentActBox, sideMapBox]), }; }, }; diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js index 6baf37f4..98b288fa 100644 --- a/src/content/dependencies/generateGroupSidebar.js +++ b/src/content/dependencies/generateGroupSidebar.js @@ -22,6 +22,7 @@ export default { generate(relations, slots, {html, language}) { return { + leftSidebarClass: 'category-map-sidebar-box', leftSidebarContent: [ html.tag('h1', language.$('groupSidebar.title')), diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js index fe2a08fa..1cdd236b 100644 --- a/src/content/dependencies/generateListingSidebar.js +++ b/src/content/dependencies/generateListingSidebar.js @@ -11,6 +11,7 @@ export default { generate(relations, {html}) { return { + leftSidebarClass: 'listing-map-sidebar-box', leftSidebarContent: [ html.tag('h1', relations.listingIndexLink), relations.listingIndexList.slot('mode', 'sidebar'), diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js index 8acd426c..0d8303f1 100644 --- a/src/content/dependencies/generateWikiHomeNewsBox.js +++ b/src/content/dependencies/generateWikiHomeNewsBox.js @@ -42,6 +42,7 @@ export default { } return { + class: 'latest-news-sidebar-box', content: [ html.tag('h1', language.$('homepage.news.title')), diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js index 40a6b1c5..36fcc6f2 100644 --- a/src/content/dependencies/generateWikiHomePage.js +++ b/src/content/dependencies/generateWikiHomePage.js @@ -75,6 +75,7 @@ export default { leftSidebarMultiple: [ (relations.customSidebarContent ? { + class: 'custom-content-sidebar-box', content: relations.customSidebarContent .slot('mode', 'multiline'), -- cgit 1.3.0-6-gf8a5 From c399b00ccea8280032e0576a99eab2d34a04355c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:17:15 -0400 Subject: content, client: use 'track-in-sidebar' for random link in nav --- src/content/dependencies/generateAlbumNavAccent.js | 2 +- src/static/client2.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js index 7eb1dac0..01c88bf7 100644 --- a/src/content/dependencies/generateAlbumNavAccent.js +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -92,7 +92,7 @@ export default { html.tag('a', { href: '#', - 'data-random': 'track-in-album', + 'data-random': 'track-in-sidebar', id: 'random-button', }, (data.isTrackPage diff --git a/src/static/client2.js b/src/static/client2.js index 0ec052bd..b72933d0 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -187,6 +187,19 @@ function addRandomLinkListeners() { break; } + case 'track-in-sidebar': { + // Note that the container for track links may be
    or
      , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + a.href = pick(trackLinks).href; + break; + } + /* Legacy links, for old versions * * of generateListRandomPageLinksGroupSection */ @@ -255,9 +268,7 @@ function addNavigationKeyPressListeners() { } else if (event.charCode === 'P'.charCodeAt(0)) { scriptedLinkInfo.previousNavLink?.click(); } else if (event.charCode === 'R'.charCodeAt(0)) { - if (ready) { - scriptedLinkInfo.randomNavLink?.click(); - } + scriptedLinkInfo.randomNavLink?.click(); } } }); -- cgit 1.3.0-6-gf8a5 From 43141f1fc41768679b63e154ac21203e928b17c7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:19:00 -0400 Subject: client, content: client2.js -> client3.js --- src/content/dependencies/generatePageLayout.js | 2 +- src/static/client2.js | 1492 ------------------------ src/static/client3.js | 1453 +++++++++++++++++++++++ 3 files changed, 1454 insertions(+), 1493 deletions(-) delete mode 100644 src/static/client2.js create mode 100644 src/static/client3.js diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 3bf0c9f0..95551f3e 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -655,7 +655,7 @@ export default { html.tag('script', { type: 'module', - src: to('shared.staticFile', 'client2.js', cachebust), + src: to('shared.staticFile', 'client3.js', cachebust), }), ]), ]) diff --git a/src/static/client2.js b/src/static/client2.js deleted file mode 100644 index b72933d0..00000000 --- a/src/static/client2.js +++ /dev/null @@ -1,1492 +0,0 @@ -/* eslint-env browser */ - -// This is the JS file that gets loaded on the client! It's only really used for -// the random track feature right now - the idea is we only use it for stuff -// that cannot 8e done at static-site compile time, 8y its fundamentally -// ephemeral nature. - -import {getColors} from '../util/colors.js'; -import {empty, stitchArrays} from '../util/sugar.js'; - -import { - filterMultipleArrays, - getArtistNumContributions, -} from '../util/wiki-data.js'; - -let albumData, artistData; -let officialAlbumData, fandomAlbumData, beyondAlbumData; - -let ready = false; - -const clientInfo = window.hsmusicClientInfo = Object.create(null); - -const clientSteps = { - getPageReferences: [], - addInternalListeners: [], - mutatePageContent: [], - initializeState: [], - addPageListeners: [], -}; - -// Localiz8tion nonsense ---------------------------------- - -const language = document.documentElement.getAttribute('lang'); - -let list; -if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { - const getFormat = (type) => { - const formatter = new Intl.ListFormat(language, {type}); - return formatter.format.bind(formatter); - }; - - list = { - conjunction: getFormat('conjunction'), - disjunction: getFormat('disjunction'), - unit: getFormat('unit'), - }; -} else { - // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. - // We use the same mock for every list 'cuz we don't have any of the - // necessary CLDR info to appropri8tely distinguish 8etween them. - const arbitraryMock = (array) => array.join(', '); - - list = { - conjunction: arbitraryMock, - disjunction: arbitraryMock, - unit: arbitraryMock, - }; -} - -// Miscellaneous helpers ---------------------------------- - -function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; - } -} - -function pick(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -function cssProp(el, key) { - return getComputedStyle(el).getPropertyValue(key).trim(); -} - -function getRefDirectory(ref) { - return ref.split(':')[1]; -} - -function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find((album) => album.directory === directory); -} - -// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to -// separ8te the tooling around that into common-shared code too. -const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); -const openAlbum = (d) => rebase(`album/${d}`); -const openTrack = (d) => rebase(`track/${d}`); -const openArtist = (d) => rebase(`artist/${d}`); - -// TODO: This should also use urlSpec. -function fetchData(type, directory) { - return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( - (res) => res.json() - ); -} - -// JS-based links ----------------------------------------- - -const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { - randomLinks: null, - revealLinks: null, - - nextLink: null, - previousLink: null, - randomLink: null, -}; - -function getScriptedLinkReferences() { - scriptedLinkInfo.randomLinks = - document.querySelectorAll('[data-random]'); - - scriptedLinkInfo.revealLinks = - document.getElementsByClassName('reveal'); - - scriptedLinkInfo.nextNavLink = - document.getElementById('next-button'); - - scriptedLinkInfo.previousNavLink = - document.getElementById('previous-button'); - - scriptedLinkInfo.randomNavLink = - document.getElementById('random-button'); -} - -function addRandomLinkListeners() { - for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } - - const tracks = albumData => - albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); - - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - a.href = openAlbum(pick(albumDirectories)); - break; - } - - case 'track-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - const filteredAlbumData = - albumData.filter(album => - albumDirectories.includes(album.directory)); - - a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); - break; - } - - case 'track-in-sidebar': { - // Note that the container for track links may be
        or
          , and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - a.href = pick(trackLinks).href; - break; - } - - /* Legacy links, for old versions * - * of generateListRandomPageLinksGroupSection */ - - case 'album-in-official': - a.href = openAlbum(pick(officialAlbumData).directory); - break; - - case 'album-in-fandom': - a.href = openAlbum(pick(fandomAlbumData).directory); - break; - - case 'album-in-beyond': - a.href = openAlbum(pick(beyondAlbumData).directory); - break; - - /* End legacy links */ - - case 'track-in-album': - a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - break; - - case 'track-in-official': - a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData)))); - break; - - case 'track-in-fandom': - a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData)))); - break; - - case 'track-in-beyond': - a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData)))); - break; - - case 'artist': - a.href = openArtist(pick(artistData).directory); - break; - - case 'artist-more-than-one-contrib': - a.href = - openArtist( - pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) - .directory); - break; - } - }); - } -} - -function mutateNavigationLinkContent() { - const prependTitle = (el, prepend) => - el?.setAttribute('title', - (el.hasAttribute('title') - ? prepend + ' ' + el.getAttribute('title') - : prepend)); - - prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); - prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); - prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); -} - -function addNavigationKeyPressListeners() { - document.addEventListener('keypress', (event) => { - if (event.shiftKey) { - if (event.charCode === 'N'.charCodeAt(0)) { - scriptedLinkInfo.nextNavLink?.click(); - } else if (event.charCode === 'P'.charCodeAt(0)) { - scriptedLinkInfo.previousNavLink?.click(); - } else if (event.charCode === 'R'.charCodeAt(0)) { - scriptedLinkInfo.randomNavLink?.click(); - } - } - }); -} - -function addRevealLinkClickListeners() { - for (const reveal of scriptedLinkInfo.revealLinks ?? []) { - reveal.addEventListener('click', (event) => { - if (!reveal.classList.contains('revealed')) { - reveal.classList.add('revealed'); - event.preventDefault(); - event.stopPropagation(); - reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); - } - }); - } -} - -clientSteps.getPageReferences.push(getScriptedLinkReferences); -clientSteps.addPageListeners.push(addRandomLinkListeners); -clientSteps.addPageListeners.push(addNavigationKeyPressListeners); -clientSteps.addPageListeners.push(addRevealLinkClickListeners); -clientSteps.mutatePageContent.push(mutateNavigationLinkContent); - -const elements1 = document.getElementsByClassName('js-hide-once-data'); -const elements2 = document.getElementsByClassName('js-show-once-data'); - -for (const element of elements1) element.style.display = 'block'; - -fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { - albumData = data.albumData; - artistData = data.artistData; - - const albumsInGroup = directory => - albumData - .filter(album => - album.groups.includes(`group:${directory}`)); - - officialAlbumData = albumsInGroup('official'); - fandomAlbumData = albumsInGroup('fandom'); - beyondAlbumData = albumsInGroup('beyond'); - - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; - - ready = true; - }); - -// Data & info card --------------------------------------- - -/* -const NORMAL_HOVER_INFO_DELAY = 750; -const FAST_HOVER_INFO_DELAY = 250; -const END_FAST_HOVER_DELAY = 500; -const HIDE_HOVER_DELAY = 250; - -let fastHover = false; -let endFastHoverTimeout = null; - -function colorLink(a, color) { - console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); - return; - - // eslint-disable-next-line no-unreachable - const chroma = {}; - - if (color) { - const {primary, dim} = getColors(color, {chroma}); - a.style.setProperty('--primary-color', primary); - a.style.setProperty('--dim-color', dim); - } -} - -function link(a, type, {name, directory, color}) { - colorLink(a, color); - a.innerText = name; - a.href = getLinkHref(type, directory); -} - -function joinElements(type, elements) { - // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on - // strings. So instead, we'll pass the element's outer HTML's (which means - // the entire HTML of that element). - // - // That does mean this function returns a string, so always 8e sure to - // set innerHTML when using it (not appendChild). - - return list[type](elements.map((el) => el.outerHTML)); -} - -const infoCard = (() => { - const container = document.getElementById('info-card-container'); - - let cancelShow = false; - let hideTimeout = null; - let showing = false; - - container.addEventListener('mouseenter', cancelHide); - container.addEventListener('mouseleave', readyHide); - - function show(type, target) { - cancelShow = false; - - fetchData(type, target.dataset[type]).then((data) => { - // Manual DOM 'cuz we're laaaazy. - - if (cancelShow) { - return; - } - - showing = true; - - const rect = target.getBoundingClientRect(); - - container.style.setProperty('--primary-color', data.color); - - container.style.top = window.scrollY + rect.bottom + 'px'; - container.style.left = window.scrollX + rect.left + 'px'; - - // Use a short timeout to let a currently hidden (or not yet shown) - // info card teleport to the position set a8ove. (If it's currently - // shown, it'll transition to that position.) - setTimeout(() => { - container.classList.remove('hide'); - container.classList.add('show'); - }, 50); - - // 8asic details. - - const nameLink = container.querySelector('.info-card-name a'); - link(nameLink, 'track', data); - - const albumLink = container.querySelector('.info-card-album a'); - link(albumLink, 'album', data.album); - - const artistSpan = container.querySelector('.info-card-artists span'); - artistSpan.innerHTML = joinElements( - 'conjunction', - data.artists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - - const coverArtistParagraph = container.querySelector( - '.info-card-cover-artists' - ); - const coverArtistSpan = coverArtistParagraph.querySelector('span'); - if (data.coverArtists.length) { - coverArtistParagraph.style.display = 'block'; - coverArtistSpan.innerHTML = joinElements( - 'conjunction', - data.coverArtists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - } else { - coverArtistParagraph.style.display = 'none'; - } - - // Cover art. - - const [containerNoReveal, containerReveal] = [ - container.querySelector('.info-card-art-container.no-reveal'), - container.querySelector('.info-card-art-container.reveal'), - ]; - - const [containerShow, containerHide] = data.cover.warnings.length - ? [containerReveal, containerNoReveal] - : [containerNoReveal, containerReveal]; - - containerHide.style.display = 'none'; - containerShow.style.display = 'block'; - - const img = containerShow.querySelector('.info-card-art'); - img.src = rebase(data.cover.paths.small, 'rebaseMedia'); - - const imgLink = containerShow.querySelector('a'); - colorLink(imgLink, data.color); - imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); - - if (containerShow === containerReveal) { - const cw = containerShow.querySelector('.info-card-art-warnings'); - cw.innerText = list.unit(data.cover.warnings); - - const reveal = containerShow.querySelector('.reveal'); - reveal.classList.remove('revealed'); - } - }); - } - - function hide() { - container.classList.remove('show'); - container.classList.add('hide'); - cancelShow = true; - showing = false; - } - - function readyHide() { - if (!hideTimeout && showing) { - hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); - } - } - - function cancelHide() { - if (hideTimeout) { - clearTimeout(hideTimeout); - hideTimeout = null; - } - } - - return { - show, - hide, - readyHide, - cancelHide, - }; -})(); - -function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout( - () => { - fastHover = true; - infoCard.show(type, evt.target); - }, - fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); - - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; - - infoCard.cancelHide(); - }, - - mouseleave() { - clearTimeout(hoverTimeout); - - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } - - infoCard.readyHide(); - }, - }; -} - -const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track'), -}; - -function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [eventName, handler] of Object.entries( - infoCardLinkHandlers[type] - )) { - a.addEventListener(eventName, handler); - } - } -} - -// Info cards are disa8led for now since they aren't quite ready for release, -// 8ut you can try 'em out 8y setting this localStorage flag! -// -// localStorage.tryInfoCards = true; -// -if (localStorage.tryInfoCards) { - addInfoCardLinkHandlers('track'); -} -*/ - -// Custom hash links -------------------------------------- - -const hashLinkInfo = clientInfo.hashLinkInfo = { - links: null, - hrefs: null, - targets: null, - - state: { - highlightedTarget: null, - scrollingAfterClick: false, - concludeScrollingStateInterval: null, - }, - - event: { - whenHashLinkClicked: [], - }, -}; - -function getHashLinkReferences() { - const info = hashLinkInfo; - - info.links = - Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); - - info.hrefs = - info.links - .map(link => link.getAttribute('href')); - - info.targets = - info.hrefs - .map(href => document.getElementById(href.slice(1))); - - filterMultipleArrays( - info.links, - info.hrefs, - info.targets, - (_link, _href, target) => target); -} - -function processScrollingAfterHashLinkClicked() { - const {state} = hashLinkInfo; - - if (state.concludeScrollingStateInterval) return; - - let lastScroll = window.scrollY; - state.scrollingAfterClick = true; - state.concludeScrollingStateInterval = setInterval(() => { - if (Math.abs(window.scrollY - lastScroll) < 10) { - clearInterval(state.concludeScrollingStateInterval); - state.scrollingAfterClick = false; - state.concludeScrollingStateInterval = null; - } else { - lastScroll = window.scrollY; - } - }, 200); -} - -function addHashLinkListeners() { - // Instead of defining a scroll offset (to account for the sticky heading) - // in JavaScript, we interface with the CSS property 'scroll-margin-top'. - // This lets the scroll offset be consolidated where it makes sense, and - // sets an appropriate offset when (re)loading a page with hash for free! - - const info = hashLinkInfo; - const {state, event} = info; - - for (const {hashLink, href, target} of stitchArrays({ - hashLink: info.links, - href: info.hrefs, - target: info.targets, - })) { - hashLink.addEventListener('click', evt => { - if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { - return; - } - - // Hide skipper box right away, so the layout is updated on time for the - // math operations coming up next. - const skipper = document.getElementById('skippers'); - skipper.style.display = 'none'; - setTimeout(() => skipper.style.display = ''); - - const box = target.getBoundingClientRect(); - const style = window.getComputedStyle(target); - - const scrollY = - window.scrollY - + box.top - - style['scroll-margin-top'].replace('px', ''); - - evt.preventDefault(); - history.pushState({}, '', href); - window.scrollTo({top: scrollY, behavior: 'smooth'}); - target.focus({preventScroll: true}); - - const maxScroll = - document.body.scrollHeight - - window.innerHeight; - - if (scrollY > maxScroll && target.classList.contains('content-heading')) { - if (state.highlightedTarget) { - state.highlightedTarget.classList.remove('highlight-hash-link'); - } - - target.classList.add('highlight-hash-link'); - state.highlightedTarget = target; - } - - processScrollingAfterHashLinkClicked(); - - for (const handler of event.whenHashLinkClicked) { - handler({ - link: hashLink, - }); - } - }); - } - - for (const target of info.targets) { - target.addEventListener('animationend', evt => { - if (evt.animationName !== 'highlight-hash-link') return; - target.classList.remove('highlight-hash-link'); - if (target !== state.highlightedTarget) return; - state.highlightedTarget = null; - }); - } -} - -clientSteps.getPageReferences.push(getHashLinkReferences); -clientSteps.addPageListeners.push(addHashLinkListeners); - -// Sticky content heading --------------------------------- - -const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { - stickyContainers: null, - - stickySubheadingRows: null, - stickySubheadings: null, - - stickyCoverContainers: null, - stickyCoverTextAreas: null, - stickyCovers: null, - - contentContainers: null, - contentHeadings: null, - contentCovers: null, - contentCoversReveal: null, - - state: { - displayedHeading: null, - }, - - event: { - whenDisplayedHeadingChanges: [], - }, -}; - -function getStickyHeadingReferences() { - const info = stickyHeadingInfo; - - info.stickyContainers = - Array.from(document.getElementsByClassName('content-sticky-heading-container')); - - info.stickyCoverContainers = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-heading-cover-container')); - - info.stickyCovers = - info.stickyCoverContainers - .map(el => el?.querySelector('.content-sticky-heading-cover')); - - info.stickyCoverTextAreas = - info.stickyCovers - .map(el => el?.querySelector('.image-text-area')); - - info.stickySubheadingRows = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-subheading-row')); - - info.stickySubheadings = - info.stickySubheadingRows - .map(el => el.querySelector('h2')); - - info.contentContainers = - info.stickyContainers - .map(el => el.parentElement); - - info.contentCovers = - info.contentContainers - .map(el => el.querySelector('#cover-art-container')); - - info.contentCoversReveal = - info.contentCovers - .map(el => el ? !!el.querySelector('.reveal') : null); - - info.contentHeadings = - info.contentContainers - .map(el => Array.from(el.querySelectorAll('.content-heading'))); -} - -function removeTextPlaceholderStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const hasTextArea = - info.stickyCoverTextAreas.map(el => !!el); - - const coverContainersWithTextArea = - info.stickyCoverContainers - .filter((_el, index) => hasTextArea[index]); - - for (const el of coverContainersWithTextArea) { - el.remove(); - } - - info.stickyCoverContainers = - info.stickyCoverContainers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCovers = - info.stickyCovers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCoverTextAreas = - info.stickyCoverTextAreas - .slice() - .fill(null); -} - -function addRevealClassToStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCoversWhichReveal = - info.stickyCovers - .filter((_el, index) => info.contentCoversReveal[index]); - - for (const el of stickyCoversWhichReveal) { - el.classList.add('content-sticky-heading-cover-needs-reveal'); - } -} - -function addRevealListenersForStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCovers = info.stickyCovers.slice(); - const contentCovers = info.contentCovers.slice(); - - filterMultipleArrays( - stickyCovers, - contentCovers, - (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); - - for (const {stickyCover, contentCover} of stitchArrays({ - stickyCover: stickyCovers, - contentCover: contentCovers, - })) { - // TODO: Janky - should use internal event instead of DOM event - contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { - stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); - }); - } -} - -function topOfViewInside(el, scroll = window.scrollY) { - return ( - scroll > el.offsetTop && - scroll < el.offsetTop + el.offsetHeight); -} - -function updateStickyCoverVisibility(index) { - const info = stickyHeadingInfo; - - const stickyCoverContainer = info.stickyCoverContainers[index]; - const contentCover = info.contentCovers[index]; - - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 0) { - stickyCoverContainer.classList.add('visible'); - } else { - stickyCoverContainer.classList.remove('visible'); - } - } -} - -function getContentHeadingClosestToStickySubheading(index) { - const info = stickyHeadingInfo; - - const contentContainer = info.contentContainers[index]; - - if (!topOfViewInside(contentContainer)) { - return null; - } - - const stickySubheading = info.stickySubheadings[index]; - - if (stickySubheading.childNodes.length === 0) { - // Supply a non-breaking space to ensure correct basic line height. - stickySubheading.appendChild(document.createTextNode('\xA0')); - } - - const stickyContainer = info.stickyContainers[index]; - const stickyRect = stickyContainer.getBoundingClientRect(); - - // TODO: Should this compute with the subheading row instead of h2? - const subheadingRect = stickySubheading.getBoundingClientRect(); - - const stickyBottom = stickyRect.bottom + subheadingRect.height; - - // Iterate from bottom to top of the content area. - const contentHeadings = info.contentHeadings[index]; - for (const heading of contentHeadings.slice().reverse()) { - const headingRect = heading.getBoundingClientRect(); - if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { - return heading; - } - } - - return null; -} - -function updateStickySubheadingContent(index) { - const info = stickyHeadingInfo; - const {event, state} = info; - - const closestHeading = getContentHeadingClosestToStickySubheading(index); - - if (state.displayedHeading === closestHeading) return; - - const stickySubheadingRow = info.stickySubheadingRows[index]; - - if (closestHeading) { - const stickySubheading = info.stickySubheadings[index]; - - // Array.from needed to iterate over a live array with for..of - for (const child of Array.from(stickySubheading.childNodes)) { - child.remove(); - } - - for (const child of closestHeading.childNodes) { - if (child.classList?.contains('content-heading-accent')) { - continue; - } - - if (child.tagName === 'A') { - for (const grandchild of child.childNodes) { - stickySubheading.appendChild(grandchild.cloneNode(true)); - } - } else { - stickySubheading.appendChild(child.cloneNode(true)); - } - } - - stickySubheadingRow.classList.add('visible'); - } else { - stickySubheadingRow.classList.remove('visible'); - } - - const oldDisplayedHeading = state.displayedHeading; - - state.displayedHeading = closestHeading; - - for (const handler of event.whenDisplayedHeadingChanges) { - handler(index, { - oldHeading: oldDisplayedHeading, - newHeading: closestHeading, - }); - } -} - -function updateStickyHeadings(index) { - updateStickyCoverVisibility(index); - updateStickySubheadingContent(index); -} - -function initializeStateForStickyHeadings() { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } -} - -function addScrollListenerForStickyHeadings() { - document.addEventListener('scroll', () => { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } - }); -} - -clientSteps.getPageReferences.push(getStickyHeadingReferences); -clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); -clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); -clientSteps.initializeState.push(initializeStateForStickyHeadings); -clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); -clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); - -// Image overlay ------------------------------------------ - -function addImageOverlayClickHandlers() { - const container = document.getElementById('image-overlay-container'); - - if (!container) { - console.warn(`#image-overlay-container missing, image overlay module disabled.`); - return; - } - - for (const link of document.querySelectorAll('.image-link')) { - if (link.querySelector('img').hasAttribute('data-no-image-preview')) { - continue; - } - - link.addEventListener('click', handleImageLinkClicked); - } - - const actionContainer = document.getElementById('image-overlay-action-container'); - - container.addEventListener('click', handleContainerClicked); - document.body.addEventListener('keydown', handleKeyDown); - - function handleContainerClicked(evt) { - // Only hide the image overlay if actually clicking the background. - if (evt.target !== container) { - return; - } - - // If you clicked anything close to or beneath the action bar, don't hide - // the image overlay. - const rect = actionContainer.getBoundingClientRect(); - if (evt.clientY >= rect.top - 40) { - return; - } - - container.classList.remove('visible'); - } - - function handleKeyDown(evt) { - if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { - container.classList.remove('visible'); - } - } -} - -function handleImageLinkClicked(evt) { - if (evt.metaKey || evt.shiftKey || evt.altKey) { - return; - } - evt.preventDefault(); - - const container = document.getElementById('image-overlay-container'); - container.classList.add('visible'); - container.classList.remove('loaded'); - container.classList.remove('errored'); - - const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); - const mainImage = document.getElementById('image-overlay-image'); - const thumbImage = document.getElementById('image-overlay-image-thumb'); - - const {href: originalSrc} = evt.target.closest('a'); - - const { - src: embeddedSrc, - dataset: { - originalSize: originalFileSize, - thumbs: availableThumbList, - }, - } = evt.target.closest('a').querySelector('img'); - - updateFileSizeInformation(originalFileSize); - - let mainSrc = null; - let thumbSrc = null; - - if (availableThumbList) { - const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); - const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); - mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); - thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); - // Show the thumbnail size on each element's data attributes. - // Y'know, just for debugging convenience. - mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; - thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; - } else { - mainSrc = originalSrc; - thumbSrc = null; - mainImage.dataset.displayingThumb = ''; - thumbImage.dataset.displayingThumb = ''; - } - - if (thumbSrc) { - thumbImage.src = thumbSrc; - thumbImage.style.display = null; - } else { - thumbImage.src = ''; - thumbImage.style.display = 'none'; - } - - for (const viewOriginal of allViewOriginal) { - viewOriginal.href = originalSrc; - } - - mainImage.addEventListener('load', handleMainImageLoaded); - mainImage.addEventListener('error', handleMainImageErrored); - - container.style.setProperty('--download-progress', '0%'); - loadImage(mainSrc, progress => { - container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); - }).then( - blobUrl => { - mainImage.src = blobUrl; - container.style.setProperty('--download-progress', '100%'); - }, - handleMainImageErrored); - - function handleMainImageLoaded() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('loaded'); - } - - function handleMainImageErrored() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('errored'); - } -} - -function parseThumbList(availableThumbList) { - // Parse all the available thumbnail sizes! These are provided by the actual - // content generation on each image. - const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' - const availableSizes = - (availableThumbList || defaultThumbList) - .split(' ') - .map(part => part.split(':')) - .map(([thumb, length]) => ({thumb, length: parseInt(length)})) - .sort((a, b) => a.length - b.length); - - return availableSizes; -} - -function getPreferredThumbSize(availableThumbList) { - // Assuming a square, the image will be constrained to the lesser window - // dimension. Coefficient here matches CSS dimensions for image overlay. - const constrainedLength = Math.floor(Math.min( - 0.80 * window.innerWidth, - 0.80 * window.innerHeight)); - - // Match device pixel ratio, which is 2x for "retina" displays and certain - // device configurations. - const visualLength = window.devicePixelRatio * constrainedLength; - - const availableSizes = parseThumbList(availableThumbList); - - // Starting from the smallest dimensions, find (and return) the first - // available length which hits a "good enough" threshold - it's got to be - // at least that percent of the way to the actual displayed dimensions. - const goodEnoughThreshold = 0.90; - - // (The last item is skipped since we'd be falling back to it anyway.) - for (const {thumb, length} of availableSizes.slice(0, -1)) { - if (Math.floor(visualLength * goodEnoughThreshold) <= length) { - return {thumb, length}; - } - } - - // If none of the items in the list were big enough to hit the "good enough" - // threshold, just use the largest size available. - return availableSizes[availableSizes.length - 1]; -} - -function getSmallestThumbSize(availableThumbList) { - // Just snag the smallest size. This'll be used for displaying the "preview" - // as the bigger one is loading. - const availableSizes = parseThumbList(availableThumbList); - return availableSizes[0]; -} - -function updateFileSizeInformation(fileSize) { - const fileSizeWarningThreshold = 8 * 10 ** 6; - - const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); - const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); - - if (!fileSize) { - actionContentWithSize.classList.remove('visible'); - actionContentWithoutSize.classList.add('visible'); - return; - } - - actionContentWithoutSize.classList.remove('visible'); - actionContentWithSize.classList.add('visible'); - - const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); - const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); - const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); - const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); - const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); - - fileSize = parseInt(fileSize); - const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; - - if (fileSize > fileSizeWarningThreshold) { - fileSizeWarning.classList.add('visible'); - } else { - fileSizeWarning.classList.remove('visible'); - } - - if (fileSize > 10 ** 6) { - megabytesContainer.classList.add('visible'); - kilobytesContainer.classList.remove('visible'); - megabytesContent.innerText = round(6); - } else { - megabytesContainer.classList.remove('visible'); - kilobytesContainer.classList.add('visible'); - kilobytesContent.innerText = round(3); - } - - void fileSizeWarning; -} - -addImageOverlayClickHandlers(); - -/** - * Credits: Parziphal, Feb 13, 2017 - * https://stackoverflow.com/a/42196770 - * - * Loads an image with progress callback. - * - * The `onprogress` callback will be called by XMLHttpRequest's onprogress - * event, and will receive the loading progress ratio as an whole number. - * However, if it's not possible to compute the progress ratio, `onprogress` - * will be called only once passing -1 as progress value. This is useful to, - * for example, change the progress animation to an undefined animation. - * - * @param {string} imageUrl The image to load - * @param {Function} onprogress - * @return {Promise} - */ -function loadImage(imageUrl, onprogress) { - return new Promise((resolve, reject) => { - var xhr = new XMLHttpRequest(); - var notifiedNotComputable = false; - - xhr.open('GET', imageUrl, true); - xhr.responseType = 'arraybuffer'; - - xhr.onprogress = function(ev) { - if (ev.lengthComputable) { - onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); - } else { - if (!notifiedNotComputable) { - notifiedNotComputable = true; - onprogress(-1); - } - } - } - - xhr.onloadend = function() { - if (!xhr.status.toString().match(/^2/)) { - reject(xhr); - } else { - if (!notifiedNotComputable) { - onprogress(100); - } - - var options = {} - var headers = xhr.getAllResponseHeaders(); - var m = headers.match(/^Content-Type:\s*(.*?)$/mi); - - if (m && m[1]) { - options.type = m[1]; - } - - var blob = new Blob([this.response], options); - - resolve(window.URL.createObjectURL(blob)); - } - } - - xhr.send(); - }); -} - -// Group contributions table ------------------------------ - -const groupContributionsTableInfo = - Array.from(document.querySelectorAll('#content dl')) - .filter(dl => dl.querySelector('a.group-contributions-sort-button')) - .map(dl => ({ - sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), - sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), - sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), - sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), - })); - -function sortGroupContributionsTableBy(info, sort) { - const [showThese, hideThese] = - (sort === 'count' - ? [info.sortingByCountElements, info.sortingByDurationElements] - : [info.sortingByDurationElements, info.sortingByCountElements]); - - for (const element of showThese) element.classList.add('visible'); - for (const element of hideThese) element.classList.remove('visible'); -} - -for (const info of groupContributionsTableInfo) { - info.sortingByCountLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'duration'); - }); - - info.sortingByDurationLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'count'); - }); -} - -// Sticky commentary sidebar ------------------------------ - -const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { - sidebar: null, - - sidebarTrackLinks: null, - sidebarTrackDirectories: null, - - sidebarTrackSections: null, - sidebarTrackSectionStartIndices: null, - - state: { - currentTrackSection: null, - currentTrackLink: null, - justChangedTrackSection: false, - }, -}; - -function getAlbumCommentarySidebarReferences() { - const info = albumCommentarySidebarInfo; - - info.sidebar = - document.getElementById('sidebar-left'); - - info.sidebarHeading = - info.sidebar.querySelector('h1'); - - info.sidebarTrackLinks = - Array.from(info.sidebar.querySelectorAll('li a')); - - info.sidebarTrackDirectories = - info.sidebarTrackLinks - .map(el => el.getAttribute('href')?.slice(1) ?? null); - - info.sidebarTrackSections = - Array.from(info.sidebar.getElementsByTagName('details')); - - info.sidebarTrackSectionStartIndices = - info.sidebarTrackSections - .map(details => details.querySelector('ol, ul')) - .reduce( - (accumulator, _list, index, array) => - (empty(accumulator) - ? [0] - : [ - ...accumulator, - (accumulator[accumulator.length - 1] + - array[index - 1].querySelectorAll('li a').length), - ]), - []); -} - -function scrollAlbumCommentarySidebar() { - const info = albumCommentarySidebarInfo; - const {state} = info; - const {currentTrackLink, currentTrackSection} = state; - - if (!currentTrackLink) { - return; - } - - const {sidebar, sidebarHeading} = info; - - const scrollTop = sidebar.scrollTop; - - const headingRect = sidebarHeading.getBoundingClientRect(); - const sidebarRect = sidebar.getBoundingClientRect(); - - const stickyPadding = headingRect.height; - const sidebarViewportHeight = sidebarRect.height - stickyPadding; - - const linkRect = currentTrackLink.getBoundingClientRect(); - const sectionRect = currentTrackSection.getBoundingClientRect(); - - const sectionTopEdge = - sectionRect.top - (sidebarRect.top - scrollTop); - - const sectionHeight = - sectionRect.height; - - const sectionScrollTop = - sectionTopEdge - stickyPadding - 10; - - const linkTopEdge = - linkRect.top - (sidebarRect.top - scrollTop); - - const linkBottomEdge = - linkRect.bottom - (sidebarRect.top - scrollTop); - - const linkScrollTop = - linkTopEdge - stickyPadding - 5; - - const linkDistanceFromSection = - linkScrollTop - sectionTopEdge; - - const linkVisibleFromTopOfSection = - linkBottomEdge - sectionTopEdge > sidebarViewportHeight; - - const linkScrollBottom = - linkScrollTop - sidebarViewportHeight + linkRect.height + 20; - - const maxScrollInViewport = - scrollTop + stickyPadding + sidebarViewportHeight; - - const minScrollInViewport = - scrollTop + stickyPadding; - - if (linkBottomEdge > maxScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (linkTopEdge < minScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (state.justChangedTrackSection) { - if (sectionHeight < sidebarViewportHeight) { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } -} - -function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { - const info = albumCommentarySidebarInfo; - const {state} = info; - - const trackIndex = - (trackDirectory - ? info.sidebarTrackDirectories - .indexOf(trackDirectory) - : -1); - - const sectionIndex = - (trackIndex >= 0 - ? info.sidebarTrackSectionStartIndices - .findIndex((start, index, array) => - (index === array.length - 1 - ? true - : trackIndex < array[index + 1])) - : -1); - - const sidebarTrackLink = - (trackIndex >= 0 - ? info.sidebarTrackLinks[trackIndex] - : null); - - const sidebarTrackSection = - (sectionIndex >= 0 - ? info.sidebarTrackSections[sectionIndex] - : null); - - state.currentTrackLink?.classList?.remove('current'); - state.currentTrackLink = sidebarTrackLink; - state.currentTrackLink?.classList?.add('current'); - - if (sidebarTrackSection !== state.currentTrackSection) { - if (sidebarTrackSection && !sidebarTrackSection.open) { - if (state.currentTrackSection) { - state.currentTrackSection.open = false; - } - - sidebarTrackSection.open = true; - } - - state.currentTrackSection?.classList?.remove('current'); - state.currentTrackSection = sidebarTrackSection; - state.currentTrackSection?.classList?.add('current'); - state.justChangedTrackSection = true; - } else { - state.justChangedTrackSection = false; - } -} - -function addAlbumCommentaryInternalListeners() { - const info = albumCommentarySidebarInfo; - - const mainContentIndex = - (stickyHeadingInfo.contentContainers ?? []) - .findIndex(({id}) => id === 'content'); - - if (mainContentIndex === -1) return; - - stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { - if (index !== mainContentIndex) return; - if (hashLinkInfo.state.scrollingAfterClick) return; - - const trackDirectory = - (newHeading - ? newHeading.id - : null); - - markDirectoryAsCurrentForAlbumCommentary(trackDirectory); - scrollAlbumCommentarySidebar(); - }); - - hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { - const hash = link.getAttribute('href').slice(1); - if (!info.sidebarTrackDirectories.includes(hash)) return; - markDirectoryAsCurrentForAlbumCommentary(hash); - }); -} - -if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { - clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); - clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); -} - -// Run setup steps ---------------------------------------- - -for (const [key, steps] of Object.entries(clientSteps)) { - for (const step of steps) { - try { - step(); - } catch (error) { - console.warn(`During ${key}, failed to run ${step.name}`); - console.debug(error); - } - } -} diff --git a/src/static/client3.js b/src/static/client3.js new file mode 100644 index 00000000..94d4c4e2 --- /dev/null +++ b/src/static/client3.js @@ -0,0 +1,1453 @@ +/* eslint-env browser */ + +// This is the JS file that gets loaded on the client! It's only really used for +// the random track feature right now - the idea is we only use it for stuff +// that cannot 8e done at static-site compile time, 8y its fundamentally +// ephemeral nature. + +import {getColors} from '../util/colors.js'; +import {empty, stitchArrays} from '../util/sugar.js'; + +import { + filterMultipleArrays, + getArtistNumContributions, +} from '../util/wiki-data.js'; + +let albumData, artistData; + +let ready = false; + +const clientInfo = window.hsmusicClientInfo = Object.create(null); + +const clientSteps = { + getPageReferences: [], + addInternalListeners: [], + mutatePageContent: [], + initializeState: [], + addPageListeners: [], +}; + +// Localiz8tion nonsense ---------------------------------- + +const language = document.documentElement.getAttribute('lang'); + +let list; +if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { + const getFormat = (type) => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit'), + }; +} else { + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = (array) => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock, + }; +} + +// Miscellaneous helpers ---------------------------------- + +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function cssProp(el, key) { + return getComputedStyle(el).getPropertyValue(key).trim(); +} + +function getRefDirectory(ref) { + return ref.split(':')[1]; +} + +function getAlbum(el) { + const directory = cssProp(el, '--album-directory'); + return albumData.find((album) => album.directory === directory); +} + +// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to +// separ8te the tooling around that into common-shared code too. +const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); +const openAlbum = (d) => rebase(`album/${d}`); +const openTrack = (d) => rebase(`track/${d}`); +const openArtist = (d) => rebase(`artist/${d}`); + +// TODO: This should also use urlSpec. +function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( + (res) => res.json() + ); +} + +// JS-based links ----------------------------------------- + +const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { + randomLinks: null, + revealLinks: null, + + nextLink: null, + previousLink: null, + randomLink: null, +}; + +function getScriptedLinkReferences() { + scriptedLinkInfo.randomLinks = + document.querySelectorAll('[data-random]'); + + scriptedLinkInfo.revealLinks = + document.getElementsByClassName('reveal'); + + scriptedLinkInfo.nextNavLink = + document.getElementById('next-button'); + + scriptedLinkInfo.previousNavLink = + document.getElementById('previous-button'); + + scriptedLinkInfo.randomNavLink = + document.getElementById('random-button'); +} + +function addRandomLinkListeners() { + for (const a of scriptedLinkInfo.randomLinks ?? []) { + a.addEventListener('click', evt => { + if (!ready) { + evt.preventDefault(); + return; + } + + const tracks = albumData => + albumData + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); + + setTimeout(() => { + a.href = rebase('js-disabled'); + }); + + switch (a.dataset.random) { + case 'album': + a.href = openAlbum(pick(albumData).directory); + break; + + case 'track': + a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); + break; + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + a.href = openAlbum(pick(albumDirectories)); + break; + } + + case 'track-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + const filteredAlbumData = + albumData.filter(album => + albumDirectories.includes(album.directory)); + + a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); + break; + } + + case 'track-in-sidebar': { + // Note that the container for track links may be
            or
              , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + a.href = pick(trackLinks).href; + break; + } + + case 'track-in-album': + a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); + break; + + case 'artist': + a.href = openArtist(pick(artistData).directory); + break; + + case 'artist-more-than-one-contrib': + a.href = + openArtist( + pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) + .directory); + break; + } + }); + } +} + +function mutateNavigationLinkContent() { + const prependTitle = (el, prepend) => + el?.setAttribute('title', + (el.hasAttribute('title') + ? prepend + ' ' + el.getAttribute('title') + : prepend)); + + prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); + prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); + prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); +} + +function addNavigationKeyPressListeners() { + document.addEventListener('keypress', (event) => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + scriptedLinkInfo.nextNavLink?.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + scriptedLinkInfo.previousNavLink?.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + scriptedLinkInfo.randomNavLink?.click(); + } + } + }); +} + +function addRevealLinkClickListeners() { + for (const reveal of scriptedLinkInfo.revealLinks ?? []) { + reveal.addEventListener('click', (event) => { + if (!reveal.classList.contains('revealed')) { + reveal.classList.add('revealed'); + event.preventDefault(); + event.stopPropagation(); + reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); + } + }); + } +} + +clientSteps.getPageReferences.push(getScriptedLinkReferences); +clientSteps.addPageListeners.push(addRandomLinkListeners); +clientSteps.addPageListeners.push(addNavigationKeyPressListeners); +clientSteps.addPageListeners.push(addRevealLinkClickListeners); +clientSteps.mutatePageContent.push(mutateNavigationLinkContent); + +const elements1 = document.getElementsByClassName('js-hide-once-data'); +const elements2 = document.getElementsByClassName('js-show-once-data'); + +for (const element of elements1) element.style.display = 'block'; + +fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { + albumData = data.albumData; + artistData = data.artistData; + + for (const element of elements1) element.style.display = 'none'; + for (const element of elements2) element.style.display = 'block'; + + ready = true; + }); + +// Data & info card --------------------------------------- + +/* +const NORMAL_HOVER_INFO_DELAY = 750; +const FAST_HOVER_INFO_DELAY = 250; +const END_FAST_HOVER_DELAY = 500; +const HIDE_HOVER_DELAY = 250; + +let fastHover = false; +let endFastHoverTimeout = null; + +function colorLink(a, color) { + console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); + return; + + // eslint-disable-next-line no-unreachable + const chroma = {}; + + if (color) { + const {primary, dim} = getColors(color, {chroma}); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } +} + +function link(a, type, {name, directory, color}) { + colorLink(a, color); + a.innerText = name; + a.href = getLinkHref(type, directory); +} + +function joinElements(type, elements) { + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map((el) => el.outerHTML)); +} + +const infoCard = (() => { + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then((data) => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements( + 'conjunction', + data.artists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + + const coverArtistParagraph = container.querySelector( + '.info-card-cover-artists' + ); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements( + 'conjunction', + data.coverArtists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [containerNoReveal, containerReveal] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal'), + ]; + + const [containerShow, containerHide] = data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]; + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); + } + } + + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + } + + return { + show, + hide, + readyHide, + cancelHide, + }; +})(); + +function makeInfoCardLinkHandlers(type) { + let hoverTimeout = null; + + return { + mouseenter(evt) { + hoverTimeout = setTimeout( + () => { + fastHover = true; + infoCard.show(type, evt.target); + }, + fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); + + clearTimeout(endFastHoverTimeout); + endFastHoverTimeout = null; + + infoCard.cancelHide(); + }, + + mouseleave() { + clearTimeout(hoverTimeout); + + if (fastHover && !endFastHoverTimeout) { + endFastHoverTimeout = setTimeout(() => { + endFastHoverTimeout = null; + fastHover = false; + }, END_FAST_HOVER_DELAY); + } + + infoCard.readyHide(); + }, + }; +} + +const infoCardLinkHandlers = { + track: makeInfoCardLinkHandlers('track'), +}; + +function addInfoCardLinkHandlers(type) { + for (const a of document.querySelectorAll(`a[data-${type}]`)) { + for (const [eventName, handler] of Object.entries( + infoCardLinkHandlers[type] + )) { + a.addEventListener(eventName, handler); + } + } +} + +// Info cards are disa8led for now since they aren't quite ready for release, +// 8ut you can try 'em out 8y setting this localStorage flag! +// +// localStorage.tryInfoCards = true; +// +if (localStorage.tryInfoCards) { + addInfoCardLinkHandlers('track'); +} +*/ + +// Custom hash links -------------------------------------- + +const hashLinkInfo = clientInfo.hashLinkInfo = { + links: null, + hrefs: null, + targets: null, + + state: { + highlightedTarget: null, + scrollingAfterClick: false, + concludeScrollingStateInterval: null, + }, + + event: { + whenHashLinkClicked: [], + }, +}; + +function getHashLinkReferences() { + const info = hashLinkInfo; + + info.links = + Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); + + info.hrefs = + info.links + .map(link => link.getAttribute('href')); + + info.targets = + info.hrefs + .map(href => document.getElementById(href.slice(1))); + + filterMultipleArrays( + info.links, + info.hrefs, + info.targets, + (_link, _href, target) => target); +} + +function processScrollingAfterHashLinkClicked() { + const {state} = hashLinkInfo; + + if (state.concludeScrollingStateInterval) return; + + let lastScroll = window.scrollY; + state.scrollingAfterClick = true; + state.concludeScrollingStateInterval = setInterval(() => { + if (Math.abs(window.scrollY - lastScroll) < 10) { + clearInterval(state.concludeScrollingStateInterval); + state.scrollingAfterClick = false; + state.concludeScrollingStateInterval = null; + } else { + lastScroll = window.scrollY; + } + }, 200); +} + +function addHashLinkListeners() { + // Instead of defining a scroll offset (to account for the sticky heading) + // in JavaScript, we interface with the CSS property 'scroll-margin-top'. + // This lets the scroll offset be consolidated where it makes sense, and + // sets an appropriate offset when (re)loading a page with hash for free! + + const info = hashLinkInfo; + const {state, event} = info; + + for (const {hashLink, href, target} of stitchArrays({ + hashLink: info.links, + href: info.hrefs, + target: info.targets, + })) { + hashLink.addEventListener('click', evt => { + if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { + return; + } + + // Hide skipper box right away, so the layout is updated on time for the + // math operations coming up next. + const skipper = document.getElementById('skippers'); + skipper.style.display = 'none'; + setTimeout(() => skipper.style.display = ''); + + const box = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + + const scrollY = + window.scrollY + + box.top + - style['scroll-margin-top'].replace('px', ''); + + evt.preventDefault(); + history.pushState({}, '', href); + window.scrollTo({top: scrollY, behavior: 'smooth'}); + target.focus({preventScroll: true}); + + const maxScroll = + document.body.scrollHeight + - window.innerHeight; + + if (scrollY > maxScroll && target.classList.contains('content-heading')) { + if (state.highlightedTarget) { + state.highlightedTarget.classList.remove('highlight-hash-link'); + } + + target.classList.add('highlight-hash-link'); + state.highlightedTarget = target; + } + + processScrollingAfterHashLinkClicked(); + + for (const handler of event.whenHashLinkClicked) { + handler({ + link: hashLink, + }); + } + }); + } + + for (const target of info.targets) { + target.addEventListener('animationend', evt => { + if (evt.animationName !== 'highlight-hash-link') return; + target.classList.remove('highlight-hash-link'); + if (target !== state.highlightedTarget) return; + state.highlightedTarget = null; + }); + } +} + +clientSteps.getPageReferences.push(getHashLinkReferences); +clientSteps.addPageListeners.push(addHashLinkListeners); + +// Sticky content heading --------------------------------- + +const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { + stickyContainers: null, + + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCovers: null, + contentCoversReveal: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + }, +}; + +function getStickyHeadingReferences() { + const info = stickyHeadingInfo; + + info.stickyContainers = + Array.from(document.getElementsByClassName('content-sticky-heading-container')); + + info.stickyCoverContainers = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-cover-container')); + + info.stickyCovers = + info.stickyCoverContainers + .map(el => el?.querySelector('.content-sticky-heading-cover')); + + info.stickyCoverTextAreas = + info.stickyCovers + .map(el => el?.querySelector('.image-text-area')); + + info.stickySubheadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-subheading-row')); + + info.stickySubheadings = + info.stickySubheadingRows + .map(el => el.querySelector('h2')); + + info.contentContainers = + info.stickyContainers + .map(el => el.parentElement); + + info.contentCovers = + info.contentContainers + .map(el => el.querySelector('#cover-art-container')); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); +} + +function removeTextPlaceholderStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const hasTextArea = + info.stickyCoverTextAreas.map(el => !!el); + + const coverContainersWithTextArea = + info.stickyCoverContainers + .filter((_el, index) => hasTextArea[index]); + + for (const el of coverContainersWithTextArea) { + el.remove(); + } + + info.stickyCoverContainers = + info.stickyCoverContainers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCovers = + info.stickyCovers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCoverTextAreas = + info.stickyCoverTextAreas + .slice() + .fill(null); +} + +function addRevealClassToStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const stickyCoversWhichReveal = + info.stickyCovers + .filter((_el, index) => info.contentCoversReveal[index]); + + for (const el of stickyCoversWhichReveal) { + el.classList.add('content-sticky-heading-cover-needs-reveal'); + } +} + +function addRevealListenersForStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const stickyCovers = info.stickyCovers.slice(); + const contentCovers = info.contentCovers.slice(); + + filterMultipleArrays( + stickyCovers, + contentCovers, + (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); + + for (const {stickyCover, contentCover} of stitchArrays({ + stickyCover: stickyCovers, + contentCover: contentCovers, + })) { + // TODO: Janky - should use internal event instead of DOM event + contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { + stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); + }); + } +} + +function topOfViewInside(el, scroll = window.scrollY) { + return ( + scroll > el.offsetTop && + scroll < el.offsetTop + el.offsetHeight); +} + +function updateStickyCoverVisibility(index) { + const info = stickyHeadingInfo; + + const stickyCoverContainer = info.stickyCoverContainers[index]; + const contentCover = info.contentCovers[index]; + + if (contentCover && stickyCoverContainer) { + if (contentCover.getBoundingClientRect().bottom < 0) { + stickyCoverContainer.classList.add('visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const info = stickyHeadingInfo; + + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickySubheading = info.stickySubheadings[index]; + + if (stickySubheading.childNodes.length === 0) { + // Supply a non-breaking space to ensure correct basic line height. + stickySubheading.appendChild(document.createTextNode('\xA0')); + } + + const stickyContainer = info.stickyContainers[index]; + const stickyRect = stickyContainer.getBoundingClientRect(); + + // TODO: Should this compute with the subheading row instead of h2? + const subheadingRect = stickySubheading.getBoundingClientRect(); + + const stickyBottom = stickyRect.bottom + subheadingRect.height; + + // Iterate from bottom to top of the content area. + const contentHeadings = info.contentHeadings[index]; + for (const heading of contentHeadings.slice().reverse()) { + const headingRect = heading.getBoundingClientRect(); + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const info = stickyHeadingInfo; + const {event, state} = info; + + const closestHeading = getContentHeadingClosestToStickySubheading(index); + + if (state.displayedHeading === closestHeading) return; + + const stickySubheadingRow = info.stickySubheadingRows[index]; + + if (closestHeading) { + const stickySubheading = info.stickySubheadings[index]; + + // Array.from needed to iterate over a live array with for..of + for (const child of Array.from(stickySubheading.childNodes)) { + child.remove(); + } + + for (const child of closestHeading.childNodes) { + if (child.classList?.contains('content-heading-accent')) { + continue; + } + + if (child.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); + } + } else { + stickySubheading.appendChild(child.cloneNode(true)); + } + } + + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } + + const oldDisplayedHeading = state.displayedHeading; + + state.displayedHeading = closestHeading; + + for (const handler of event.whenDisplayedHeadingChanges) { + handler(index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); + } +} + +function updateStickyHeadings(index) { + updateStickyCoverVisibility(index); + updateStickySubheadingContent(index); +} + +function initializeStateForStickyHeadings() { + for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { + updateStickyHeadings(i); + } +} + +function addScrollListenerForStickyHeadings() { + document.addEventListener('scroll', () => { + for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { + updateStickyHeadings(i); + } + }); +} + +clientSteps.getPageReferences.push(getStickyHeadingReferences); +clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); +clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); +clientSteps.initializeState.push(initializeStateForStickyHeadings); +clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); +clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); + +// Image overlay ------------------------------------------ + +function addImageOverlayClickHandlers() { + const container = document.getElementById('image-overlay-container'); + + if (!container) { + console.warn(`#image-overlay-container missing, image overlay module disabled.`); + return; + } + + for (const link of document.querySelectorAll('.image-link')) { + if (link.querySelector('img').hasAttribute('data-no-image-preview')) { + continue; + } + + link.addEventListener('click', handleImageLinkClicked); + } + + const actionContainer = document.getElementById('image-overlay-action-container'); + + container.addEventListener('click', handleContainerClicked); + document.body.addEventListener('keydown', handleKeyDown); + + function handleContainerClicked(evt) { + // Only hide the image overlay if actually clicking the background. + if (evt.target !== container) { + return; + } + + // If you clicked anything close to or beneath the action bar, don't hide + // the image overlay. + const rect = actionContainer.getBoundingClientRect(); + if (evt.clientY >= rect.top - 40) { + return; + } + + container.classList.remove('visible'); + } + + function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + container.classList.remove('visible'); + } + } +} + +function handleImageLinkClicked(evt) { + if (evt.metaKey || evt.shiftKey || evt.altKey) { + return; + } + evt.preventDefault(); + + const container = document.getElementById('image-overlay-container'); + container.classList.add('visible'); + container.classList.remove('loaded'); + container.classList.remove('errored'); + + const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); + const mainImage = document.getElementById('image-overlay-image'); + const thumbImage = document.getElementById('image-overlay-image-thumb'); + + const {href: originalSrc} = evt.target.closest('a'); + + const { + src: embeddedSrc, + dataset: { + originalSize: originalFileSize, + thumbs: availableThumbList, + }, + } = evt.target.closest('a').querySelector('img'); + + updateFileSizeInformation(originalFileSize); + + let mainSrc = null; + let thumbSrc = null; + + if (availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); + mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); + thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); + // Show the thumbnail size on each element's data attributes. + // Y'know, just for debugging convenience. + mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + } else { + mainSrc = originalSrc; + thumbSrc = null; + mainImage.dataset.displayingThumb = ''; + thumbImage.dataset.displayingThumb = ''; + } + + if (thumbSrc) { + thumbImage.src = thumbSrc; + thumbImage.style.display = null; + } else { + thumbImage.src = ''; + thumbImage.style.display = 'none'; + } + + for (const viewOriginal of allViewOriginal) { + viewOriginal.href = originalSrc; + } + + mainImage.addEventListener('load', handleMainImageLoaded); + mainImage.addEventListener('error', handleMainImageErrored); + + container.style.setProperty('--download-progress', '0%'); + loadImage(mainSrc, progress => { + container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); + }).then( + blobUrl => { + mainImage.src = blobUrl; + container.style.setProperty('--download-progress', '100%'); + }, + handleMainImageErrored); + + function handleMainImageLoaded() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + container.classList.add('loaded'); + } + + function handleMainImageErrored() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + container.classList.add('errored'); + } +} + +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { + // Assuming a square, the image will be constrained to the lesser window + // dimension. Coefficient here matches CSS dimensions for image overlay. + const constrainedLength = Math.floor(Math.min( + 0.80 * window.innerWidth, + 0.80 * window.innerHeight)); + + // Match device pixel ratio, which is 2x for "retina" displays and certain + // device configurations. + const visualLength = window.devicePixelRatio * constrainedLength; + + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. + const goodEnoughThreshold = 0.90; + + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } + } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; +} + +function updateFileSizeInformation(fileSize) { + const fileSizeWarningThreshold = 8 * 10 ** 6; + + const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); + const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); + + if (!fileSize) { + actionContentWithSize.classList.remove('visible'); + actionContentWithoutSize.classList.add('visible'); + return; + } + + actionContentWithoutSize.classList.remove('visible'); + actionContentWithSize.classList.add('visible'); + + const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); + const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); + const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); + const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); + const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + fileSizeWarning.classList.add('visible'); + } else { + fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + megabytesContainer.classList.add('visible'); + kilobytesContainer.classList.remove('visible'); + megabytesContent.innerText = round(6); + } else { + megabytesContainer.classList.remove('visible'); + kilobytesContainer.classList.add('visible'); + kilobytesContent.innerText = round(3); + } + + void fileSizeWarning; +} + +addImageOverlayClickHandlers(); + +/** + * Credits: Parziphal, Feb 13, 2017 + * https://stackoverflow.com/a/42196770 + * + * Loads an image with progress callback. + * + * The `onprogress` callback will be called by XMLHttpRequest's onprogress + * event, and will receive the loading progress ratio as an whole number. + * However, if it's not possible to compute the progress ratio, `onprogress` + * will be called only once passing -1 as progress value. This is useful to, + * for example, change the progress animation to an undefined animation. + * + * @param {string} imageUrl The image to load + * @param {Function} onprogress + * @return {Promise} + */ +function loadImage(imageUrl, onprogress) { + return new Promise((resolve, reject) => { + var xhr = new XMLHttpRequest(); + var notifiedNotComputable = false; + + xhr.open('GET', imageUrl, true); + xhr.responseType = 'arraybuffer'; + + xhr.onprogress = function(ev) { + if (ev.lengthComputable) { + onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); + } else { + if (!notifiedNotComputable) { + notifiedNotComputable = true; + onprogress(-1); + } + } + } + + xhr.onloadend = function() { + if (!xhr.status.toString().match(/^2/)) { + reject(xhr); + } else { + if (!notifiedNotComputable) { + onprogress(100); + } + + var options = {} + var headers = xhr.getAllResponseHeaders(); + var m = headers.match(/^Content-Type:\s*(.*?)$/mi); + + if (m && m[1]) { + options.type = m[1]; + } + + var blob = new Blob([this.response], options); + + resolve(window.URL.createObjectURL(blob)); + } + } + + xhr.send(); + }); +} + +// Group contributions table ------------------------------ + +const groupContributionsTableInfo = + Array.from(document.querySelectorAll('#content dl')) + .filter(dl => dl.querySelector('a.group-contributions-sort-button')) + .map(dl => ({ + sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), + sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), + sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), + sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), + })); + +function sortGroupContributionsTableBy(info, sort) { + const [showThese, hideThese] = + (sort === 'count' + ? [info.sortingByCountElements, info.sortingByDurationElements] + : [info.sortingByDurationElements, info.sortingByCountElements]); + + for (const element of showThese) element.classList.add('visible'); + for (const element of hideThese) element.classList.remove('visible'); +} + +for (const info of groupContributionsTableInfo) { + info.sortingByCountLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'duration'); + }); + + info.sortingByDurationLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'count'); + }); +} + +// Sticky commentary sidebar ------------------------------ + +const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { + sidebar: null, + + sidebarTrackLinks: null, + sidebarTrackDirectories: null, + + sidebarTrackSections: null, + sidebarTrackSectionStartIndices: null, + + state: { + currentTrackSection: null, + currentTrackLink: null, + justChangedTrackSection: false, + }, +}; + +function getAlbumCommentarySidebarReferences() { + const info = albumCommentarySidebarInfo; + + info.sidebar = + document.getElementById('sidebar-left'); + + info.sidebarHeading = + info.sidebar.querySelector('h1'); + + info.sidebarTrackLinks = + Array.from(info.sidebar.querySelectorAll('li a')); + + info.sidebarTrackDirectories = + info.sidebarTrackLinks + .map(el => el.getAttribute('href')?.slice(1) ?? null); + + info.sidebarTrackSections = + Array.from(info.sidebar.getElementsByTagName('details')); + + info.sidebarTrackSectionStartIndices = + info.sidebarTrackSections + .map(details => details.querySelector('ol, ul')) + .reduce( + (accumulator, _list, index, array) => + (empty(accumulator) + ? [0] + : [ + ...accumulator, + (accumulator[accumulator.length - 1] + + array[index - 1].querySelectorAll('li a').length), + ]), + []); +} + +function scrollAlbumCommentarySidebar() { + const info = albumCommentarySidebarInfo; + const {state} = info; + const {currentTrackLink, currentTrackSection} = state; + + if (!currentTrackLink) { + return; + } + + const {sidebar, sidebarHeading} = info; + + const scrollTop = sidebar.scrollTop; + + const headingRect = sidebarHeading.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + + const stickyPadding = headingRect.height; + const sidebarViewportHeight = sidebarRect.height - stickyPadding; + + const linkRect = currentTrackLink.getBoundingClientRect(); + const sectionRect = currentTrackSection.getBoundingClientRect(); + + const sectionTopEdge = + sectionRect.top - (sidebarRect.top - scrollTop); + + const sectionHeight = + sectionRect.height; + + const sectionScrollTop = + sectionTopEdge - stickyPadding - 10; + + const linkTopEdge = + linkRect.top - (sidebarRect.top - scrollTop); + + const linkBottomEdge = + linkRect.bottom - (sidebarRect.top - scrollTop); + + const linkScrollTop = + linkTopEdge - stickyPadding - 5; + + const linkDistanceFromSection = + linkScrollTop - sectionTopEdge; + + const linkVisibleFromTopOfSection = + linkBottomEdge - sectionTopEdge > sidebarViewportHeight; + + const linkScrollBottom = + linkScrollTop - sidebarViewportHeight + linkRect.height + 20; + + const maxScrollInViewport = + scrollTop + stickyPadding + sidebarViewportHeight; + + const minScrollInViewport = + scrollTop + stickyPadding; + + if (linkBottomEdge > maxScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (linkTopEdge < minScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (state.justChangedTrackSection) { + if (sectionHeight < sidebarViewportHeight) { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } +} + +function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { + const info = albumCommentarySidebarInfo; + const {state} = info; + + const trackIndex = + (trackDirectory + ? info.sidebarTrackDirectories + .indexOf(trackDirectory) + : -1); + + const sectionIndex = + (trackIndex >= 0 + ? info.sidebarTrackSectionStartIndices + .findIndex((start, index, array) => + (index === array.length - 1 + ? true + : trackIndex < array[index + 1])) + : -1); + + const sidebarTrackLink = + (trackIndex >= 0 + ? info.sidebarTrackLinks[trackIndex] + : null); + + const sidebarTrackSection = + (sectionIndex >= 0 + ? info.sidebarTrackSections[sectionIndex] + : null); + + state.currentTrackLink?.classList?.remove('current'); + state.currentTrackLink = sidebarTrackLink; + state.currentTrackLink?.classList?.add('current'); + + if (sidebarTrackSection !== state.currentTrackSection) { + if (sidebarTrackSection && !sidebarTrackSection.open) { + if (state.currentTrackSection) { + state.currentTrackSection.open = false; + } + + sidebarTrackSection.open = true; + } + + state.currentTrackSection?.classList?.remove('current'); + state.currentTrackSection = sidebarTrackSection; + state.currentTrackSection?.classList?.add('current'); + state.justChangedTrackSection = true; + } else { + state.justChangedTrackSection = false; + } +} + +function addAlbumCommentaryInternalListeners() { + const info = albumCommentarySidebarInfo; + + const mainContentIndex = + (stickyHeadingInfo.contentContainers ?? []) + .findIndex(({id}) => id === 'content'); + + if (mainContentIndex === -1) return; + + stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { + if (index !== mainContentIndex) return; + if (hashLinkInfo.state.scrollingAfterClick) return; + + const trackDirectory = + (newHeading + ? newHeading.id + : null); + + markDirectoryAsCurrentForAlbumCommentary(trackDirectory); + scrollAlbumCommentarySidebar(); + }); + + hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { + const hash = link.getAttribute('href').slice(1); + if (!info.sidebarTrackDirectories.includes(hash)) return; + markDirectoryAsCurrentForAlbumCommentary(hash); + }); +} + +if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { + clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); + clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); +} + +// Run setup steps ---------------------------------------- + +for (const [key, steps] of Object.entries(clientSteps)) { + for (const step of steps) { + try { + step(); + } catch (error) { + console.warn(`During ${key}, failed to run ${step.name}`); + console.debug(error); + } + } +} -- cgit 1.3.0-6-gf8a5 From 91f00a36b33d13630ea7a9ac6fcd03110b0f1a73 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:55:22 -0400 Subject: client: tidy & better isolate random links code in client info --- src/content/dependencies/listRandomPageLinks.js | 6 +- src/static/client3.js | 218 +++++++++++++----------- src/static/site5.css | 5 + 3 files changed, 128 insertions(+), 101 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 0b904019..5e2972ad 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -114,12 +114,10 @@ export default { language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'), })), - html.tag('p', - {class: 'js-hide-once-data'}, + html.tag('p', {id: 'data-loading-line'}, language.$('listingPage.other.randomPages.dataLoadingLine')), - html.tag('p', - {class: 'js-show-once-data'}, + html.tag('p', {id: 'data-loaded-line'}, language.$('listingPage.other.randomPages.dataLoadedLine')), ], diff --git a/src/static/client3.js b/src/static/client3.js index 94d4c4e2..31eddfe9 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -13,10 +13,6 @@ import { getArtistNumContributions, } from '../util/wiki-data.js'; -let albumData, artistData; - -let ready = false; - const clientInfo = window.hsmusicClientInfo = Object.create(null); const clientSteps = { @@ -79,11 +75,6 @@ function getRefDirectory(ref) { return ref.split(':')[1]; } -function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find((album) => album.directory === directory); -} - // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); @@ -107,6 +98,11 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { nextLink: null, previousLink: null, randomLink: null, + + state: { + albumData: null, + artistData: null, + }, }; function getScriptedLinkReferences() { @@ -128,93 +124,115 @@ function getScriptedLinkReferences() { function addRandomLinkListeners() { for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } + a.addEventListener('click', domEvent => { + handleRandomLinkClicked(a, domEvent); + }); + } +} + +function handleRandomLinkClicked(a, domEvent) { + const href = determineRandomLinkHref(a); + + if (!href) { + domEvent.preventDefault(); + return; + } + + setTimeout(() => { + a.href = '#' + }); + + a.href = href; +} + +function determineRandomLinkHref(a) { + const {state} = scriptedLinkInfo; + const {albumData, artistData} = state; + + const tracksFromAlbums = albums => + albums + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); + + switch (a.dataset.random) { + case 'album': + if (!albumData) return null; + return openAlbum(pick(albumData).directory); + + case 'track': + if (!albumData) return null; + return openTrack(getRefDirectory(pick(tracksFromAlbums(albumData)))); - const tracks = albumData => + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + return openAlbum(pick(albumDirectories)); + } + + case 'track-in-group-dl': { + if (!albumData) return null; + + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + const filteredAlbumData = albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + .filter(album => + albumDirectories.includes(album.directory)); - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - a.href = openAlbum(pick(albumDirectories)); - break; - } + return openTrack(getRefDirectory(pick(tracksFromAlbums(filteredAlbumData)))); + } - case 'track-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) + case 'track-in-sidebar': { + // Note that the container for track links may be
                or
                  , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); + return pick(trackLinks).href; + } - const filteredAlbumData = - albumData.filter(album => - albumDirectories.includes(album.directory)); + case 'track-in-album': { + if (!albumData) return null; - a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); - break; - } + const directory = cssProp(a, '--album-directory'); + const {tracks} = albumData.find(album => album.directory === directory); - case 'track-in-sidebar': { - // Note that the container for track links may be
                    or
                      , and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - a.href = pick(trackLinks).href; - break; - } + return openTrack(getRefDirectory(pick(tracks))); + } + + case 'artist': { + if (!artistData) return null; + return openArtist(pick(artistData).directory); + } - case 'track-in-album': - a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - break; + case 'artist-more-than-one-contrib': { + if (!artistData) return null; - case 'artist': - a.href = openArtist(pick(artistData).directory); - break; + const artists = + artistData + .filter(artist => getArtistNumContributions(artist) > 1); - case 'artist-more-than-one-contrib': - a.href = - openArtist( - pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) - .directory); - break; - } - }); + return openArtist(pick(artists).directory); + } } } @@ -263,22 +281,28 @@ clientSteps.addPageListeners.push(addNavigationKeyPressListeners); clientSteps.addPageListeners.push(addRevealLinkClickListeners); clientSteps.mutatePageContent.push(mutateNavigationLinkContent); -const elements1 = document.getElementsByClassName('js-hide-once-data'); -const elements2 = document.getElementsByClassName('js-show-once-data'); +if ( + document.documentElement.dataset.urlKey === 'localized.listing' && + document.documentElement.dataset.urlValue0 === 'random' +) { + const dataLoadingLine = document.getElementById('data-loading-line'); + const dataLoadedLine = document.getElementById('data-loaded-line'); -for (const element of elements1) element.style.display = 'block'; + dataLoadingLine.style.display = 'block'; -fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { - albumData = data.albumData; - artistData = data.artistData; + fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { + const {state} = scriptedLinkInfo; - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; + state.albumData = data.albumData; + state.artistData = data.artistData; - ready = true; - }); + dataLoadingLine.style.display = 'none'; + dataLoadedLine.style.display = 'block'; + }); + +} // Data & info card --------------------------------------- diff --git a/src/static/site5.css b/src/static/site5.css index 014e6d25..9111f3a6 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -792,6 +792,11 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line { + display: none; +} + /* Images */ .image-container { -- cgit 1.3.0-6-gf8a5 From 28371f6e029fb86ba536bf4c20e787dba44d202b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:56:10 -0400 Subject: content, client: handle random data failing to load --- src/content/dependencies/listRandomPageLinks.js | 3 +++ src/static/client3.js | 15 ++++++++++++++- src/static/site5.css | 7 ++++++- src/strings-default.yaml | 5 ++++- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 5e2972ad..18585696 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -119,6 +119,9 @@ export default { html.tag('p', {id: 'data-loaded-line'}, language.$('listingPage.other.randomPages.dataLoadedLine')), + + html.tag('p', {id: 'data-error-line'}, + language.$('listingPage.other.randomPages.dataErrorLine')), ], showSkipToSection: true, diff --git a/src/static/client3.js b/src/static/client3.js index 31eddfe9..d2f2bd17 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -287,6 +287,7 @@ if ( ) { const dataLoadingLine = document.getElementById('data-loading-line'); const dataLoadedLine = document.getElementById('data-loaded-line'); + const dataErrorLine = document.getElementById('data-error-line'); dataLoadingLine.style.display = 'block'; @@ -300,8 +301,20 @@ if ( dataLoadingLine.style.display = 'none'; dataLoadedLine.style.display = 'block'; - }); + }) + .catch(() => { + const info = scriptedLinkInfo; + + for (const a of info.randomLinks) { + const href = determineRandomLinkHref(a); + if (!href) { + a.removeAttribute('href'); + } + } + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; + }); } // Data & info card --------------------------------------- diff --git a/src/static/site5.css b/src/static/site5.css index 9111f3a6..bb83fe67 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -793,10 +793,15 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { } html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line { +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { display: none; } +html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { + opacity: 0.7; +} + /* Images */ .image-container { diff --git a/src/strings-default.yaml b/src/strings-default.yaml index a21758e7..e6b8d6db 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -1556,7 +1556,7 @@ listingPage: If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry. - # dataLoadingLine, dataLoadedLine: + # dataLoadingLine, dataLoadedLine, dataErrorLine: # Since the links on this page depend on access to a fairly # large data file that is downloaded separately and in the # background, these messages indicate the status of that @@ -1568,6 +1568,9 @@ listingPage: dataLoadedLine: >- (Data files have finished being downloaded. The links should work!) + dataErrorLine: >- + (Data files failed to download. Sorry, some of these links won't work right now!) + chunk: title: -- cgit 1.3.0-6-gf8a5 From 84c367c27ca82938c61b696ef24601d1cff9b2b3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 19:16:52 -0400 Subject: client, write: data.json -> random-link-data.json --- src/static/client3.js | 120 +++++++++++++++++-------------- src/write/build-modes/live-dev-server.js | 11 +-- src/write/build-modes/static-build.js | 18 ++--- src/write/common-templates.js | 40 ++++++----- 4 files changed, 100 insertions(+), 89 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index d2f2bd17..8372a268 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -7,11 +7,7 @@ import {getColors} from '../util/colors.js'; import {empty, stitchArrays} from '../util/sugar.js'; - -import { - filterMultipleArrays, - getArtistNumContributions, -} from '../util/wiki-data.js'; +import {filterMultipleArrays} from '../util/wiki-data.js'; const clientInfo = window.hsmusicClientInfo = Object.create(null); @@ -71,10 +67,6 @@ function cssProp(el, key) { return getComputedStyle(el).getPropertyValue(key).trim(); } -function getRefDirectory(ref) { - return ref.split(':')[1]; -} - // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); @@ -100,8 +92,10 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { randomLink: null, state: { - albumData: null, - artistData: null, + albumDirectories: null, + albumTrackDirectories: null, + artistDirectories: null, + artistNumContributions: null, }, }; @@ -147,21 +141,31 @@ function handleRandomLinkClicked(a, domEvent) { function determineRandomLinkHref(a) { const {state} = scriptedLinkInfo; - const {albumData, artistData} = state; - const tracksFromAlbums = albums => - albums - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + const trackDirectoriesFromAlbumDirectories = albumDirectories => + albumDirectories + .map(directory => state.albumDirectories.indexOf(directory)) + .map(index => state.albumTrackDirectories[index]) + .reduce((acc, trackDirectories) => acc.concat(trackDirectories, [])); switch (a.dataset.random) { - case 'album': - if (!albumData) return null; - return openAlbum(pick(albumData).directory); + case 'album': { + const {albumDirectories} = state; + if (!albumDirectories) return null; - case 'track': - if (!albumData) return null; - return openTrack(getRefDirectory(pick(tracksFromAlbums(albumData)))); + return openAlbum(pick(albumDirectories)); + } + + case 'track': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + albumDirectories); + + return openTrack(pick(trackDirectories)); + } case 'album-in-group-dl': { const albumLinks = @@ -170,15 +174,16 @@ function determineRandomLinkHref(a) { .nextElementSibling .querySelectorAll('li a')) - const albumDirectories = + const listAlbumDirectories = albumLinks .map(a => cssProp(a, '--album-directory')); - return openAlbum(pick(albumDirectories)); + return openAlbum(pick(listAlbumDirectories)); } case 'track-in-group-dl': { - if (!albumData) return null; + const {albumDirectories} = state; + if (!albumDirectories) return null; const albumLinks = Array.from(a @@ -186,16 +191,15 @@ function determineRandomLinkHref(a) { .nextElementSibling .querySelectorAll('li a')) - const albumDirectories = + const listAlbumDirectories = albumLinks .map(a => cssProp(a, '--album-directory')); - const filteredAlbumData = - albumData - .filter(album => - albumDirectories.includes(album.directory)); + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + listAlbumDirectories); - return openTrack(getRefDirectory(pick(tracksFromAlbums(filteredAlbumData)))); + return openTrack(pick(trackDirectories)); } case 'track-in-sidebar': { @@ -211,27 +215,32 @@ function determineRandomLinkHref(a) { } case 'track-in-album': { - if (!albumData) return null; + const {albumDirectories, albumTrackDirectories} = state; + if (!albumDirectories || !albumTrackDirectories) return null; - const directory = cssProp(a, '--album-directory'); - const {tracks} = albumData.find(album => album.directory === directory); + const albumDirectory = cssProp(a, '--album-directory'); + const albumIndex = albumDirectories.indexOf(albumDirectory); + const trackDirectories = albumTrackDirectories[albumIndex]; - return openTrack(getRefDirectory(pick(tracks))); + return openTrack(pick(trackDirectories)); } case 'artist': { - if (!artistData) return null; - return openArtist(pick(artistData).directory); + const {artistDirectories} = state; + if (!artistDirectories) return null; + + return openArtist(pick(artistDirectories)); } case 'artist-more-than-one-contrib': { - if (!artistData) return null; + const {artistDirectories, artistNumContributions} = state; + if (!artistDirectories || !artistNumContributions) return null; - const artists = - artistData - .filter(artist => getArtistNumContributions(artist) > 1); + const filteredArtistDirectories = + artistDirectories + .filter((_artist, index) => artistNumContributions[index] > 1); - return openArtist(pick(artists).directory); + return openArtist(pick(filteredArtistDirectories)); } } } @@ -291,29 +300,32 @@ if ( dataLoadingLine.style.display = 'block'; - fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { + fetch(rebase('random-link-data.json', 'rebaseShared')) + .then(data => data.json()) + .then(data => { const {state} = scriptedLinkInfo; - state.albumData = data.albumData; - state.artistData = data.artistData; + Object.assign(state, { + albumDirectories: data.albumDirectories, + albumTrackDirectories: data.albumTrackDirectories, + artistDirectories: data.artistDirectories, + artistNumContributions: data.artistNumContributions, + }); dataLoadingLine.style.display = 'none'; dataLoadedLine.style.display = 'block'; + }, () => { + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; }) - .catch(() => { - const info = scriptedLinkInfo; - - for (const a of info.randomLinks) { + .then(() => { + const {randomLinks} = scriptedLinkInfo; + for (const a of randomLinks) { const href = determineRandomLinkHref(a); if (!href) { a.removeAttribute('href'); } } - - dataLoadingLine.style.display = 'none'; - dataErrorLine.style.display = 'block'; }); } diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index ab6ceecb..8828a5bd 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -16,7 +16,7 @@ import { } from '#urls'; import {bindUtilities} from '../bind-utilities.js'; -import {generateGlobalWikiDataJSON, generateRedirectHTML} from '../common-templates.js'; +import {generateRandomLinkDataJSON, generateRedirectHTML} from '../common-templates.js'; const defaultHost = '0.0.0.0'; const defaultPort = 8002; @@ -157,19 +157,20 @@ export async function go({ // Specialized routes - if (pathname === '/data.json') { + if (pathname === '/random-link-data.json') { try { - const json = generateGlobalWikiDataJSON({ + const json = generateRandomLinkDataJSON({ serializeThings, wikiData, }); + response.writeHead(200, contentTypeJSON); response.end(json); - if (loudResponses) console.log(`${requestHead} [200] /data.json`); + if (loudResponses) console.log(`${requestHead} [200] ${pathname}`); } catch (error) { response.writeHead(500, contentTypeJSON); response.end(`Internal error serializing wiki JSON`); - console.error(`${requestHead} [500] /data.json`); + console.error(`${requestHead} [500] ${pathname}`); showError(error); } return; diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index b6dc9643..a8e0eb23 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -31,7 +31,7 @@ import { } from '#urls'; import {bindUtilities} from '../bind-utilities.js'; -import {generateRedirectHTML, generateGlobalWikiDataJSON} from '../common-templates.js'; +import {generateRedirectHTML, generateRandomLinkDataJSON} from '../common-templates.js'; const pageFlags = Object.keys(pageSpecs); @@ -145,14 +145,8 @@ export async function go({ }); await writeSharedFilesAndPages({ - language: defaultLanguage, outputPath, - urls, - wikiData, - wikiDataJSON: generateGlobalWikiDataJSON({ - serializeThings, - wikiData, - }), + randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}), }); const buildSteps = writeAll @@ -477,12 +471,12 @@ async function writeFavicon({ async function writeSharedFilesAndPages({ outputPath, - wikiDataJSON, + randomLinkDataJSON, }) { return progressPromiseAll(`Writing files & pages shared across languages.`, [ - wikiDataJSON && + randomLinkDataJSON && writeFile( - path.join(outputPath, 'data.json'), - wikiDataJSON), + path.join(outputPath, 'random-link-data.json'), + randomLinkDataJSON), ].filter(Boolean)); } diff --git a/src/write/common-templates.js b/src/write/common-templates.js index 2dd4c924..d897a73b 100644 --- a/src/write/common-templates.js +++ b/src/write/common-templates.js @@ -1,4 +1,5 @@ import * as html from '#html'; +import {getArtistNumContributions} from '#wiki-data'; export function generateRedirectHTML(title, target, {language}) { return `\n` + html.tag('html', [ @@ -30,22 +31,25 @@ export function generateRedirectHTML(title, target, {language}) { ]); } -export function generateGlobalWikiDataJSON({ - serializeThings, - wikiData, -}) { - const stringifyThings = thingData => - JSON.stringify(serializeThings(thingData)); - - return '{\n' + - ([ - `"albumData": ${stringifyThings(wikiData.albumData)},`, - wikiData.wikiInfo.enableFlashesAndGames && - `"flashData": ${stringifyThings(wikiData.flashData)},`, - `"artistData": ${stringifyThings(wikiData.artistData)}`, - ] - .filter(Boolean) - .map(line => ' ' + line) - .join('\n')) + - '\n}'; +export function generateRandomLinkDataJSON({wikiData}) { + const {albumData, artistData} = wikiData; + + return JSON.stringify({ + albumDirectories: + albumData + .map(album => album.directory), + + albumTrackDirectories: + albumData + .map(album => album.tracks + .map(track => track.directory)), + + artistDirectories: + artistData + .map(artist => artist.directory), + + artistNumContributions: + artistData + .map(artist => getArtistNumContributions(artist)), + }); } -- cgit 1.3.0-6-gf8a5 From 4b62389403ef84497d5855e8ef1359ebf5784881 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:12:06 -0400 Subject: content: generateCommentarySectionEntry -> generateCommentaryEntry --- .../dependencies/generateCommentaryEntry.js | 77 ++++++++++++++++++++++ .../dependencies/generateCommentarySection.js | 4 +- .../dependencies/generateCommentarySectionEntry.js | 77 ---------------------- 3 files changed, 79 insertions(+), 79 deletions(-) create mode 100644 src/content/dependencies/generateCommentaryEntry.js delete mode 100644 src/content/dependencies/generateCommentarySectionEntry.js diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js new file mode 100644 index 00000000..22e8fd1e --- /dev/null +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -0,0 +1,77 @@ +export default { + contentDependencies: ['linkArtist', 'transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + artistLink: + (entry.artist && !entry.artistDisplayText + ? relation('linkArtist', entry.artist) + : null), + + artistsContent: + (entry.artistDisplayText + ? relation('transformContent', entry.artistDisplayText) + : null), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + + bodyContent: + (entry.body + ? relation('transformContent', entry.body) + : null), + }), + + data: (entry) => ({ + date: entry.date, + }), + + generate(data, relations, {html, language}) { + const artistsSpan = + html.tag('span', {class: 'commentary-entry-artists'}, + (relations.artistsContent + ? relations.artistsContent.slot('mode', 'inline') + : relations.artistLink + ? relations.artistLink + : language.$('misc.artistCommentary.noArtist'))); + + const accentParts = ['misc.artistCommentary.entry.title.accent']; + const accentOptions = {}; + + if (relations.annotationContent) { + accentParts.push('withAnnotation'); + accentOptions.annotation = + relations.annotationContent.slot('mode', 'inline'); + } + + if (data.date) { + accentParts.push('withDate'); + accentOptions.date = + language.formatDate(data.date); + } + + const accent = + (accentParts.length > 1 + ? html.tag('span', {class: 'commentary-entry-accent'}, + language.$(...accentParts, accentOptions)) + : null); + + const titleParts = ['misc.artistCommentary.entry.title']; + const titleOptions = {artists: artistsSpan}; + + if (accent) { + titleParts.push('withAccent'); + titleOptions.accent = accent; + } + + return html.tags([ + html.tag('p', {class: 'commentary-entry-heading'}, + language.$(...titleParts, titleOptions)), + + html.tag('blockquote', {class: 'commentary-entry-body'}, + relations.bodyContent.slot('mode', 'multiline')), + ]); + }, +}; diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js index d08c3c90..8ae1b2d0 100644 --- a/src/content/dependencies/generateCommentarySection.js +++ b/src/content/dependencies/generateCommentarySection.js @@ -1,7 +1,7 @@ export default { contentDependencies: [ 'transformContent', - 'generateCommentarySectionEntry', + 'generateCommentaryEntry', 'generateContentHeading', ], @@ -13,7 +13,7 @@ export default { entries: entries.map(entry => - relation('generateCommentarySectionEntry', entry)), + relation('generateCommentaryEntry', entry)), }), generate: (relations, {html, language}) => diff --git a/src/content/dependencies/generateCommentarySectionEntry.js b/src/content/dependencies/generateCommentarySectionEntry.js deleted file mode 100644 index 22e8fd1e..00000000 --- a/src/content/dependencies/generateCommentarySectionEntry.js +++ /dev/null @@ -1,77 +0,0 @@ -export default { - contentDependencies: ['linkArtist', 'transformContent'], - extraDependencies: ['html', 'language'], - - relations: (relation, entry) => ({ - artistLink: - (entry.artist && !entry.artistDisplayText - ? relation('linkArtist', entry.artist) - : null), - - artistsContent: - (entry.artistDisplayText - ? relation('transformContent', entry.artistDisplayText) - : null), - - annotationContent: - (entry.annotation - ? relation('transformContent', entry.annotation) - : null), - - bodyContent: - (entry.body - ? relation('transformContent', entry.body) - : null), - }), - - data: (entry) => ({ - date: entry.date, - }), - - generate(data, relations, {html, language}) { - const artistsSpan = - html.tag('span', {class: 'commentary-entry-artists'}, - (relations.artistsContent - ? relations.artistsContent.slot('mode', 'inline') - : relations.artistLink - ? relations.artistLink - : language.$('misc.artistCommentary.noArtist'))); - - const accentParts = ['misc.artistCommentary.entry.title.accent']; - const accentOptions = {}; - - if (relations.annotationContent) { - accentParts.push('withAnnotation'); - accentOptions.annotation = - relations.annotationContent.slot('mode', 'inline'); - } - - if (data.date) { - accentParts.push('withDate'); - accentOptions.date = - language.formatDate(data.date); - } - - const accent = - (accentParts.length > 1 - ? html.tag('span', {class: 'commentary-entry-accent'}, - language.$(...accentParts, accentOptions)) - : null); - - const titleParts = ['misc.artistCommentary.entry.title']; - const titleOptions = {artists: artistsSpan}; - - if (accent) { - titleParts.push('withAccent'); - titleOptions.accent = accent; - } - - return html.tags([ - html.tag('p', {class: 'commentary-entry-heading'}, - language.$(...titleParts, titleOptions)), - - html.tag('blockquote', {class: 'commentary-entry-body'}, - relations.bodyContent.slot('mode', 'multiline')), - ]); - }, -}; -- cgit 1.3.0-6-gf8a5 From 233e46c8d9099b4f6d2174e12f2b287478c54f40 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:25:08 -0400 Subject: content: generateAlbumCommentaryPage: use updated commentary entries --- .../dependencies/generateAlbumCommentaryPage.js | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index e2415516..5b00acf7 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -7,12 +7,12 @@ export default { 'generateAlbumSidebarTrackSection', 'generateAlbumStyleRules', 'generateColorStyleVariables', + 'generateCommentaryEntry', 'generateContentHeading', 'generateTrackCoverArtwork', 'generatePageLayout', 'linkAlbum', 'linkTrack', - 'transformContent', ], extraDependencies: ['html', 'language'], @@ -38,8 +38,9 @@ export default { relation('generateAlbumCoverArtwork', album); } - relations.albumCommentaryContent = - relation('transformContent', album.commentary); + relations.albumCommentaryEntries = + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)); } const tracksWithCommentary = @@ -61,9 +62,11 @@ export default { ? relation('generateTrackCoverArtwork', track) : null)); - relations.trackCommentaryContent = + relations.trackCommentaryEntries = tracksWithCommentary - .map(track => relation('transformContent', track.commentary)); + .map(track => + track.commentary + .map(entry => relation('generateCommentaryEntry', entry))); relations.trackCommentaryColorVariables = tracksWithCommentary @@ -163,10 +166,10 @@ export default { link: relations.trackCommentaryLinks, directory: data.trackCommentaryDirectories, cover: relations.trackCommentaryCovers, - content: relations.trackCommentaryContent, + entries: relations.trackCommentaryEntries, colorVariables: relations.trackCommentaryColorVariables, color: data.trackCommentaryColors, - }).map(({heading, link, directory, cover, content, colorVariables, color}) => [ + }).map(({heading, link, directory, cover, entries, colorVariables, color}) => [ heading.slots({ tag: 'h3', id: directory, @@ -175,11 +178,7 @@ export default { cover?.slots({mode: 'commentary'}), - html.tag('blockquote', - (color - ? {style: colorVariables.slot('color', color).content} - : {}), - content), + entries, ]), ], -- cgit 1.3.0-6-gf8a5 From 7a5b4465069d594a90c8e783e646a3248408ddaf Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:25:37 -0400 Subject: content: generateCommentaryEntry: add color slot --- .../dependencies/generateAlbumCommentaryPage.js | 13 ++--------- .../dependencies/generateCommentaryEntry.js | 27 ++++++++++++++++++---- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 5b00acf7..001003ae 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -6,7 +6,6 @@ export default { 'generateAlbumNavAccent', 'generateAlbumSidebarTrackSection', 'generateAlbumStyleRules', - 'generateColorStyleVariables', 'generateCommentaryEntry', 'generateContentHeading', 'generateTrackCoverArtwork', @@ -68,13 +67,6 @@ export default { track.commentary .map(entry => relation('generateCommentaryEntry', entry))); - relations.trackCommentaryColorVariables = - tracksWithCommentary - .map(track => - (track.color === album.color - ? null - : relation('generateColorStyleVariables'))); - relations.sidebarAlbumLink = relation('linkAlbum', album); @@ -167,9 +159,8 @@ export default { directory: data.trackCommentaryDirectories, cover: relations.trackCommentaryCovers, entries: relations.trackCommentaryEntries, - colorVariables: relations.trackCommentaryColorVariables, color: data.trackCommentaryColors, - }).map(({heading, link, directory, cover, entries, colorVariables, color}) => [ + }).map(({heading, link, directory, cover, entries, color}) => [ heading.slots({ tag: 'h3', id: directory, @@ -178,7 +169,7 @@ export default { cover?.slots({mode: 'commentary'}), - entries, + entries.map(entry => entry.slot('color', color)), ]), ], diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index 22e8fd1e..72e30657 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -1,5 +1,10 @@ export default { - contentDependencies: ['linkArtist', 'transformContent'], + contentDependencies: [ + 'generateColorStyleVariables', + 'linkArtist', + 'transformContent', + ], + extraDependencies: ['html', 'language'], relations: (relation, entry) => ({ @@ -22,13 +27,20 @@ export default { (entry.body ? relation('transformContent', entry.body) : null), + + colorVariables: + relation('generateColorStyleVariables'), }), data: (entry) => ({ date: entry.date, }), - generate(data, relations, {html, language}) { + slots: { + color: {validate: v => v.isColor}, + }, + + generate(data, relations, slots, {html, language}) { const artistsSpan = html.tag('span', {class: 'commentary-entry-artists'}, (relations.artistsContent @@ -66,11 +78,18 @@ export default { titleOptions.accent = accent; } + const style = + (slots.color + ? relations.colorVariables + .slot('color', slots.color) + .content + : null); + return html.tags([ - html.tag('p', {class: 'commentary-entry-heading'}, + html.tag('p', {class: 'commentary-entry-heading', style}, language.$(...titleParts, titleOptions)), - html.tag('blockquote', {class: 'commentary-entry-body'}, + html.tag('blockquote', {class: 'commentary-entry-body', style}, relations.bodyContent.slot('mode', 'multiline')), ]); }, -- cgit 1.3.0-6-gf8a5 From c9c8ccfbeb6a013131dee429da0f12192364a8d0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:27:15 -0400 Subject: content: generateCommentaryEntry: add missing noArtists string --- src/content/dependencies/generateCommentaryEntry.js | 2 +- src/strings-default.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index 72e30657..b265ed41 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -47,7 +47,7 @@ export default { ? relations.artistsContent.slot('mode', 'inline') : relations.artistLink ? relations.artistLink - : language.$('misc.artistCommentary.noArtist'))); + : language.$('misc.artistCommentary.entry.title.noArtists'))); const accentParts = ['misc.artistCommentary.entry.title.accent']; const accentOptions = {}; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index f83412e9..5a3d172f 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -353,6 +353,7 @@ misc: entry: title: _: "{ARTISTS}:" + noArtists: "Unknown artist" withAccent: "{ARTISTS}: {ACCENT}" accent: withAnnotation: "({ANNOTATION})" -- cgit 1.3.0-6-gf8a5 From 2834bc41d1f141402f14a64f0c513997f43c4dc4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:30:26 -0400 Subject: content: linkExternal: add tab: 'separate' slot --- src/content/dependencies/linkExternal.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 5de612e2..4a0959c0 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -24,6 +24,11 @@ export default { validate: v => v.is('generic', 'album', 'flash'), default: 'generic', }, + + tab: { + validate: v => v.is('default', 'separate'), + default: 'default', + }, }, generate(data, slots, {html, language}) { @@ -53,6 +58,10 @@ export default { { href: data.url, class: 'nowrap', + target: + (slots.tab === 'separate' + ? '_blank' + : null), }, // truly unhinged indentation here -- cgit 1.3.0-6-gf8a5 From 0936a1d377f45dbfcfe28ff0fd735b6faeb66cbf Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:33:02 -0400 Subject: content: image: support specifying color on image --- src/content/dependencies/image.js | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index 8aa9753b..3c78abe3 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -14,6 +14,12 @@ export default { 'to', ], + contentDependencies: ['generateColorStyleVariables'], + + relations: (relation) => ({ + colorVariables: relation('generateColorStyleVariables'), + }), + data(artTags) { const data = {}; @@ -43,6 +49,10 @@ export default { default: false, }, + color: { + validate: v => v.isColor, + }, + reveal: {type: 'boolean', default: true}, lazy: {type: 'boolean', default: false}, square: {type: 'boolean', default: false}, @@ -56,7 +66,7 @@ export default { missingSourceContent: {type: 'html'}, }, - generate(data, slots, { + generate(data, relations, slots, { checkIfImagePathHasCachedThumbnails, getDimensionsOfImagePath, getSizeOfImagePath, @@ -110,6 +120,12 @@ export default { !isMissingImageFile && !empty(data.contentWarnings); + const colorStyle = + slots.color && + relations.colorVariables + .slot('color', slots.color) + .content; + const willSquare = slots.square; const idOnImg = willLink ? null : slots.id; @@ -118,6 +134,9 @@ export default { const classOnImg = willLink ? null : slots.class; const classOnLink = willLink ? slots.class : null; + const styleOnContainer = willLink ? null : colorStyle; + const styleOnLink = willLink ? colorStyle : null; + if (!originalSrc || isMissingImageFile) { return prepare( html.tag('div', {class: 'image-text-area'}, @@ -191,7 +210,7 @@ export default { imgAttributes['data-no-image-preview'] = true; } - // These attributes are only relevant when a thumbnail are available *and* + // These attributes are only relevant when a thumbnail is available *and* // being used. if (hasThumbnails && slots.thumb) { if (fileSize) { @@ -238,9 +257,13 @@ export default { let wrapped = content; wrapped = - html.tag('div', {class: ['image-container', !originalSrc && 'placeholder-image']}, + html.tag('div', { + class: ['image-container', !originalSrc && 'placeholder-image'], + style: styleOnContainer, + }, [ html.tag('div', {class: 'image-inner-area'}, - wrapped)); + wrapped), + ]); if (willReveal) { wrapped = @@ -270,6 +293,7 @@ export default { wrapped = html.tag('a', { id: idOnLink, + class: [ 'box', 'image-link', @@ -277,6 +301,8 @@ export default { classOnLink, ], + style: styleOnLink, + href: (typeof slots.link === 'string' ? slots.link -- cgit 1.3.0-6-gf8a5 From a328c845c0e8c89a9ca39a33c18016a1a88e023b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:33:37 -0400 Subject: content: generateAlbumCommentaryPage: track listening links --- .../dependencies/generateAlbumCommentaryPage.js | 32 ++++++++++++++++++++-- src/strings-default.yaml | 4 ++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 001003ae..3e97c379 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -11,6 +11,7 @@ export default { 'generateTrackCoverArtwork', 'generatePageLayout', 'linkAlbum', + 'linkExternal', 'linkTrack', ], @@ -54,6 +55,11 @@ export default { tracksWithCommentary .map(track => relation('linkTrack', track)); + relations.trackCommentaryListeningLinks = + tracksWithCommentary + .map(track => + track.urls.map(url => relation('linkExternal', url))); + relations.trackCommentaryCovers = tracksWithCommentary .map(track => @@ -156,15 +162,37 @@ export default { stitchArrays({ heading: relations.trackCommentaryHeadings, link: relations.trackCommentaryLinks, + listeningLinks: relations.trackCommentaryListeningLinks, directory: data.trackCommentaryDirectories, cover: relations.trackCommentaryCovers, entries: relations.trackCommentaryEntries, color: data.trackCommentaryColors, - }).map(({heading, link, directory, cover, entries, color}) => [ + }).map(({ + heading, + link, + listeningLinks, + directory, + cover, + entries, + color, + }) => [ heading.slots({ tag: 'h3', id: directory, - title: link, + color, + + title: + language.$('albumCommentaryPage.entry.title.trackCommentary', { + track: link, + }), + + accent: + language.$('albumCommentaryPage.entry.title.trackCommentary.accent', { + listeningLinks: + language.formatUnitList( + listeningLinks.map(link => + link.slot('tab', 'separate'))), + }), }), cover?.slots({mode: 'commentary'}), diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 5a3d172f..30261e80 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -754,7 +754,9 @@ albumCommentaryPage: entry: title: albumCommentary: "Album commentary" - trackCommentary: "{TRACK}" + trackCommentary: + _: "{TRACK}" + accent: "({LISTENING_LINKS})" # # artistInfoPage: -- cgit 1.3.0-6-gf8a5 From 8e0a343d1ff83aa67083f5a67a9bd199f3f6ad9c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:34:27 -0400 Subject: content: generate{Album,Track}CoverArtwork: provide thing color --- .../dependencies/generateAlbumCoverArtwork.js | 20 ++++++++++---- src/content/dependencies/generateCoverArtwork.js | 7 +++++ .../dependencies/generateTrackCoverArtwork.js | 32 ++++++++++++++-------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js index cbec930e..ce8cde21 100644 --- a/src/content/dependencies/generateAlbumCoverArtwork.js +++ b/src/content/dependencies/generateAlbumCoverArtwork.js @@ -1,12 +1,22 @@ export default { contentDependencies: ['generateCoverArtwork'], - relations: (relation, album) => - ({coverArtwork: relation('generateCoverArtwork', album.artTags)}), + relations: (relation, album) => ({ + coverArtwork: + relation('generateCoverArtwork', album.artTags), + }), - data: (album) => - ({path: ['media.albumCover', album.directory, album.coverArtFileExtension]}), + data: (album) => ({ + path: + ['media.albumCover', album.directory, album.coverArtFileExtension], + + color: + album.color, + }), generate: (data, relations) => - relations.coverArtwork.slot('path', data.path), + relations.coverArtwork.slots({ + path: data.path, + color: data.color, + }), }; diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index aeba97de..e43963fb 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -31,6 +31,10 @@ export default { type: 'string', }, + color: { + validate: v => v.isColor, + }, + mode: { validate: v => v.is('primary', 'thumbnail', 'commentary'), default: 'primary', @@ -45,6 +49,7 @@ export default { .slots({ path: slots.path, alt: slots.alt, + color: slots.color, thumb: 'medium', id: 'cover-art', reveal: true, @@ -67,6 +72,7 @@ export default { .slots({ path: slots.path, alt: slots.alt, + color: slots.color, thumb: 'small', reveal: false, link: false, @@ -78,6 +84,7 @@ export default { .slots({ path: slots.path, alt: slots.alt, + color: slots.color, thumb: 'medium', class: 'commentary-art', reveal: true, diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js index ec0488e2..6c056c9a 100644 --- a/src/content/dependencies/generateTrackCoverArtwork.js +++ b/src/content/dependencies/generateTrackCoverArtwork.js @@ -1,20 +1,28 @@ export default { contentDependencies: ['generateCoverArtwork'], - relations: (relation, track) => - ({coverArtwork: - relation('generateCoverArtwork', - (track.hasUniqueCoverArt - ? track.artTags - : track.album.artTags))}), - - data: (track) => - ({path: + relations: (relation, track) => ({ + coverArtwork: + relation('generateCoverArtwork', (track.hasUniqueCoverArt - ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] - : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension])}), + ? track.artTags + : track.album.artTags)), + }), + + data: (track) => ({ + path: + (track.hasUniqueCoverArt + ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] + : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]), + + color: + track.color, + }), generate: (data, relations) => - relations.coverArtwork.slot('path', data.path), + relations.coverArtwork.slots({ + path: data.path, + color: data.color, + }), }; -- cgit 1.3.0-6-gf8a5 From 173ea7144c42e799dc08e1ba4e2a09410223840e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:47:22 -0400 Subject: content: linkTrackDynamically --- src/content/dependencies/linkTrackDynamically.js | 34 ++++++++++++++++++++++++ src/content/dependencies/transformContent.js | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/content/dependencies/linkTrackDynamically.js diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js new file mode 100644 index 00000000..242cd4cb --- /dev/null +++ b/src/content/dependencies/linkTrackDynamically.js @@ -0,0 +1,34 @@ +export default { + contentDependencies: ['linkTrack'], + extraDependencies: ['pagePath'], + + relations: (relation, track) => ({ + infoLink: relation('linkTrack', track), + }), + + data: (track) => ({ + trackDirectory: + track.directory, + + albumDirectory: + track.album.directory, + + trackHasCommentary: + !!track.commentary, + }), + + generate(data, relations, {pagePath}) { + if ( + pagePath[0] === 'albumCommentary' && + pagePath[1] === data.albumDirectory && + data.trackHasCommentary + ) { + relations.infoLink.setSlots({ + anchor: true, + hash: data.trackDirectory, + }); + } + + return relations.infoLink; + }, +}; diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index 3c2c3521..a60206c9 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -130,7 +130,7 @@ const linkThingRelationMap = { newsEntry: 'linkNewsEntry', staticPage: 'linkStaticPage', tag: 'linkArtTag', - track: 'linkTrack', + track: 'linkTrackDynamically', }; const linkValueRelationMap = { -- cgit 1.3.0-6-gf8a5 From 5c46ac058d7a09b6d129d612e6ea9d72df5d77cd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 21:17:02 -0400 Subject: content, css: generateAlbumCommentaryPage: album listening links, etc --- .../dependencies/generateAlbumCommentaryPage.js | 48 +++++++++++++++++----- src/static/site5.css | 3 +- src/strings-default.yaml | 7 +++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 3e97c379..442d72a7 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -1,4 +1,4 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ @@ -33,6 +33,15 @@ export default { relation('generateAlbumNavAccent', album, null); if (album.commentary) { + relations.albumCommentaryHeading = + relation('generateContentHeading'); + + relations.albumCommentaryLink = + relation('linkAlbum', album); + + relations.albumCommentaryListeningLinks = + album.urls.map(url => relation('linkExternal', url)); + if (album.hasCoverArt) { relations.albumCommentaryCover = relation('generateAlbumCoverArtwork', album); @@ -148,9 +157,27 @@ export default { })), relations.albumCommentaryContent && [ - html.tag('h3', - {class: ['content-heading']}, - language.$('albumCommentaryPage.entry.title.albumCommentary')), + relations.albumCommentaryHeading.slots({ + tag: 'h3', + color: data.color, + + title: + language.$('albumCommentaryPage.entry.title.albumCommentary', { + album: relations.albumCommentaryLink, + }), + + accent: + !empty(relations.albumCommentaryListeningLinks) && + language.$('albumCommentaryPage.entry.title.albumCommentary.accent', { + listeningLinks: + language.formatUnitList( + relations.albumCommentaryListeningLinks + .map(link => link.slots({ + mode: 'album', + tab: 'separate', + }))), + }), + }), relations.albumCommentaryCover ?.slots({mode: 'commentary'}), @@ -187,12 +214,13 @@ export default { }), accent: - language.$('albumCommentaryPage.entry.title.trackCommentary.accent', { - listeningLinks: - language.formatUnitList( - listeningLinks.map(link => - link.slot('tab', 'separate'))), - }), + !empty(listeningLinks) && + language.$('albumCommentaryPage.entry.title.trackCommentary.accent', { + listeningLinks: + language.formatUnitList( + listeningLinks.map(link => + link.slot('tab', 'separate'))), + }), }), cover?.slots({mode: 'commentary'}), diff --git a/src/static/site5.css b/src/static/site5.css index ba44ec37..3aa11f92 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1292,7 +1292,8 @@ html[data-url-key="localized.home"] .carousel-container { .content-heading .content-heading-accent { font-weight: normal; - font-size: 1rem; + font-style: oblique; + font-size: 0.9rem; margin-left: 0.25em; } diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 30261e80..46a592df 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -753,10 +753,13 @@ albumCommentaryPage: entry: title: - albumCommentary: "Album commentary" + albumCommentary: + _: "{ALBUM}" + accent: "Listen on: {LISTENING_LINKS}" + trackCommentary: _: "{TRACK}" - accent: "({LISTENING_LINKS})" + accent: "Listen on: {LISTENING_LINKS}" # # artistInfoPage: -- cgit 1.3.0-6-gf8a5 From aec1daecb6f2ae60697b836ec80775edc171ebba Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 17:26:37 -0400 Subject: css: apply custom content-heading-accent style on specific page --- src/static/site5.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/static/site5.css b/src/static/site5.css index 3aa11f92..ccae29a0 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -803,6 +803,13 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } +html[data-url-key="localized.albumCommentary"] .content-heading .content-heading-accent { + font-weight: normal; + font-style: oblique; + font-size: 0.9rem; + margin-left: 0.25em; +} + html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { @@ -1290,13 +1297,6 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } -.content-heading .content-heading-accent { - font-weight: normal; - font-style: oblique; - font-size: 0.9rem; - margin-left: 0.25em; -} - h3.content-heading { clear: both; } -- cgit 1.3.0-6-gf8a5 From 69385cc2ca5d4a7691d6cb3726de5741de153a7c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 17:36:46 -0400 Subject: content, client: generateContentHeading: expose main title directly --- src/content/dependencies/generateContentHeading.js | 4 +++- src/static/client3.js | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js index 56f68cb3..0343409c 100644 --- a/src/content/dependencies/generateContentHeading.js +++ b/src/content/dependencies/generateContentHeading.js @@ -29,7 +29,9 @@ export default { .slot('color', slots.color) .content, }, [ - slots.title, + html.tag('span', + {[html.onlyIfContent]: true, class: 'content-heading-main-title'}, + slots.title), html.tag('span', {[html.onlyIfContent]: true, class: 'content-heading-accent'}, diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..6af548d9 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -899,11 +899,12 @@ function updateStickySubheadingContent(index) { child.remove(); } - for (const child of closestHeading.childNodes) { - if (child.classList?.contains('content-heading-accent')) { - continue; - } + const textContainer = + closestHeading.querySelector('.content-heading-main-title') + // Just for compatibility with older builds of the site. + ?? closestHeading; + for (const child of textContainer.childNodes) { if (child.tagName === 'A') { for (const grandchild of child.childNodes) { stickySubheading.appendChild(grandchild.cloneNode(true)); -- cgit 1.3.0-6-gf8a5 From d0716804ca0b547ca79f819c94f413a542b7e172 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 17:37:22 -0400 Subject: css: handle line wrapping in commentary heading accents better --- src/static/site5.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/static/site5.css b/src/static/site5.css index ccae29a0..c47252f4 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -803,11 +803,15 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } -html[data-url-key="localized.albumCommentary"] .content-heading .content-heading-accent { +html[data-url-key="localized.albumCommentary"] .content-heading-main-title { + margin-right: 0.25em; +} + +html[data-url-key="localized.albumCommentary"] .content-heading-accent { font-weight: normal; font-style: oblique; font-size: 0.9rem; - margin-left: 0.25em; + display: inline-block; } html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -- cgit 1.3.0-6-gf8a5 From 4eb923d78f3f735b8697697033543a27bad623d7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:36:30 -0400 Subject: css: add light shadow to commentary cover art --- src/static/site5.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/static/site5.css b/src/static/site5.css index c47252f4..9ff3954e 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -553,6 +553,7 @@ p .current { width: 30%; max-width: 250px; margin: 15px 0 10px 20px; + box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35); } .js-hide, -- cgit 1.3.0-6-gf8a5 From 1beb0d4712962ed943a18b9aeb81b231ad26691b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:45:14 -0400 Subject: css: tweak responsive long-content padding values & dynamics --- src/static/site5.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/static/site5.css b/src/static/site5.css index 9ff3954e..bfda0ba3 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -704,7 +704,7 @@ p code { } main.long-content { - --long-content-padding-ratio: 0.12; + --long-content-padding-ratio: 0.10; } main.long-content .main-content-container, @@ -1683,6 +1683,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content */ @media (min-width: 600px) and (max-width: 899.98px) { + /* Medim layout is mainly defined (to the user) by hiding the sidebar, so + * don't apply the similar layout change of widening the long-content area + * if this page doesn't have a sidebar to hide in the first place. + */ + #page-container:not(.has-zero-sidebars) main.long-content { + --long-content-padding-ratio: 0.06; + } } /* Layout - Wide or Medium */ @@ -1772,6 +1779,10 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content max-width: unset; } + main.long-content { + --long-content-padding-ratio: 0.02; + } + /* Show sticky heading above cover art */ .content-sticky-heading-container { @@ -1787,8 +1798,4 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content #header > div:not(:first-child) { margin-top: 0.5em; } - - main.long-content { - --long-content-padding-ratio: 0.04; - } } -- cgit 1.3.0-6-gf8a5 From fbcfb88ea407ab76d278c28f1f0711502ab2fbf9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 17:38:17 -0400 Subject: content, css: quick visual fixes for commentary entries --- src/content/dependencies/transformContent.js | 4 +++- src/static/site5.css | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index 3c2c3521..dab89630 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -450,7 +450,9 @@ export default { // In inline mode, no further processing is needed! if (slots.mode === 'inline') { - return html.tags(contentFromNodes.map(node => node.data)); + return html.tags( + contentFromNodes.map(node => node.data), + {[html.joinChildren]: ''}); } // Multiline mode has a secondary processing stage where it's passed... diff --git a/src/static/site5.css b/src/static/site5.css index ba44ec37..dd16cbf9 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -540,6 +540,7 @@ p .current { .commentary-entry-heading { margin-left: 15px; padding-left: 5px; + max-width: 625px; padding-bottom: 0.2em; border-bottom: 1px dotted var(--primary-color); } @@ -1700,7 +1701,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content float: right; width: 40%; max-width: 400px; - margin: -60px 0 10px 10px; + margin: -60px 0 10px 20px; position: relative; z-index: 2; -- cgit 1.3.0-6-gf8a5 From a34b8d027866fbe858a4d2ff3543bc84c9d5983a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 17 Nov 2023 06:53:34 -0400 Subject: data, yaml, content: support multiple artists per commentary entry --- .../dependencies/generateCommentaryEntry.js | 13 ++++--- .../wiki-data/withParsedCommentaryEntries.js | 41 +++++++++++++++++----- .../wiki-properties/commentatorArtists.js | 23 +++++++----- src/data/yaml.js | 24 ++++++++++--- src/util/wiki-data.js | 2 +- 5 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index b265ed41..0b2b2558 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -1,3 +1,5 @@ +import {empty} from '#sugar'; + export default { contentDependencies: [ 'generateColorStyleVariables', @@ -8,9 +10,10 @@ export default { extraDependencies: ['html', 'language'], relations: (relation, entry) => ({ - artistLink: - (entry.artist && !entry.artistDisplayText - ? relation('linkArtist', entry.artist) + artistLinks: + (!empty(entry.artists) && !entry.artistDisplayText + ? entry.artists + .map(artist => relation('linkArtist', artist)) : null), artistsContent: @@ -45,8 +48,8 @@ export default { html.tag('span', {class: 'commentary-entry-artists'}, (relations.artistsContent ? relations.artistsContent.slot('mode', 'inline') - : relations.artistLink - ? relations.artistLink + : relations.artistLinks + ? language.formatConjunctionList(relations.artistLinks) : language.$('misc.artistCommentary.entry.title.noArtists'))); const accentParts = ['misc.artistCommentary.entry.title.accent']; diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js index 7b1c9484..25c07a37 100644 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -4,7 +4,12 @@ import {stitchArrays} from '#sugar'; import {isCommentary} from '#validators'; import {commentaryRegex} from '#wiki-data'; -import {fillMissingListItems, withPropertiesFromList} from '#composite/data'; +import { + fillMissingListItems, + withFlattenedList, + withPropertiesFromList, + withUnflattenedList, +} from '#composite/data'; import withResolvedReferenceList from './withResolvedReferenceList.js'; @@ -86,23 +91,43 @@ export default templateCompositeFrom({ list: '#rawMatches.groups', prefix: input.value('#entries'), properties: input.value([ - 'artistReference', + 'artistReferences', 'artistDisplayText', 'annotation', 'date', ]), }), - // The artistReference group will always have a value, since it's required + // The artistReferences group will always have a value, since it's required // for the line to match in the first place. + { + dependencies: ['#entries.artistReferences'], + compute: (continuation, { + ['#entries.artistReferences']: artistReferenceTexts, + }) => continuation({ + ['#entries.artistReferences']: + artistReferenceTexts + .map(text => text.split(',').map(ref => ref.trim())), + }), + }, + + withFlattenedList({ + list: '#entries.artistReferences', + }), + withResolvedReferenceList({ - list: '#entries.artistReference', + list: '#flattenedList', data: 'artistData', find: input.value(find.artist), notFoundMode: input.value('null'), + }), + + withUnflattenedList({ + list: '#resolvedReferenceList', + filter: input.value(false), }).outputs({ - '#resolvedReferenceList': '#entries.artist', + '#unflattenedList': '#entries.artists', }), fillMissingListItems({ @@ -127,7 +152,7 @@ export default templateCompositeFrom({ { dependencies: [ - '#entries.artist', + '#entries.artists', '#entries.artistDisplayText', '#entries.annotation', '#entries.date', @@ -135,7 +160,7 @@ export default templateCompositeFrom({ ], compute: (continuation, { - ['#entries.artist']: artist, + ['#entries.artists']: artists, ['#entries.artistDisplayText']: artistDisplayText, ['#entries.annotation']: annotation, ['#entries.date']: date, @@ -143,7 +168,7 @@ export default templateCompositeFrom({ }) => continuation({ ['#parsedCommentaryEntries']: stitchArrays({ - artist, + artists, artistDisplayText, annotation, date, diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index 8720e66d..65ab1466 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -4,8 +4,9 @@ import {input, templateCompositeFrom} from '#composite'; import {unique} from '#sugar'; -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromList} from '#composite/data'; +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withFlattenedList, withPropertyFromList} from '#composite/data'; import {withParsedCommentaryEntries} from '#composite/wiki-data'; export default templateCompositeFrom({ @@ -26,15 +27,19 @@ export default templateCompositeFrom({ withPropertyFromList({ list: '#parsedCommentaryEntries', - property: input.value('artist'), + property: input.value('artists'), }).outputs({ - '#parsedCommentaryEntries.artist': '#artists', + '#parsedCommentaryEntries.artists': '#artistLists', }), - { - dependencies: ['#artists'], - compute: ({'#artists': artists}) => - unique(artists.filter(artist => artist !== null)), - }, + withFlattenedList({ + list: '#artistLists', + }).outputs({ + '#flattenedList': '#artists', + }), + + exposeDependency({ + dependency: '#artists', + }), ], }); diff --git a/src/data/yaml.js b/src/data/yaml.js index 843e70b3..0734d539 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -21,6 +21,7 @@ import { decorateErrorWithIndex, decorateErrorWithAnnotation, empty, + filterAggregate, filterProperties, openAggregate, showAggregate, @@ -1686,8 +1687,10 @@ export function filterReferenceErrors(wikiData) { if (value) { value = Array.from(value.matchAll(commentaryRegex)) - .map(({groups}) => groups.artistReference); + .map(({groups}) => groups.artistReferences) + .map(text => text.split(',').map(text => text.trim())); } + writeProperty = false; break; } @@ -1804,11 +1807,22 @@ export function filterReferenceErrors(wikiData) { let newPropertyValue = value; - if (Array.isArray(value)) { + if (findFnKey === '_commentary') { + // Commentary doesn't write a property value, so no need to set. + filter( + value, {message: errorMessage}, + decorateErrorWithIndex(refs => + (refs.length === 1 + ? suppress(findFn)(refs[0]) + : filterAggregate( + refs, {message: `Errors in entry's artist references`}, + decorateErrorWithIndex(suppress(findFn))) + .aggregate + .close()))); + } else if (Array.isArray(value)) { newPropertyValue = filter( - value, - decorateErrorWithIndex(suppress(findFn)), - {message: errorMessage}); + value, {message: errorMessage}, + decorateErrorWithIndex(suppress(findFn))); } else { nest({message: errorMessage}, suppress(({call}) => { diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 75a141d3..4c7ec043 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -652,7 +652,7 @@ export function sortFlashesChronologically(data, { // out of the original string based on the indices matched using this. // export const commentaryRegex = - /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; + /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; export function filterAlbumsByCommentary(albums) { return albums -- cgit 1.3.0-6-gf8a5 From 7da15227b8623a2fcef28c4f7988a2f89e5ab8b3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:22:08 -0400 Subject: content: transformContent: use marked for inline + own instances There's obviously lots of room to do more here, but this is a simple way to get typical inline contents passing through marked. --- src/content/dependencies/transformContent.js | 57 +++++++++++++++++++--------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index dab89630..7b2d0573 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -1,7 +1,7 @@ import {bindFind} from '#find'; import {parseInput} from '#replacer'; -import {marked} from 'marked'; +import {Marked} from 'marked'; export const replacerSpec = { album: { @@ -147,6 +147,29 @@ const linkIndexRelationMap = { newsIndex: 'linkNewsIndex', }; +const commonMarkedOptions = { + headerIds: false, + mangle: false, +}; + +const multilineMarked = new Marked({ + ...commonMarkedOptions, +}); + +const inlineMarked = new Marked({ + ...commonMarkedOptions, + + renderer: { + paragraph(text) { + return text; + }, + }, +}); + +const lyricsMarked = new Marked({ + ...commonMarkedOptions, +}); + function getPlaceholder(node, content) { return {type: 'text', data: content.slice(node.i, node.iEnd)}; } @@ -447,21 +470,9 @@ export default { return link.data; } - // In inline mode, no further processing is needed! - - if (slots.mode === 'inline') { - return html.tags( - contentFromNodes.map(node => node.data), - {[html.joinChildren]: ''}); - } - - // Multiline mode has a secondary processing stage where it's passed... - // through marked! Rolling your own Markdown only gets you so far :D - - const markedOptions = { - headerIds: false, - mangle: false, - }; + // Content always goes through marked (i.e. parsing as Markdown). + // This does require some attention to detail, mostly to do with line + // breaks (in multiline mode) and extracting/re-inserting non-text nodes. // The content of non-text nodes can end up getting mangled by marked. // To avoid this, we replace them with mundane placeholders, then @@ -536,6 +547,16 @@ export default { return html.tags(tags, {[html.joinChildren]: ''}); }; + if (slots.mode === 'inline') { + const markedInput = + extractNonTextNodes(); + + const markedOutput = + inlineMarked.parse(markedInput); + + return reinsertNonTextNodes(markedOutput); + } + // This is separated into its own function just since we're gonna reuse // it in a minute if everything goes to heck in lyrics mode. const transformMultiline = () => { @@ -552,7 +573,7 @@ export default { .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); const markedOutput = - marked.parse(markedInput, markedOptions); + multilineMarked.parse(markedInput); return reinsertNonTextNodes(markedOutput); } @@ -602,7 +623,7 @@ export default { }); const markedOutput = - marked.parse(markedInput, markedOptions); + lyricsMarked.parse(markedInput); return reinsertNonTextNodes(markedOutput); } -- cgit 1.3.0-6-gf8a5 From 42a8275ff59d13757b0d6a5a19588d4f8393ab6f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:19:49 -0400 Subject: update min node to 20, canonize in package.json --- README.md | 2 +- package.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a7fc5824..2c99e3d8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagini Install dependencies: -- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 16.x LTS should also work +- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work - [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php) Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git: diff --git a/package.json b/package.json index 34b4ebf0..6f3323f2 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,9 @@ "#wiki-data": "./src/util/wiki-data.js", "#yaml": "./src/data/yaml.js" }, + "engines": { + "node": ">= 20.9.0" + }, "dependencies": { "chroma-js": "^2.4.2", "command-exists": "^1.2.9", -- cgit 1.3.0-6-gf8a5 From f60c890fc4578879707bfdbb4e8219f78bd67b8b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:20:58 -0400 Subject: update marked from 5.0.2 to 10.0.0 --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6433ea16..9e8f4dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "he": "^1.2.0", "image-size": "^1.0.2", "js-yaml": "^4.1.0", - "marked": "^5.0.2", + "marked": "^10.0.0", "striptags": "^4.0.0-alpha.4", "word-wrap": "^1.2.3" }, @@ -3303,9 +3303,9 @@ } }, "node_modules/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz", + "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==", "bin": { "marked": "bin/marked.js" }, @@ -8096,9 +8096,9 @@ } }, "marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==" + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz", + "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==" }, "mimic-fn": { "version": "2.1.0", diff --git a/package.json b/package.json index 6f3323f2..719a5b89 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "he": "^1.2.0", "image-size": "^1.0.2", "js-yaml": "^4.1.0", - "marked": "^5.0.2", + "marked": "^10.0.0", "striptags": "^4.0.0-alpha.4", "word-wrap": "^1.2.3" }, -- cgit 1.3.0-6-gf8a5 From 4b7da4c1f8c359e5c82c4cc5e0cfb78f8204850f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:21:31 -0400 Subject: data: parse commentary heading contents to end of line --- src/util/wiki-data.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 4c7ec043..8a6d4345 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -652,7 +652,7 @@ export function sortFlashesChronologically(data, { // out of the original string based on the indices matched using this. // export const commentaryRegex = - /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=[,)]))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?/gm; + /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?$/gm; export function filterAlbumsByCommentary(albums) { return albums -- cgit 1.3.0-6-gf8a5 From 522c982bf0b5a0bd39512eb56a9d0d8d8feea44e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:30:27 -0400 Subject: data: looser commentary date parsing + clearer regex explanation --- src/util/wiki-data.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 8a6d4345..09b3623e 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -641,8 +641,10 @@ export function sortFlashesChronologically(data, { // // * "25 December 2019" - one or two number digits, followed by any text, // followed by four number digits -// * "12/25/2019" - one or two number digits, a slash, one or two number -// digits, a slash, and two to four number digits +// * "December 25, 2019" - one all-letters word, a space, one or two number +// digits, a comma, and four number digits +// * "12/25/2019" etc - three sets of one to four number digits, separated +// by slashes (only valid formats are MM/DD/YYYY and YYYY/MM/DD) // // Capturing group "artistReference" is all the characters between and // (apart from the pipe and "artistDisplayText" text, if present), and is either @@ -652,7 +654,7 @@ export function sortFlashesChronologically(data, { // out of the original string based on the indices matched using this. // export const commentaryRegex = - /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}))?\))?$/gm; + /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}\/[0-9]{1,4}\/[0-9]{1,4}))?\))?$/gm; export function filterAlbumsByCommentary(albums) { return albums -- cgit 1.3.0-6-gf8a5 From e874f7e4d0df5fed25d4c359c8cb403e67061e59 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:33:38 -0400 Subject: data: support dash-style short dates in commentary dates --- src/util/wiki-data.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 09b3623e..5e3182a9 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -644,7 +644,7 @@ export function sortFlashesChronologically(data, { // * "December 25, 2019" - one all-letters word, a space, one or two number // digits, a comma, and four number digits // * "12/25/2019" etc - three sets of one to four number digits, separated -// by slashes (only valid formats are MM/DD/YYYY and YYYY/MM/DD) +// by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD) // // Capturing group "artistReference" is all the characters between and // (apart from the pipe and "artistDisplayText" text, if present), and is either @@ -654,7 +654,7 @@ export function sortFlashesChronologically(data, { // out of the original string based on the indices matched using this. // export const commentaryRegex = - /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}\/[0-9]{1,4}\/[0-9]{1,4}))?\))?$/gm; + /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?$/gm; export function filterAlbumsByCommentary(albums) { return albums -- cgit 1.3.0-6-gf8a5 From 6fb9bbcb4d4806a4d31eb3d387dd229a598bc15d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:38:33 -0400 Subject: test: withParsedCommentaryEntries: update + test multiple artists --- .../data/composite/wiki-data/withParsedCommentaryEntries.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js index 50570de6..928a9c98 100644 --- a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -49,7 +49,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => { `Very cool.\n`, }), [ { - artist: artist1, + artists: [artist1], artistDisplayText: null, annotation: null, date: null, @@ -67,32 +67,32 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => { `Second commentary entry. Yes. So cool.\n` + `Mystery Artist: (pingas, August 25, 2023)\n` + `Oh no.. Oh dear...\n` + - `Mobius Trip:\n` + + `Mobius Trip, Hadron Kaleido:\n` + `And back around we go.`, }), [ { - artist: artist1, + artists: [artist1], artistDisplayText: `Moo-bius Trip`, annotation: `music, art`, date: new Date('12 January 2015'), body: `First commentary entry.\nVery cool.`, }, { - artist: artist2, + artists: [artist2], artistDisplayText: `[[artist:hadron-kaleido|The Ol' Hadron]]`, annotation: `moral support`, date: new Date('4 April 2022'), body: `Second commentary entry. Yes. So cool.`, }, { - artist: null, + artists: [], artistDisplayText: null, annotation: `pingas`, date: new Date('25 August 2023'), body: `Oh no.. Oh dear...`, }, { - artist: artist1, + artists: [artist1, artist2], artistDisplayText: null, annotation: null, date: null, -- cgit 1.3.0-6-gf8a5 From 9eef57746726faeeeb8e571b1794ec5c14023e97 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:53:35 -0400 Subject: test: withParsedCommentaryEntries: use stricter t.same() matching --- test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js index 928a9c98..babe4fae 100644 --- a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -41,7 +41,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => { }, }); - t.match(composite.expose.compute({ + t.same(composite.expose.compute({ artistData, from: `Mobius Trip:\n` + @@ -57,7 +57,7 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => { }, ]); - t.match(composite.expose.compute({ + t.same(composite.expose.compute({ artistData, from: `Mobius Trip|Moo-bius Trip: (music, art, 12 January 2015)\n` + -- cgit 1.3.0-6-gf8a5 From 6cbd8ee458a25ed8608760f8867008aa8f55d5e3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 19:54:37 -0400 Subject: test: Track.commentatorArtists: update overall --- test/unit/data/things/track.js | 48 ++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 806efbf1..571624a5 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -214,7 +214,7 @@ t.test(`Track.color`, t => { }); t.test(`Track.commentatorArtists`, t => { - t.plan(6); + t.plan(8); const track = new Track(); const artist1 = stubArtist(`SnooPING`); @@ -226,47 +226,67 @@ t.test(`Track.commentatorArtists`, t => { artistData: [artist1, artist2, artist3], }); - track.commentary = + // Keep track of the last commentary string in a separate value, since + // the track.commentary property exposes as a completely different format + // (i.e. an array of objects, one for each entry), and so isn't compatible + // with the += operator on its own. + let commentary; + + track.commentary = commentary = `SnooPING:\n` + `Wow.\n`; t.same(track.commentatorArtists, [artist1], `Track.commentatorArtists #1: works with one commentator`); - track.commentary += + track.commentary = commentary += `ASUsual:\n` + `Yes!\n`; t.same(track.commentatorArtists, [artist1, artist2], `Track.commentatorArtists #2: works with two commentators`); - track.commentary += - `Icy:\n` + + track.commentary = commentary += + `Icy|Icy What You Did There:\n` + `Incredible.\n`; t.same(track.commentatorArtists, [artist1, artist2, artist3], - `Track.commentatorArtists #3: works with boldface name`); + `Track.commentatorArtists #3: works with custom artist text`); - track.commentary = + track.commentary = commentary = `Icy: (project manager)\n` + `Very good track.\n`; t.same(track.commentatorArtists, [artist3], - `Track.commentatorArtists #4: works with parenthical accent`); + `Track.commentatorArtists #4: works with annotation`); - track.commentary += - `SNooPING ASUsual Icy:\n` + - `WITH ALL THREE POWERS COMBINED...`; + track.commentary = commentary = + `Icy: (project manager, 08/15/2023)\n` + + `Very very good track.\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #5: works with date`); + + track.commentary = commentary += + `Ohohohoho:\n` + + `OHOHOHOHOHOHO...\n`; t.same(track.commentatorArtists, [artist3], - `Track.commentatorArtists #5: ignores artist names not found`); + `Track.commentatorArtists #6: ignores artist names not found`); - track.commentary += + track.commentary = commentary += `Icy:\n` + `I'm back!\n`; t.same(track.commentatorArtists, [artist3], - `Track.commentatorArtists #6: ignores duplicate artist`); + `Track.commentatorArtists #7: ignores duplicate artist`); + + track.commentary = commentary += + `SNooPING, ASUsual, Icy:\n` + + `WITH ALL THREE POWERS COMBINED...`; + + t.same(track.commentatorArtists, [artist3, artist1, artist2], + `Track.commentatorArtists #8: works with more than one artist in one entry`); }); t.test(`Track.coverArtistContribs`, t => { -- cgit 1.3.0-6-gf8a5 From f82b74f62595c4def21b176c366c703da02c331e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 20:15:07 -0400 Subject: data, test: withUniqueItemsOnly (#composite/data) --- src/data/composite/data/index.js | 1 + src/data/composite/data/withUniqueItemsOnly.js | 40 +++++++++++ .../data/composite/data/withUniqueItemsOnly.js | 84 ++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/data/composite/data/withUniqueItemsOnly.js create mode 100644 test/unit/data/composite/data/withUniqueItemsOnly.js diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index db1c37cc..e2927afd 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -11,3 +11,4 @@ export {default as withPropertiesFromObject} from './withPropertiesFromObject.js export {default as withPropertyFromList} from './withPropertyFromList.js'; export {default as withPropertyFromObject} from './withPropertyFromObject.js'; export {default as withUnflattenedList} from './withUnflattenedList.js'; +export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js'; diff --git a/src/data/composite/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js new file mode 100644 index 00000000..7ee08b08 --- /dev/null +++ b/src/data/composite/data/withUniqueItemsOnly.js @@ -0,0 +1,40 @@ +// Excludes duplicate items from a list and provides the results, overwriting +// the list in-place, if possible. + +import {input, templateCompositeFrom} from '#composite'; +import {unique} from '#sugar'; + +export default templateCompositeFrom({ + annotation: `withUniqueItemsOnly`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list ?? '#uniqueItems'], + + steps: () => [ + { + dependencies: [input('list')], + compute: (continuation, { + [input('list')]: list, + }) => continuation({ + ['#values']: + unique(list), + }), + }, + + { + dependencies: ['#values', input.staticDependency('list')], + compute: (continuation, { + '#values': values, + [input.staticDependency('list')]: list, + }) => continuation({ + [list ?? '#uniqueItems']: + values, + }), + }, + ], +}); diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js new file mode 100644 index 00000000..965b14b5 --- /dev/null +++ b/test/unit/data/composite/data/withUniqueItemsOnly.js @@ -0,0 +1,84 @@ +import t from 'tap'; + +import {compositeFrom, input} from '#composite'; +import {exposeDependency} from '#composite/control-flow'; +import {withUniqueItemsOnly} from '#composite/data'; + +t.test(`withUniqueItemsOnly: basic behavior`, t => { + t.plan(3); + + const composite = compositeFrom({ + compose: false, + + steps: [ + withUniqueItemsOnly({ + list: 'list', + }), + + exposeDependency({dependency: '#list'}), + ], + }); + + t.match(composite, { + expose: { + dependencies: ['list'], + }, + }); + + t.same(composite.expose.compute({ + list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'], + }), ['apple', 'banana', 'watermelon']); + + t.same(composite.expose.compute({ + list: [], + }), []); +}); + +t.test(`withUniqueItemsOnly: output shapes & values`, t => { + t.plan(2 * 3 ** 1); + + const dependencies = { + ['list_dependency']: + [1, 1, 2, 3, 3, 4, 'foo', false, false, 4], + [input('list_neither')]: + [8, 8, 7, 6, 6, 5, 'bar', true, true, 5], + }; + + const mapLevel1 = [ + ['list_dependency', { + '#list_dependency': [1, 2, 3, 4, 'foo', false], + }], + [input.value([-1, -1, 'interesting', 'very', 'interesting']), { + '#uniqueItems': [-1, 'interesting', 'very'], + }], + [input('list_neither'), { + '#uniqueItems': [8, 7, 6, 5, 'bar', true], + }], + ]; + + for (const [listInput, outputDict] of mapLevel1) { + const step = withUniqueItemsOnly({ + list: listInput, + }); + + quickCheckOutputs(step, outputDict); + } + + function quickCheckOutputs(step, outputDict) { + t.same( + Object.keys(step.toDescription().outputs), + Object.keys(outputDict)); + + const composite = compositeFrom({ + compose: false, + steps: [step, { + dependencies: Object.keys(outputDict), + compute: dependencies => dependencies, + }], + }); + + t.same( + composite.expose.compute(dependencies), + outputDict); + } +}); -- cgit 1.3.0-6-gf8a5 From 5b8060bb86d457a0d23b607aa866c4d7d6eb6f0f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 20:16:40 -0400 Subject: data: withParsedCommentaryEntries: filter out null artists --- src/data/composite/wiki-data/withParsedCommentaryEntries.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js index 25c07a37..edfc9e3c 100644 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -125,7 +125,6 @@ export default templateCompositeFrom({ withUnflattenedList({ list: '#resolvedReferenceList', - filter: input.value(false), }).outputs({ '#unflattenedList': '#entries.artists', }), -- cgit 1.3.0-6-gf8a5 From e35d23f4e9492b497138dce3f21382872e329e71 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 20:16:57 -0400 Subject: data: commentatorArtists: filter out duplicate artists --- src/data/composite/wiki-properties/commentatorArtists.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index 65ab1466..f400bbfc 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -6,7 +6,8 @@ import {unique} from '#sugar'; import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; -import {withFlattenedList, withPropertyFromList} from '#composite/data'; +import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly} + from '#composite/data'; import {withParsedCommentaryEntries} from '#composite/wiki-data'; export default templateCompositeFrom({ @@ -38,6 +39,10 @@ export default templateCompositeFrom({ '#flattenedList': '#artists', }), + withUniqueItemsOnly({ + list: '#artists', + }), + exposeDependency({ dependency: '#artists', }), -- cgit 1.3.0-6-gf8a5 From a6dedb0b17f514e408919240c060072df2b179dd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 18 Nov 2023 20:18:42 -0400 Subject: test: Track.color: mock fake album for updated wikiData validator --- test/unit/data/things/track.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 806efbf1..51f86070 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -189,13 +189,14 @@ t.test(`Track.color`, t => { // connection breaks for some future reason (with the album still present), // Track.color should still inherit directly from the album. wikiData.albumData = [ - new Proxy({ + { + constructor: {[Thing.referenceType]: 'album'}, color: '#abcdef', tracks: [track], trackSections: [ {color: '#baaaad', tracks: []}, ], - }, {getPrototypeOf: () => Album.prototype}), + }, ]; linkWikiDataArrays(); -- cgit 1.3.0-6-gf8a5 From ee02bc3efebf992c47694ec4065f658473b1f904 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 13:32:10 -0400 Subject: data: validateArrayItems: annotate multiline errors nicely --- package-lock.json | 14 ++++++++++++++ package.json | 1 + src/data/things/validators.js | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9e8f4dfa..bca46bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "image-size": "^1.0.2", "js-yaml": "^4.1.0", "marked": "^10.0.0", + "printable-characters": "^1.0.42", "striptags": "^4.0.0-alpha.4", "word-wrap": "^1.2.3" }, @@ -26,6 +27,9 @@ "chokidar": "^3.5.3", "tap": "^18.4.0", "tcompare": "^6.0.0" + }, + "engines": { + "node": ">= 20.9.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -3954,6 +3958,11 @@ "node": ">= 0.8.0" } }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -8576,6 +8585,11 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, + "printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + }, "prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", diff --git a/package.json b/package.json index 719a5b89..b7467fd4 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "image-size": "^1.0.2", "js-yaml": "^4.1.0", "marked": "^10.0.0", + "printable-characters": "^1.0.42", "striptags": "^4.0.0-alpha.4", "word-wrap": "^1.2.3" }, diff --git a/src/data/things/validators.js b/src/data/things/validators.js index f60c363c..e213e933 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,5 +1,9 @@ import {inspect as nodeInspect} from 'node:util'; +// Heresy. +import printable_characters from 'printable-characters'; +const {strlen} = printable_characters; + import {colors, ENABLE_COLOR} from '#cli'; import {empty, typeAppearance, withAggregate} from '#sugar'; @@ -174,8 +178,19 @@ function validateArrayItemsHelper(itemValidator) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`; + const annotation = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)})`; + + error.message = + (error.message.includes('\n') || strlen(annotation) > 20 + ? annotation + '\n' + + error.message + .split('\n') + .map(line => ` ${line}`) + .join('\n') + : `${annotation} ${error}`); + error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index; + throw error; } }; -- cgit 1.3.0-6-gf8a5 From 2d58b70d0bd5bbc7cdd8789332a31a220c78da01 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 13:48:52 -0400 Subject: data: validateArrayItems (etc): pass through index, array --- src/data/things/validators.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/data/things/validators.js b/src/data/things/validators.js index e213e933..2893e7fd 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -170,9 +170,9 @@ export function is(...values) { } function validateArrayItemsHelper(itemValidator) { - return (item, index) => { + return (item, index, array) => { try { - const value = itemValidator(item); + const value = itemValidator(item, index, array); if (value !== true) { throw new Error(`Expected validator to return true`); @@ -197,13 +197,15 @@ function validateArrayItemsHelper(itemValidator) { } export function validateArrayItems(itemValidator) { - const fn = validateArrayItemsHelper(itemValidator); + const helper = validateArrayItemsHelper(itemValidator); return (array) => { isArray(array); - withAggregate({message: 'Errors validating array items'}, ({wrap}) => { - array.forEach(wrap(fn)); + withAggregate({message: 'Errors validating array items'}, ({call}) => { + for (let index = 0; index < array.length; index++) { + call(helper, array[index], index, array); + } }); return true; @@ -215,12 +217,12 @@ export function strictArrayOf(itemValidator) { } export function sparseArrayOf(itemValidator) { - return validateArrayItems(item => { + return validateArrayItems((item, index, array) => { if (item === false || item === null) { return true; } - return itemValidator(item); + return itemValidator(item, index, array); }); } -- cgit 1.3.0-6-gf8a5 From 1de52e0937132eca0349460fdcc7b0383d2b3312 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 13:53:13 -0400 Subject: sugar: cut (string to length) --- src/util/sugar.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/util/sugar.js b/src/util/sugar.js index 9646be37..ad676914 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -250,6 +250,16 @@ export function typeAppearance(value) { return typeof value; } +// Limits a string to the desired length, filling in an ellipsis at the end +// if it cuts any text off. +export function cut(text, length = 40) { + if (text.length >= length) { + return text.slice(0, Math.max(1, length - 3)) + '...'; + } else { + return text; + } +} + // Binds default values for arguments in a {key: value} type function argument // (typically the second argument, but may be overridden by providing a // [bindOpts.bindIndex] argument). Typically useful for preparing a function for -- cgit 1.3.0-6-gf8a5 From 9a35630d91ebd7227994a1009487794a65ec38e1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 13:53:36 -0400 Subject: data: tidy yaml error message construction, cut long strings ...Using maxStringLength, which is more than a bit annoying, because this isn't the same cut() algorithm we just added, looks bulkier, and can't be customized. But that's the cost of using util.inspect() here. It's better than displaying the entire long string or handling line breaks poorly. FOR NOW. --- src/data/yaml.js | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index 0734d539..49e05266 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -38,8 +38,8 @@ import { // --> General supporting stuff -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); +function inspect(value, opts = {}) { + return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); } // --> YAML data repository structure constants @@ -308,7 +308,12 @@ export class FieldCombinationError extends Error { constructor(fields, message) { const fieldNames = Object.keys(fields); - const mainMessage = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`; + const fieldNamesText = + fieldNames + .map(field => colors.red(field)) + .join(', '); + + const mainMessage = `Don't combine ${fieldNamesText}`; const causeMessage = (typeof message === 'function' @@ -330,7 +335,12 @@ export class FieldCombinationError extends Error { export class FieldValueAggregateError extends AggregateError { constructor(thingConstructor, errors) { - super(errors, `Errors processing field values for ${colors.green(thingConstructor.name)}`); + const constructorText = + colors.green(thingConstructor.name); + + super( + errors, + `Errors processing field values for ${constructorText}`); } } @@ -341,8 +351,17 @@ export class FieldValueError extends Error { ? caughtError.cause : caughtError); + const fieldText = + colors.green(`"${field}"`); + + const propertyText = + colors.green(property); + + const valueText = + inspect(value, {maxStringLength: 40}); + super( - `Failed to set ${colors.green(`"${field}"`)} field (${colors.green(property)}) to ${inspect(value)}`, + `Failed to set ${fieldText} field (${propertyText}) to ${valueText}`, {cause}); } } @@ -354,13 +373,18 @@ export class SkippedFieldsSummaryError extends Error { const lines = entries.map(([field, value]) => ` - ${field}: ` + - inspect(value) + inspect(value, {maxStringLength: 70}) .split('\n') .map((line, index) => index === 0 ? line : ` ${line}`) .join('\n')); + const numFieldsText = + (entries.length === 1 + ? `1 field` + : `${entries.length} fields`); + super( - colors.bright(colors.yellow(`Altogether, skipped ${entries.length === 1 ? `1 field` : `${entries.length} fields`}:\n`)) + + colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) + lines.join('\n') + '\n' + colors.bright(colors.yellow(`See above errors for details.`))); } -- cgit 1.3.0-6-gf8a5 From 87988954ad7314bee59932b0e5ef3474936ed33e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 13:59:13 -0400 Subject: data: update and revamp isCommentary validator --- src/data/things/validators.js | 59 +++++++++++++++++++++++++++++++++++-------- src/util/wiki-data.js | 14 +++++++--- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 2893e7fd..569a7b34 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -5,7 +5,8 @@ import printable_characters from 'printable-characters'; const {strlen} = printable_characters; import {colors, ENABLE_COLOR} from '#cli'; -import {empty, typeAppearance, withAggregate} from '#sugar'; +import {cut, empty, typeAppearance, withAggregate} from '#sugar'; +import {commentaryRegex} from '#wiki-data'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -248,18 +249,56 @@ export function isColor(color) { throw new TypeError(`Unknown color format`); } -export function isCommentary(commentary) { - isString(commentary); +export function isCommentary(commentaryText) { + isString(commentaryText); - const [firstLine] = commentary.match(/.*/); - if (!firstLine.replace(/<\/b>/g, '').includes(':')) { - throw new TypeError(`Missing commentary citation: "${ - firstLine.length > 40 - ? firstLine.slice(0, 40) + '...' - : firstLine - }"`); + const rawMatches = + Array.from(commentaryText.matchAll(commentaryRegex)); + + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one commentary heading`); } + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); + + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first commentary heading to be at top`); + } + + const ownInput = commentaryText.slice(position, position + length); + const restOfInput = commentaryText.slice(position + length); + const nextLineBreak = restOfInput.indexOf('\n'); + const upToNextLineBreak = restOfInput.slice(0, nextLineBreak); + + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected commentary heading to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } + + const nextHeading = + (index === niceMatches.length - 1 + ? commentaryText.length + : niceMatches[index + 1].position); + + const upToNextHeading = + commentaryText.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected commentary entry to have body text, only got a heading`); + } + + return true; + })(niceMatches); + return true; } diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 5e3182a9..b5813c7a 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -636,8 +636,8 @@ export function sortFlashesChronologically(data, { // // where capturing group "annotation" can be any text at all, except that the // last entry (past a comma or the only content within parentheses), if parsed -// as a date, is the capturing group "date". "Parsing as a date" means one of -// these formats: +// as a date, is the capturing group "date". "Parsing as a date" means matching +// one of these formats: // // * "25 December 2019" - one or two number digits, followed by any text, // followed by four number digits @@ -646,6 +646,14 @@ export function sortFlashesChronologically(data, { // * "12/25/2019" etc - three sets of one to four number digits, separated // by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD) // +// Note that the annotation and date are always wrapped by one opening and one +// closing parentheses. The whole heading does NOT need to match the entire +// line it occupies (though it does always start at the first position on that +// line), and if there is more than one closing parenthesis on the line, the +// annotation will always cut off only at the last parenthesis, or a comma +// preceding a date and then the last parenthesis. This is to ensure that +// parentheses can be part of the actual annotation content. +// // Capturing group "artistReference" is all the characters between and // (apart from the pipe and "artistDisplayText" text, if present), and is either // the name of an artist or an "artist:directory"-style reference. @@ -654,7 +662,7 @@ export function sortFlashesChronologically(data, { // out of the original string based on the indices matched using this. // export const commentaryRegex = - /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?$/gm; + /^(?.+?)(?:\|(?.+))?:<\/i>(?: \((?(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?/gm; export function filterAlbumsByCommentary(albums) { return albums -- cgit 1.3.0-6-gf8a5 From f87fa920f91d36424e4613ac5da50f46418f4b19 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 14:31:58 -0400 Subject: data, util: principle "translucent errors" & applications --- src/data/yaml.js | 9 +++++++-- src/util/sugar.js | 14 +++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index 49e05266..dddf5fb2 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -334,6 +334,8 @@ export class FieldCombinationError extends Error { } export class FieldValueAggregateError extends AggregateError { + [Symbol.for('hsmusic.aggregate.translucent')] = true; + constructor(thingConstructor, errors) { const constructorText = colors.green(thingConstructor.name); @@ -1162,7 +1164,10 @@ export async function loadAndProcessDataDocuments({dataPath}) { for (const dataStep of dataSteps) { await processDataAggregate.nestAsync( - {message: `Errors during data step: ${colors.bright(dataStep.title)}`}, + { + message: `Errors during data step: ${colors.bright(dataStep.title)}`, + translucent: true, + }, async ({call, callAsync, map, mapAsync, push}) => { const {documentMode} = dataStep; @@ -1407,7 +1412,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { switch (documentMode) { case documentModes.headerAndEntries: - map(yamlResults, {message: `Errors processing documents in data files`}, + map(yamlResults, {message: `Errors processing documents in data files`, translucent: true}, decorateErrorWithFile(({documents}) => { const headerDocument = documents[0]; const entryDocuments = documents.slice(1).filter(Boolean); diff --git a/src/util/sugar.js b/src/util/sugar.js index ad676914..6c41bca2 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -325,6 +325,12 @@ export function openAggregate({ // constructed. message = '', + // Optional flag to indicate that this layer of the aggregate error isn't + // generally useful outside of developer debugging purposes - it will be + // skipped by default when using showAggregate, showing contained errors + // inline with other children of this aggregate's parent. + translucent = false, + // Value to return when a provided function throws an error. If this is a // function, it will be called with the arguments given to the function. // (This is primarily useful when wrapping a function and then providing it @@ -407,7 +413,13 @@ export function openAggregate({ aggregate.close = () => { if (errors.length) { - throw Reflect.construct(errorClass, [errors, message]); + const error = Reflect.construct(errorClass, [errors, message]); + + if (translucent) { + error[Symbol.for(`hsmusic.aggregate.translucent`)] = true; + } + + throw error; } }; -- cgit 1.3.0-6-gf8a5 From 08e18be60cf20ee6d69701c88676d3577c8f431e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 14:32:27 -0400 Subject: sugar: translucent errors implementation --- src/util/sugar.js | 122 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 95 insertions(+), 27 deletions(-) diff --git a/src/util/sugar.js b/src/util/sugar.js index 6c41bca2..c4d4c129 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -592,34 +592,101 @@ export function _withAggregate(mode, aggregateOpts, fn) { export function showAggregate(topError, { pathToFileURL = f => f, showTraces = true, + showTranslucent = showTraces, print = true, } = {}) { - const recursive = (error, {level}) => { - let headerPart = showTraces - ? `[${error.constructor.name || 'unnamed'}] ${ - error.message || '(no message)' - }` - : error instanceof AggregateError - ? `[${error.message || '(no message)'}]` - : error.message || '(no message)'; + const translucentSymbol = Symbol.for('hsmusic.aggregate.translucent'); + + const determineCause = error => { + let cause = error.cause; + if (showTranslucent) return cause ?? null; + + while (cause) { + if (!cause[translucentSymbol]) return cause; + cause = cause.cause; + } + + return null; + }; + + const determineErrors = parentError => { + if (!parentError.errors) return null; + if (showTranslucent) return parentError.errors; + + const errors = []; + for (const error of parentError.errors) { + if (!error[translucentSymbol]) { + errors.push(error); + continue; + } + + if (error.cause) { + errors.push(determineCause(error)); + } + + if (error.errors) { + errors.push(...determineErrors(error)); + } + } + + return errors; + }; + + const flattenErrorStructure = (error, level = 0) => { + const cause = determineCause(error); + const errors = determineErrors(error); + + return { + level, + + kind: error.constructor.name, + message: error.message, + stack: error.stack, + + cause: + (cause + ? flattenErrorStructure(cause, level + 1) + : null), + + errors: + (errors + ? errors.map(error => flattenErrorStructure(error, level + 1)) + : null), + }; + }; + + const recursive = ({level, kind, message, stack, cause, errors}) => { + const messagePart = + message || `(no message)`; + + const kindPart = + kind || `unnamed kind`; + + let headerPart = + (showTraces + ? `[${kindPart}] ${messagePart}` + : errors + ? `[${messagePart}]` + : messagePart); if (showTraces) { - const stackLines = error.stack?.split('\n'); + const stackLines = + stack?.split('\n'); - const stackLine = stackLines?.find( - (line) => + const stackLine = + stackLines?.find(line => line.trim().startsWith('at') && !line.includes('sugar') && !line.includes('node:') && - !line.includes('') - ); + !line.includes('')); - const tracePart = stackLine - ? '- ' + - stackLine - .trim() - .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) - : '(no stack trace)'; + const tracePart = + (stackLine + ? '- ' + + stackLine + .trim() + .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) + : '(no stack trace)'); headerPart += ` ${colors.dim(tracePart)}`; } @@ -628,8 +695,8 @@ export function showAggregate(topError, { const bar1 = ' '; const causePart = - (error.cause - ? recursive(error.cause, {level: level + 1}) + (cause + ? recursive(cause) .split('\n') .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`) .join('\n') @@ -638,19 +705,20 @@ export function showAggregate(topError, { const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); - const aggregatePart = - (error instanceof AggregateError - ? error.errors - .map(error => recursive(error, {level: level + 1})) + const errorsPart = + (errors + ? errors + .map(error => recursive(error)) .flatMap(str => str.split('\n')) .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`) .join('\n') : ''); - return [headerPart, causePart, aggregatePart].filter(Boolean).join('\n'); + return [headerPart, causePart, errorsPart].filter(Boolean).join('\n'); }; - const message = recursive(topError, {level: 0}); + const structure = flattenErrorStructure(topError); + const message = recursive(structure); if (print) { console.error(message); -- cgit 1.3.0-6-gf8a5 From 92fc43c31f08d477c95524bc4a04b11ecb8fb5d1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:22:40 -0400 Subject: sugar: fix async decorateError not providing calling arguments --- src/util/sugar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/sugar.js b/src/util/sugar.js index 9646be37..3f0eb2ea 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -685,7 +685,7 @@ export function asyncAdaptiveDecorateError(fn, callback) { try { return await fn(...args); } catch (caughtError) { - throw callback(caughtError); + throw callback(caughtError, ...args); } }; -- cgit 1.3.0-6-gf8a5 From 45fb872e3c9db62da126c94c3219133b4945b532 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:23:58 -0400 Subject: css: allow custom scroll margin offset per-element --- src/static/site5.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/static/site5.css b/src/static/site5.css index bb83fe67..6cdc0c35 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1310,6 +1310,10 @@ h3.content-heading { /* Sticky heading */ +[id] { + --custom-scroll-offset: 0px; +} + #content [id] { /* Adjust scroll margin. */ scroll-margin-top: calc( @@ -1317,6 +1321,7 @@ h3.content-heading { + 33px /* Sticky subheading */ - 1em /* One line of text (align bottom) */ - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ ); } -- cgit 1.3.0-6-gf8a5 From 7f56163ad123b3ba8da431630d3da67f5444adb6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:25:30 -0400 Subject: css: allow sticky heading text to occupy blank area in thin layout --- src/static/site5.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/static/site5.css b/src/static/site5.css index 6cdc0c35..ea27e35e 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1766,6 +1766,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content z-index: 2; } + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + /* Disable grid features, just line header children up vertically */ #header { -- cgit 1.3.0-6-gf8a5 From 453b0e9845d41c7a1049d4f0982b66121626766a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 19:28:06 -0400 Subject: data: use optional in definitions for more utility validators --- src/data/things/validators.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/data/things/validators.js b/src/data/things/validators.js index f60c363c..19ad1c0a 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -96,7 +96,10 @@ export function isStringNonEmpty(value) { } export function optional(validator) { - return value => value === null || value === undefined || validator(value); + return value => + value === null || + value === undefined || + validator(value); } // Complex types (non-primitives) @@ -285,20 +288,14 @@ export function validateProperties(spec) { export const isContribution = validateProperties({ who: isArtistRef, - what: (value) => - value === undefined || - value === null || - isStringNonEmpty(value), + what: optional(isStringNonEmpty), }); export const isContributionList = validateArrayItems(isContribution); export const isAdditionalFile = validateProperties({ title: isString, - description: (value) => - value === undefined || - value === null || - isString(value), + description: optional(isStringNonEmpty), files: validateArrayItems(isString), }); -- cgit 1.3.0-6-gf8a5 From 765e039ef94f7cd1365ba9f211bd686beab5e8ec Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 19:31:05 -0400 Subject: data: move accent-parsing regex out of parseContributors Also use named capturing groups (and non-capturing groups) for generally better regex form. --- src/data/yaml.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index 1d35bae8..67cd8db7 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -717,26 +717,28 @@ export function parseAdditionalFiles(array) { })); } -export function parseContributors(contributors) { +const extractAccentRegex = + /^(?
                      .*?)(?: \((?.*)\))?$/; + +export function parseContributors(contributionStrings) { // If this isn't something we can parse, just return it as-is. // The Thing object's validators will handle the data error better // than we're able to here. - if (!Array.isArray(contributors)) { - return contributors; + if (!Array.isArray(contributionStrings)) { + return contributionStrings; } - contributors = contributors.map((contrib) => { - if (typeof contrib !== 'string') return contrib; + return contributionStrings.map(contribString => { + if (typeof contribString !== 'string') return contribString; - const match = contrib.match(/^(.*?)( \((.*)\))?$/); - if (!match) return contrib; + const match = contribString.match(extractAccentRegex); + if (!match) return contribString; - const who = match[1]; - const what = match[3] || null; - return {who, what}; + return { + who: match.groups.main, + what: match.groups.accent ?? null, + }; }); - - return contributors; } function parseDimensions(string) { -- cgit 1.3.0-6-gf8a5 From bde469f4cede426bd9baa8981b876e82ae290972 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 19:32:03 -0400 Subject: data: add additionalNames wiki property ("Additional Names") --- .../composite/wiki-properties/additionalNameList.js | 13 +++++++++++++ src/data/composite/wiki-properties/index.js | 1 + src/data/things/track.js | 2 ++ src/data/things/validators.js | 7 +++++++ src/data/yaml.js | 20 ++++++++++++++++++++ 5 files changed, 43 insertions(+) create mode 100644 src/data/composite/wiki-properties/additionalNameList.js diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js new file mode 100644 index 00000000..d1302224 --- /dev/null +++ b/src/data/composite/wiki-properties/additionalNameList.js @@ -0,0 +1,13 @@ +// A list of additional names! These can be used for a variety of purposes, +// e.g. providing extra searchable titles, localizations, romanizations or +// original titles, and so on. Each item has a name and, optionally, a +// descriptive annotation. + +import {isAdditionalNameList} from '#validators'; + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isAdditionalNameList}, + }; +} diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 2462b047..7607e361 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -1,4 +1,5 @@ export {default as additionalFiles} from './additionalFiles.js'; +export {default as additionalNameList} from './additionalNameList.js'; export {default as color} from './color.js'; export {default as commentary} from './commentary.js'; export {default as commentatorArtists} from './commentatorArtists.js'; diff --git a/src/data/things/track.js b/src/data/things/track.js index 8d310611..f6320677 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -24,6 +24,7 @@ import { import { additionalFiles, + additionalNameList, commentary, commentatorArtists, contributionList, @@ -63,6 +64,7 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), + additionalNames: additionalNameList(), duration: duration(), urls: urls(), diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 19ad1c0a..71570c5a 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -373,6 +373,13 @@ export function isURL(string) { return true; } +export const isAdditionalName = validateProperties({ + name: isName, + annotation: optional(isStringNonEmpty), +}); + +export const isAdditionalNameList = validateArrayItems(isAdditionalName); + export function validateReference(type = 'track') { return (ref) => { isStringNonEmpty(ref); diff --git a/src/data/yaml.js b/src/data/yaml.js index 67cd8db7..cde4413b 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -436,6 +436,7 @@ export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHel export const processTrackDocument = makeProcessDocument(T.Track, { fieldTransformations: { + 'Additional Names': parseAdditionalNames, 'Duration': parseDuration, 'Date First Released': (value) => new Date(value), @@ -457,6 +458,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, { propertyFieldMapping: { name: 'Track', directory: 'Directory', + additionalNames: 'Additional Names', duration: 'Duration', color: 'Color', urls: 'URLs', @@ -741,6 +743,24 @@ export function parseContributors(contributionStrings) { }); } +export function parseAdditionalNames(additionalNameStrings) { + if (!Array.isArray(additionalNameStrings)) { + return additionalNameStrings; + } + + return additionalNameStrings.map(additionalNameString => { + if (typeof additionalNameString !== 'string') return additionalNameString; + + const match = additionalNameString.match(extractAccentRegex); + if (!match) return additionalNameString; + + return { + name: match.groups.main, + annotation: match.groups.accent ?? null, + }; + }); +} + function parseDimensions(string) { // It's technically possible to pass an array like [30, 40] through here. // That's not really an issue because if it isn't of the appropriate shape, -- cgit 1.3.0-6-gf8a5 From c9847299d6a31b5fb39f82f80c92e80d53c44234 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:20:16 -0400 Subject: data: parse contribs & additional names from object shape --- src/data/yaml.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index cde4413b..5da66c93 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -730,11 +730,14 @@ export function parseContributors(contributionStrings) { return contributionStrings; } - return contributionStrings.map(contribString => { - if (typeof contribString !== 'string') return contribString; + return contributionStrings.map(item => { + if (typeof item === 'object' && item['Who']) + return {who: item['Who'], what: item['What'] ?? null}; - const match = contribString.match(extractAccentRegex); - if (!match) return contribString; + if (typeof item !== 'string') return item; + + const match = item.match(extractAccentRegex); + if (!match) return item; return { who: match.groups.main, @@ -748,11 +751,14 @@ export function parseAdditionalNames(additionalNameStrings) { return additionalNameStrings; } - return additionalNameStrings.map(additionalNameString => { - if (typeof additionalNameString !== 'string') return additionalNameString; + return additionalNameStrings.map(item => { + if (typeof item === 'object' && item['Name']) + return {name: item['Name'], annotation: item['Annotation'] ?? null}; + + if (typeof item !== 'string') return item; - const match = additionalNameString.match(extractAccentRegex); - if (!match) return additionalNameString; + const match = item.match(extractAccentRegex); + if (!match) return item; return { name: match.groups.main, -- cgit 1.3.0-6-gf8a5 From 745eb45531afb233a49433b18e3095238eb41b84 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:22:15 -0400 Subject: client: internal beforeHashLinkScrolls listener --- src/static/client3.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..4a5dffc2 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -576,6 +576,7 @@ const hashLinkInfo = clientInfo.hashLinkInfo = { }, event: { + beforeHashLinkScrolls: [], whenHashLinkClicked: [], }, }; @@ -638,6 +639,21 @@ function addHashLinkListeners() { return; } + // Don't do anything if the target element isn't actually visible! + if (target.offsetParent === null) { + return; + } + + // Allow event handlers to prevent scrolling. + for (const handler of event.beforeHashLinkScrolls) { + if (handler({ + link: hashLink, + target, + }) === false) { + return; + } + } + // Hide skipper box right away, so the layout is updated on time for the // math operations coming up next. const skipper = document.getElementById('skippers'); @@ -675,6 +691,7 @@ function addHashLinkListeners() { for (const handler of event.whenHashLinkClicked) { handler({ link: hashLink, + target, }); } }); -- cgit 1.3.0-6-gf8a5 From bad238355e19c4fef5e5f3b41df88fa9b1b84aaa Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:31:45 -0400 Subject: content, client, css: generateAdditionalNamesBox --- .../dependencies/generateAdditionalNamesBox.js | 48 ++++++++++++ src/content/dependencies/generatePageLayout.js | 38 +++++---- src/content/dependencies/generateTrackInfoPage.js | 8 ++ src/static/client3.js | 90 ++++++++++++++++++++++ src/static/site5.css | 66 ++++++++++++++++ src/strings-default.yaml | 15 ++++ 6 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 src/content/dependencies/generateAdditionalNamesBox.js diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js new file mode 100644 index 00000000..f7fa3b00 --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -0,0 +1,48 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, additionalNames) => ({ + names: + additionalNames.map(({name}) => + relation('transformContent', name)), + + annotations: + additionalNames.map(({annotation}) => + (annotation + ? relation('transformContent', annotation) + : null)), + }), + + generate: (relations, {html, language}) => { + const names = + relations.names.map(name => + html.tag('span', {class: 'additional-name'}, + name.slot('mode', 'inline'))); + + const annotations = + relations.annotations.map(annotation => + (annotation + ? html.tag('span', {class: 'annotation'}, + language.$('misc.additionalNames.item.annotation', { + annotation: + annotation.slot('mode', 'inline'), + })) + : null)); + + return html.tag('div', {id: 'additional-names-box'}, [ + html.tag('p', + language.$('misc.additionalNames.title')), + + html.tag('ul', + stitchArrays({name: names, annotation: annotations}) + .map(({name, annotation}) => + html.tag('li', + (annotation + ? language.$('misc.additionalNames.item.withAnnotation', {name, annotation}) + : language.$('misc.additionalNames.item', {name}))))), + ]); + }, +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 95551f3e..7dee8cc3 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -108,6 +108,8 @@ export default { title: {type: 'html'}, showWikiNameInTitle: {type: 'boolean', default: true}, + additionalNames: {type: 'html'}, + cover: {type: 'html'}, socialEmbed: {type: 'html'}, @@ -222,22 +224,25 @@ export default { const colors = getColors(slots.color ?? data.wikiColor); const hasSocialEmbed = !html.isBlank(slots.socialEmbed); - let titleHTML = null; - - if (!html.isBlank(slots.title)) { - switch (slots.headingMode) { - case 'sticky': - titleHTML = - relations.stickyHeadingContainer.slots({ - title: slots.title, - cover: slots.cover, - }); - break; - case 'static': - titleHTML = html.tag('h1', slots.title); - break; - } - } + const titleContentsHTML = + (html.isBlank(slots.title) + ? null + : html.isBlank(slots.additionalNames) + ? language.sanitize(slots.title) + : html.tag('a', { + href: '#additional-names-box', + title: language.$('misc.additionalNames.tooltip').toString(), + }, language.sanitize(slots.title))); + + const titleHTML = + (html.isBlank(slots.title) + ? null + : slots.headingMode === 'sticky' + ? relations.stickyHeadingContainer.slots({ + title: titleContentsHTML, + cover: slots.cover, + }) + : html.tag('h1', titleContentsHTML)); let footerContent = slots.footerContent; @@ -254,6 +259,7 @@ export default { titleHTML, slots.cover, + slots.additionalNames, html.tag('div', { diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 93334948..180e5c29 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -6,6 +6,7 @@ import getChronologyRelations from '../util/getChronologyRelations.js'; export default { contentDependencies: [ 'generateAdditionalFilesShortcut', + 'generateAdditionalNamesBox', 'generateAlbumAdditionalFilesList', 'generateAlbumNavAccent', 'generateAlbumSidebar', @@ -106,6 +107,11 @@ export default { list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), }); + if (!empty(track.additionalNames)) { + relations.additionalNamesBox = + relation('generateAdditionalNamesBox', track.additionalNames); + } + if (track.hasUniqueCoverArt || album.hasCoverArt) { relations.cover = relation('generateTrackCoverArtwork', track); @@ -300,6 +306,8 @@ export default { title: language.$('trackPage.title', {track: data.name}), headingMode: 'sticky', + additionalNames: relations.additionalNamesBox ?? null, + color: data.color, styleRules: [relations.albumStyleRules], diff --git a/src/static/client3.js b/src/static/client3.js index 4a5dffc2..94ba4a23 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1260,6 +1260,96 @@ function loadImage(imageUrl, onprogress) { }); } +// "Additional names" box --------------------------------- + +const additionalNamesBoxInfo = clientInfo.additionalNamesBox = { + box: null, + links: null, + mainContentContainer: null, + + state: { + visible: false, + }, +}; + +function getAdditionalNamesBoxReferences() { + const info = additionalNamesBoxInfo; + + info.box = + document.getElementById('additional-names-box'); + + info.links = + document.querySelectorAll('a[href="#additional-names-box"]'); + + info.mainContentContainer = + document.querySelector('#content .main-content-container'); +} + +function addAdditionalNamesBoxInternalListeners() { + const info = additionalNamesBoxInfo; + + hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { + if (target === info.box) { + return false; + } + }); +} + +function addAdditionalNamesBoxListeners() { + const info = additionalNamesBoxInfo; + + for (const link of info.links) { + link.addEventListener('click', domEvent => { + handleAdditionalNamesBoxLinkClicked(domEvent); + }); + } +} + +function handleAdditionalNamesBoxLinkClicked(domEvent) { + const info = additionalNamesBoxInfo; + const {state} = info; + + domEvent.preventDefault(); + + if (!info.box || !info.mainContentContainer) return; + + const margin = + +(cssProp(info.box, 'scroll-margin-top').replace('px', '')); + + const {top} = + (state.visible + ? info.box.getBoundingClientRect() + : info.mainContentContainer.getBoundingClientRect()); + + if (top + 20 < margin || top > 0.4 * window.innerHeight) { + if (!state.visible) { + toggleAdditionalNamesBox(); + } + + window.scrollTo({ + top: window.scrollY + top - margin, + behavior: 'smooth', + }); + } else { + toggleAdditionalNamesBox(); + } +} + +function toggleAdditionalNamesBox() { + const info = additionalNamesBoxInfo; + const {state} = info; + + state.visible = !state.visible; + info.box.style.display = + (state.visible + ? 'block' + : 'none'); +} + +clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences); +clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners); +clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners); + // Group contributions table ------------------------------ const groupContributionsTableInfo = diff --git a/src/static/site5.css b/src/static/site5.css index ea27e35e..31b2995b 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -802,6 +802,68 @@ html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not( opacity: 0.7; } +/* Additional names (heading and box) */ + +h1 a[href="#additional-names-box"] { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; +} + +h1 a[href="#additional-names-box"]:hover { + text-decoration-style: solid; +} + +#additional-names-box { + --custom-scroll-offset: calc(0.5em - 2px); + + margin: 1em 0 1em -10px; + padding: 15px 20px 10px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + + display: none; +} + +#additional-names-box > :first-child { margin-top: 0; } +#additional-names-box > :last-child { margin-bottom: 0; } + +#additional-names-box p { + padding-left: 10px; + padding-right: 10px; + margin-bottom: 0; + font-style: oblique; +} + +#additional-names-box ul { + padding-left: 10px; + margin-top: 0.5em; +} + +#additional-names-box li .additional-name { + margin-right: 0.25em; +} + +#additional-names-box li .additional-name .content-image { + margin-bottom: 0.25em; + margin-top: 0.5em; +} + +#additional-names-box li .annotation { + opacity: 0.8; + display: inline-block; +} + /* Images */ .image-container { @@ -1760,6 +1822,10 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content max-width: unset; } + #additional-names-box { + max-width: unset; + } + /* Show sticky heading above cover art */ .content-sticky-heading-container { diff --git a/src/strings-default.yaml b/src/strings-default.yaml index e6b8d6db..7562af84 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -338,6 +338,21 @@ trackList: # misc: + # additionalNames: + # "Drop"-styled box that catalogues a variety of additional or + # alternate names for the current thing; toggled by clicking on the + # thing's title, which is styled interactively and gets a tooltip + # (hover text), since it isn't usually an interactive element. + + additionalNames: + title: "Additional or alternate names:" + tooltip: "Click to view additional or alternate names" + + item: + _: "{NAME}" + withAnnotation: "{NAME} {ANNOTATION}" + annotation: "({ANNOTATION})" + # alt: # Fallback text for the alt text of images and artworks - these # are read aloud by screen readers. -- cgit 1.3.0-6-gf8a5 From c11edada828dc734cce6988e5819630a73326085 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 21 Jul 2023 20:06:32 -0300 Subject: content, test: linkContribution: tooltip icons --- .../generateReleaseInfoContributionsLine.js | 1 + src/content/dependencies/linkContribution.js | 79 ++++++++--- src/static/client3.js | 150 +++++++++++++++++++++ src/static/site5.css | 42 ++++++ .../snapshot/generateAlbumReleaseInfo.js.test.cjs | 16 +-- .../snapshot/generateTrackReleaseInfo.js.test.cjs | 2 +- .../test/snapshot/linkContribution.js.test.cjs | 144 ++++++++++++++++---- test/snapshot/linkContribution.js | 37 ++++- 8 files changed, 411 insertions(+), 60 deletions(-) diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js index 1fa8dcca..2e6c4709 100644 --- a/src/content/dependencies/generateReleaseInfoContributionsLine.js +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -35,6 +35,7 @@ export default { link.slots({ showContribution: slots.showContribution, showIcons: slots.showIcons, + iconMode: 'tooltip', }))), }); }, diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index 8e42f247..5bc398de 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -20,7 +20,6 @@ export default { if (!empty(contribution.who.urls)) { relations.artistIcons = contribution.who.urls - .slice(0, 4) .map(url => relation('linkExternalAsIcon', url)); } @@ -37,37 +36,79 @@ export default { showContribution: {type: 'boolean', default: false}, showIcons: {type: 'boolean', default: false}, preventWrapping: {type: 'boolean', default: true}, + + iconMode: { + validate: v => v.is('inline', 'tooltip'), + default: 'inline' + }, }, generate(data, relations, slots, {html, language}) { - const hasContributionPart = !!(slots.showContribution && data.what); - const hasExternalPart = !!(slots.showIcons && relations.artistIcons); - - const externalLinks = hasExternalPart && - html.tag('span', - {[html.noEdgeWhitespace]: true, class: 'icons'}, - language.formatUnitList(relations.artistIcons)); + const hasContribution = !!(slots.showContribution && data.what); + const hasExternalIcons = !!(slots.showIcons && relations.artistIcons); const parts = ['misc.artistLink']; const options = {artist: relations.artistLink}; - if (hasContributionPart) { + if (hasContribution) { parts.push('withContribution'); options.contrib = data.what; } - if (hasExternalPart) { + if (hasExternalIcons && slots.iconMode === 'inline') { parts.push('withExternalLinks'); - options.links = externalLinks; + options.links = + html.tag('span', + { + [html.noEdgeWhitespace]: true, + class: ['icons', 'icons-inline'], + }, + language.formatUnitList( + relations.artistIcons + .slice(0, 4))); } - const content = language.formatString(parts.join('.'), options); + let content = language.formatString(parts.join('.'), options); - return ( - (parts.length > 1 && slots.preventWrapping - ? html.tag('span', - {[html.noEdgeWhitespace]: true, class: 'nowrap'}, - content) - : content)); - }, + if (hasExternalIcons && slots.iconMode === 'tooltip') { + content = [ + content, + html.tag('span', + { + [html.noEdgeWhitespace]: true, + class: ['icons', 'icons-tooltip'], + inert: true, + }, + html.tag('span', + { + [html.noEdgeWhitespace]: true, + [html.joinChildren]: '', + class: 'icons-tooltip-content', + }, + relations.artistIcons)), + ]; + } + + if (hasContribution || hasExternalIcons) { + content = + html.tag('span', { + [html.noEdgeWhitespace]: true, + [html.joinChildren]: '', + + class: [ + 'contribution', + + hasExternalIcons && + slots.iconMode === 'tooltip' && + 'has-tooltip', + + parts.length > 1 && + slots.preventWrapping && + 'nowrap', + ], + }, content); + } + + return content; + } }; diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..091d1fcf 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -958,6 +958,8 @@ clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); // Image overlay ------------------------------------------ +// TODO: Update to clientSteps style. + function addImageOverlayClickHandlers() { const container = document.getElementById('image-overlay-container'); @@ -1245,6 +1247,8 @@ function loadImage(imageUrl, onprogress) { // Group contributions table ------------------------------ +// TODO: Update to clientSteps style. + const groupContributionsTableInfo = Array.from(document.querySelectorAll('#content dl')) .filter(dl => dl.querySelector('a.group-contributions-sort-button')) @@ -1277,6 +1281,152 @@ for (const info of groupContributionsTableInfo) { }); } +// Artist link icon tooltips ------------------------------ + +// TODO: Update to clientSteps style. + +const linkIconTooltipInfo = + Array.from(document.querySelectorAll('span.contribution.has-tooltip')) + .map(span => ({ + mainLink: span.querySelector('a'), + iconsContainer: span.querySelector('span.icons-tooltip'), + iconLinks: span.querySelectorAll('span.icons-tooltip a'), + })); + +for (const info of linkIconTooltipInfo) { + const focusElements = + [info.mainLink, ...info.iconLinks]; + + const hoverElements = + [info.mainLink, info.iconsContainer]; + + let hidden = true; + + const show = () => { + info.iconsContainer.classList.add('visible'); + info.iconsContainer.inert = false; + hidden = false; + }; + + const hide = () => { + info.iconsContainer.classList.remove('visible'); + info.iconsContainer.inert = true; + hidden = true; + }; + + const considerHiding = () => { + if (hoverElements.some(el => el.matches(':hover'))) { + return; + } + + if (focusElements.includes(document.activeElement)) { + return; + } + + if (justTouched) { + return; + } + + hide(); + }; + + // Hover (pointer) + + let hoverTimeout; + + info.mainLink.addEventListener('mouseenter', () => { + if (hidden) { + hoverTimeout = setTimeout(show, 250); + } + }); + + info.mainLink.addEventListener('mouseout', () => { + if (hidden) { + clearTimeout(hoverTimeout); + } else { + considerHiding(); + } + }); + + info.iconsContainer.addEventListener('mouseout', () => { + if (!hidden) { + considerHiding(); + } + }); + + // Focus (keyboard) + + let focusTimeout; + + info.mainLink.addEventListener('focus', () => { + focusTimeout = setTimeout(show, 750); + }); + + info.mainLink.addEventListener('blur', () => { + clearTimeout(focusTimeout); + }); + + info.iconsContainer.addEventListener('focusout', () => { + requestAnimationFrame(considerHiding); + }); + + info.mainLink.addEventListener('blur', () => { + requestAnimationFrame(considerHiding); + }); + + // Touch (finger) + + let justTouched = false; + let touchTimeout; + + info.mainLink.addEventListener('touchend', event => { + let wasTarget = false; + + for (const touch of event.changedTouches) { + if (touch.target === info.mainLink) { + wasTarget = true; + break; + } + } + + if (!wasTarget) { + return; + } + + justTouched = true; + + clearTimeout(touchTimeout); + touchTimeout = setTimeout(() => { + justTouched = false; + }, 250); + + show(); + }); + + info.mainLink.addEventListener('click', event => { + if (hidden && justTouched) { + event.preventDefault(); + event.target.focus(); + show(); + } + }); + + document.body.addEventListener('touchend', event => { + const touches = [...event.changedTouches, ...event.touches]; + for (const {clientX, clientY} of touches) { + const touchEl = document.elementFromPoint(clientX, clientY); + if (!touchEl) continue; + + for (const hoverEl of hoverElements) { + if (touchEl === hoverEl) return; + if (hoverEl.contains(touchEl)) return; + } + } + + hide(); + }); +} + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { diff --git a/src/static/site5.css b/src/static/site5.css index bb83fe67..06696799 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -427,6 +427,7 @@ a { a:hover { text-decoration: underline; + text-decoration-style: solid !important; } a.current { @@ -472,11 +473,52 @@ a:not([href]):hover { white-space: nowrap; } +.contribution { + position: relative; +} + +.contribution.has-tooltip a { + text-decoration: underline; + text-decoration-style: dotted; +} + .icons { font-style: normal; white-space: nowrap; } +.icons-tooltip { + position: absolute; + z-index: 999; + left: -12px; + top: calc(1em - 2px); + padding: 4px 12px 6px 8px; +} + +.icons-tooltip:not(.visible) { + display: none; +} + +.icons-tooltip-content { + display: block; + padding: 6px 2px 2px 2px; + background: var(--bg-black-color); + border: 1px dotted var(--primary-color); + border-radius: 4px; + + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +.icons a:hover { + filter: brightness(1.4); +} + +.icons a { + padding: 0 3px; +} + .icon { display: inline-block; width: 24px; diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs index 9702cad8..3335a2eb 100644 --- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs @@ -7,18 +7,18 @@ 'use strict' exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `

                      - By Toby Fox (music probably) and Tensei (hot jams) ( - - Bandcamp - - - ). + By Toby Fox (music probably) and Tensei (hot jams) + + Bandcamp + + + .
                      Cover art by Hanni Brosh.
                      - Wallpaper art by Hanni Brosh and Niklink (edits). + Wallpaper art by Hanni Brosh and Niklink (edits).
                      - Banner art by Hanni Brosh and Niklink (edits). + Banner art by Hanni Brosh and Niklink (edits).
                      Released 3/14/2011.
                      diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs index 2add28ed..3d988dce 100644 --- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs @@ -25,7 +25,7 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI

                      By Toby Fox.
                      - Cover art by Alpaca (🔥). + Cover art by Alpaca (🔥).

                      This wiki doesn't have any listening links for Suspicious Track.

                      ` diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs index 75b9d273..4cf3aa3f 100644 --- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs @@ -5,8 +5,8 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links 1`] = ` -Lorem Ipsum Lover ( +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (inline) 1`] = ` +Lorem Ipsum Lover ( External (loremipsum.io) @@ -29,6 +29,50 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ) ` +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = ` +Lorem Ipsum Lover + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + +` + exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no accents 1`] = ` Clark Powell Grounder & Scratch @@ -36,41 +80,41 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no preventWrapping 1`] = ` -Clark Powell ( - - SoundCloud - - - ) -Grounder & Scratch (Snooping) -Toby Fox (Arrangement) ( - - Bandcamp - - - , - - External (toby.fox) - - - ) +Clark Powell ( + + SoundCloud + + + ) +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) ( + + Bandcamp + + + , + + External (toby.fox) + + + ) ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showContribution 1`] = ` Clark Powell -Grounder & Scratch (Snooping) -Toby Fox (Arrangement) +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) ` -exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons 1`] = ` -Clark Powell ( +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (inline) 1`] = ` +Clark Powell ( SoundCloud ) Grounder & Scratch -Toby Fox ( +Toby Fox ( Bandcamp @@ -83,15 +127,36 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ) ` -exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons 1`] = ` -Clark Powell ( +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = ` +Clark Powell + + SoundCloud + + + +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) + + Bandcamp + + + + + External (toby.fox) + + + +` + +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (inline) 1`] = ` +Clark Powell ( SoundCloud ) -Grounder & Scratch (Snooping) -Toby Fox (Arrangement) ( +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) ( Bandcamp @@ -103,3 +168,24 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ) ` + +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = ` +Clark Powell + + SoundCloud + + + +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) + + Bandcamp + + + + + External (toby.fox) + + + +` diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js index ad5fb416..ebd3be58 100644 --- a/test/snapshot/linkContribution.js +++ b/test/snapshot/linkContribution.js @@ -33,22 +33,36 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => { slots, }); - quickSnapshot('showContribution & showIcons', { + quickSnapshot('showContribution & showIcons (inline)', { showContribution: true, showIcons: true, + iconMode: 'inline', + }); + + quickSnapshot('showContribution & showIcons (tooltip)', { + showContribution: true, + showIcons: true, + iconMode: 'tooltip', }); quickSnapshot('only showContribution', { showContribution: true, }); - quickSnapshot('only showIcons', { + quickSnapshot('only showIcons (inline)', { + showIcons: true, + iconMode: 'inline', + }); + + quickSnapshot('only showIcons (tooltip)', { + showContribution: true, showIcons: true, + iconMode: 'tooltip', }); quickSnapshot('no accents', {}); - evaluate.snapshot('loads of links', { + evaluate.snapshot('loads of links (inline)', { name: 'linkContribution', args: [ {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [ @@ -65,6 +79,23 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => { slots: {showIcons: true}, }); + evaluate.snapshot('loads of links (tooltip)', { + name: 'linkContribution', + args: [ + {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [ + 'https://loremipsum.io', + 'https://loremipsum.io/generator/', + 'https://loremipsum.io/#meaning', + 'https://loremipsum.io/#usage-and-examples', + 'https://loremipsum.io/#controversy', + 'https://loremipsum.io/#when-to-use-lorem-ipsum', + 'https://loremipsum.io/#lorem-ipsum-all-the-things', + 'https://loremipsum.io/#original-source', + ]}, what: null}, + ], + slots: {showIcons: true, iconMode: 'tooltip'}, + }); + quickSnapshot('no preventWrapping', { showContribution: true, showIcons: true, -- cgit 1.3.0-6-gf8a5 From 10140f5b90e0fa9b38cdacfa23b10d96fb6fd189 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:30:23 -0400 Subject: client: dispatchInternalEvent utility --- src/static/client3.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/static/client3.js b/src/static/client3.js index 091d1fcf..84a66e3b 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -81,6 +81,31 @@ function fetchData(type, directory) { ); } +function dispatchInternalEvent(event, eventName, ...args) { + const [infoName] = + Object.entries(clientInfo) + .find(pair => pair[1].event === event); + + if (!infoName) { + throw new Error(`Expected event to be stored on clientInfo`); + } + + const {[eventName]: listeners} = event; + + if (!listeners) { + throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); + } + + for (const listener of listeners) { + try { + listener(...args); + } catch (error) { + console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); + console.debug(error); + } + } +} + // JS-based links ----------------------------------------- const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { @@ -672,6 +697,8 @@ function addHashLinkListeners() { processScrollingAfterHashLinkClicked(); + dispatchInternalEvent(event, 'whenHashLinkClicked', {link: hashLink}); + for (const handler of event.whenHashLinkClicked) { handler({ link: hashLink, -- cgit 1.3.0-6-gf8a5 From c34da87fb949c7797a1f273264720798dc7341ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:32:08 -0400 Subject: client: add hoverable tooltip system, logic pulled from info cards While this system comprehensively covers everything that info cards did (which was generally smarter hovering logic than newer code for external icon tooltips), it isn't focus- and touch-capable yet, so isn't quite done within this commit. However, the interface this system provides to others is baked and fully implemented here. --- src/static/client3.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 8 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 84a66e3b..acd85880 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -354,17 +354,209 @@ if ( }); } -// Data & info card --------------------------------------- +// Tooltip-style hover (infrastructure) ------------------- -/* -const NORMAL_HOVER_INFO_DELAY = 750; -const FAST_HOVER_INFO_DELAY = 250; -const END_FAST_HOVER_DELAY = 500; -const HIDE_HOVER_DELAY = 250; +const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { + settings: { + normalHoverInfoDelay: 400, + fastHoveringInfoDelay: 150, + + endFastHoveringDelay: 500, + + hideTooltipDelay: 500, + }, + + state: { + // These maps store a record for each registered element and related state + // and registration info, if applicable. + registeredTooltips: new Map(), + registeredHoverables: new Map(), + + // These are common across all tooltips, rather than stored individually, + // based on the principles that 1) only a single tooltip can be displayed + // at once, and 2) only a single hoverable can be hovered at a once. + hoverTimeout: null, + hideTimeout: null, + currentlyShownTooltip: null, + + // Fast hovering is a global mode which is activated as soon as any tooltip + // is displayed and turns off after a delay of no hoverables being hovered. + // Note that fast hovering may be turned off while hovering a tooltip, but + // it will never be turned off while idling over a hoverable. + fastHovering: false, + endFastHoveringTimeout: false, + }, + + event: { + whenTooltipShouldBeShown: [], + whenTooltipShouldBeHidden: [], + }, +}; + +// Adds DOM event listeners, so must be called during addPageListeners step. +function registerTooltipElement(tooltip) { + const {state} = hoverableTooltipInfo; + + if (!tooltip) + throw new Error(`Expected tooltip`); + + if (state.registeredTooltips.has(tooltip)) + throw new Error(`This tooltip is already registered`); + + // No state or registration info here. + state.registeredTooltips.set(tooltip, {}); + + tooltip.addEventListener('mouseenter', () => { + handleTooltipMouseEntered(tooltip); + }); -let fastHover = false; -let endFastHoverTimeout = null; + tooltip.addEventListener('mouseleave', () => { + handleTooltipMouseLeft(tooltip); + }); +} + +// Adds DOM event listeners, so must be called during addPageListeners step. +function registerTooltipHoverableElement(hoverable, tooltip) { + const {state} = hoverableTooltipInfo; + + if (!hoverable || !tooltip) + if (hoverable) + throw new Error(`Expected hoverable and tooltip, got only hoverable`); + else + throw new Error(`Expected hoverable and tooltip, got neither`); + + if (!state.registeredTooltips.has(tooltip)) + throw new Error(`Register tooltip before registering hoverable`); + + if (state.registeredHoverables.has(hoverable)) + throw new Error(`This hoverable is already registered`); + + state.registeredHoverables.set(hoverable, {tooltip}); + + hoverable.addEventListener('mouseenter', () => { + handleTooltipHoverableMouseEntered(hoverable); + }); + + hoverable.addEventListener('mouseleave', () => { + handleTooltipHoverableMouseLeft(hoverable); + }); +} + +function handleTooltipMouseEntered(tooltip) { + const {state} = hoverableTooltipInfo; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Don't time out the current tooltip while hovering it. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} +function handleTooltipMouseLeft(tooltip) { + const {state, settings} = hoverableTooltipInfo; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Start timing out the current tooltip when it's left. This could be + // canceled by mousing over a hoverable, or back over the tooltip again. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function handleTooltipHoverableMouseEntered(hoverable) { + const {event, settings, state} = hoverableTooltipInfo; + + const hoverTimeoutDelay = + (state.fastHovering + ? settings.fastHoveringInfoDelay + : settings.normalHoverInfoDelay); + + // Start a timer to show the corresponding tooltip, with the delay depending + // on whether fast hovering or not. This could be canceled by mousing out of + // the hoverable. + state.hoverTimeout = + setTimeout(() => { + state.hoverTimeout = null; + state.fastHovering = true; + showTooltipFromHoverable(hoverable); + }, hoverTimeoutDelay); + + // Don't stop fast hovering while over any hoverable. + if (state.endFastHoveringTimeout) { + clearTimeout(state.endFastHoveringTimeout); + state.endFastHoveringTimeout = null; + } + + // Don't time out the current tooltip while over any hoverable. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipHoverableMouseLeft(hoverable) { + const {state, settings} = hoverableTooltipInfo; + + // Don't show a tooltip when not over a hoverable! + if (state.hoverTimeout) { + clearTimeout(state.hoverTimeout); + state.hoverTimeout = null; + } + + // Start timing out fast hovering (if active) when not over a hoverable. + // This will only be canceled by mousing over another hoverable. + if (state.fastHovering && !state.endFastHoveringTimeout) { + state.endFastHoveringTimeout = + setTimeout(() => { + state.endFastHoveringTimeout = null; + state.fastHovering = false; + }, settings.endFastHoveringDelay); + } + + // Start timing out the current tooltip when mousing not over a hoverable. + // This could be canceled by mousing over another hoverable, or over the + // currently shown tooltip. + if (state.currentlyShownTooltip && !state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function hideCurrentlyShownTooltip() { + const {event, state} = hoverableTooltipInfo; + const {currentlyShownTooltip: tooltip} = state; + + if (!tooltip) return; + + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + + state.currentlyShownTooltip = null; +} + +function showTooltipFromHoverable(hoverable) { + const {event, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + hideCurrentlyShownTooltip(); + + dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); + + state.currentlyShownTooltip = tooltip; +} + +// Data & info card --------------------------------------- + +/* function colorLink(a, color) { console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); return; -- cgit 1.3.0-6-gf8a5 From 15f72dcf7bec602b979621d6c9e9c6d11617ffbb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:35:18 -0400 Subject: client: integrate new tooltip system into external link icons Reference code currently retained, waiting for focus and touch support in the new tooltip system. But this commit should fully cover all the new integration needed! --- src/static/client3.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index acd85880..57922022 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1502,8 +1502,67 @@ for (const info of groupContributionsTableInfo) { // Artist link icon tooltips ------------------------------ -// TODO: Update to clientSteps style. +const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = { + hoverableLinks: null, + iconContainers: null, +}; + +function getExternalIconTooltipReferences() { + const info = externalIconTooltipInfo; + + const spans = + Array.from(document.querySelectorAll('span.contribution.has-tooltip')); + + info.hoverableLinks = + spans + .map(span => span.querySelector('a')); + + info.iconContainers = + spans + .map(span => span.querySelector('span.icons-tooltip')); +} + +function addExternalIconTooltipInternalListeners() { + const info = externalIconTooltipInfo; + + hoverableTooltipInfo.event.whenTooltipShouldBeShown.push(({tooltip}) => { + if (!info.iconContainers.includes(tooltip)) return; + showExternalIconTooltip(tooltip); + }); + + hoverableTooltipInfo.event.whenTooltipShouldBeHidden.push(({tooltip}) => { + if (!info.iconContainers.includes(tooltip)) return; + hideExternalIconTooltip(tooltip); + }); +} +function showExternalIconTooltip(iconContainer) { + iconContainer.classList.add('visible'); + iconContainer.inert = false; +} + +function hideExternalIconTooltip(iconContainer) { + iconContainer.classList.remove('visible'); + iconContainer.inert = true; +} + +function addExternalIconTooltipPageListeners() { + const info = externalIconTooltipInfo; + + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverableLinks, + tooltip: info.iconContainers, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} + +clientSteps.getPageReferences.push(getExternalIconTooltipReferences); +clientSteps.addInternalListeners.push(addExternalIconTooltipInternalListeners); +clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); + +/* const linkIconTooltipInfo = Array.from(document.querySelectorAll('span.contribution.has-tooltip')) .map(span => ({ @@ -1538,7 +1597,7 @@ for (const info of linkIconTooltipInfo) { return; } - if (focusElements.includes(document.activeElement)) { + if () { return; } @@ -1645,6 +1704,7 @@ for (const info of linkIconTooltipInfo) { hide(); }); } +*/ // Sticky commentary sidebar ------------------------------ -- cgit 1.3.0-6-gf8a5 From 4c319007bdf151064ffed7d275001414b95f24d6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 15:48:10 -0400 Subject: client: add basic tooltip focus behavior --- src/static/client3.js | 135 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 57922022..57bc21a8 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -361,6 +361,8 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { normalHoverInfoDelay: 400, fastHoveringInfoDelay: 150, + focusInfoDelay: 750, + endFastHoveringDelay: 500, hideTooltipDelay: 500, @@ -374,10 +376,13 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // These are common across all tooltips, rather than stored individually, // based on the principles that 1) only a single tooltip can be displayed - // at once, and 2) only a single hoverable can be hovered at a once. + // at once, and 2) likewise, only a single hoverable can be hovered, + // focused, or otherwise active at once. hoverTimeout: null, + focusTimeout: null, hideTimeout: null, currentlyShownTooltip: null, + currentlyActiveHoverable: null, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -413,6 +418,19 @@ function registerTooltipElement(tooltip) { tooltip.addEventListener('mouseleave', () => { handleTooltipMouseLeft(tooltip); }); + + tooltip.addEventListener('focusin', () => { + handleTooltipReceivedFocus(tooltip); + }); + + tooltip.addEventListener('focusout', event => { + // This event gets activated for tabbing *between* links inside the + // tooltip, which is no good and certainly doesn't represent the focus + // leaving the tooltip. + if (tooltip.contains(event.relatedTarget)) return; + + handleTooltipLostFocus(tooltip); + }); } // Adds DOM event listeners, so must be called during addPageListeners step. @@ -440,6 +458,14 @@ function registerTooltipHoverableElement(hoverable, tooltip) { hoverable.addEventListener('mouseleave', () => { handleTooltipHoverableMouseLeft(hoverable); }); + + hoverable.addEventListener('focusin', () => { + handleTooltipHoverableReceivedFocus(hoverable); + }); + + hoverable.addEventListener('focusout', () => { + handleTooltipHoverableLostFocus(hoverable); + }); } function handleTooltipMouseEntered(tooltip) { @@ -455,7 +481,7 @@ function handleTooltipMouseEntered(tooltip) { } function handleTooltipMouseLeft(tooltip) { - const {state, settings} = hoverableTooltipInfo; + const {settings, state} = hoverableTooltipInfo; if (state.currentlyShownTooltip !== tooltip) return; @@ -470,6 +496,34 @@ function handleTooltipMouseLeft(tooltip) { } } +function handleTooltipReceivedFocus(tooltip) { + const {state} = hoverableTooltipInfo; + + // Cancel the tooltip-hiding timeout if it exists. The tooltip will never + // be hidden while it contains the focus anyway, but this ensures the timeout + // will be suitably reset when the tooltip loses focus. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipLostFocus(tooltip) { + const {settings, state} = hoverableTooltipInfo; + + // Start timing out the current tooltip when it loses focus. This will be + // canceled if the tooltip receives focus again. Another tooltip might also + // display before this timeout runs, but since this is the same timeout name + // as all tooltip interactions, it'll get cleared appropriately. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + function handleTooltipHoverableMouseEntered(hoverable) { const {event, settings, state} = hoverableTooltipInfo; @@ -502,7 +556,7 @@ function handleTooltipHoverableMouseEntered(hoverable) { } function handleTooltipHoverableMouseLeft(hoverable) { - const {state, settings} = hoverableTooltipInfo; + const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip when not over a hoverable! if (state.hoverTimeout) { @@ -532,26 +586,93 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } +function handleTooltipHoverableReceivedFocus(hoverable) { + const {settings, state} = hoverableTooltipInfo; + + // Start a timer to show the corresponding tooltip. + state.focusTimeout = + setTimeout(() => { + state.focusTimeout = null; + showTooltipFromHoverable(hoverable); + }, settings.focusInfoDelay); +} + +function handleTooltipHoverableLostFocus(hoverable) { + const {settings, state} = hoverableTooltipInfo; + + // Don't show a tooltip from focusing a hoverable if it isn't focused + // anymore! If another hoverable is receiving focus, that will be evaluated + // and set its own focus timeout after we clear the previous one here. + if (state.focusTimeout) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + } + + // Start timing out the current tooltip when the hoverable loses focus. + // Yes, even if focus is going *into* that very tooltip! This timeout will + // be immediately canceled, in that case. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function currentlyShownTooltipHasFocus() { + const {state} = hoverableTooltipInfo; + + const { + currentlyShownTooltip: tooltip, + currentlyActiveHoverable: hoverable, + } = state; + + // If there's no tooltip, it can't possibly have focus. + if (!tooltip) return false; + + // If the tooltip literally contains (or is) the focused element, then that's + // the principle condition we're looking for. + if (tooltip.contains(document.activeElement)) return true; + + // If the hoverable *which opened the tooltip* is focused, then that also + // represents the tooltip being focused (in its currently shown state). + if (hoverable.contains(document.activeElement)) return true; + + return false; +} + function hideCurrentlyShownTooltip() { const {event, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; - if (!tooltip) return; + // If there was no tooltip to begin with, we're functionally in the desired + // state already, so return true. + if (!tooltip) return true; - dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + // Never hide the tooltip if it's focused. + if (currentlyShownTooltipHasFocus()) return false; state.currentlyShownTooltip = null; + state.currentlyActiveHoverable = null; + + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + + return true; } function showTooltipFromHoverable(hoverable) { const {event, state} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); - hideCurrentlyShownTooltip(); + if (!hideCurrentlyShownTooltip()) return false; + + state.currentlyShownTooltip = tooltip; + state.currentlyActiveHoverable = hoverable; dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); - state.currentlyShownTooltip = tooltip; + return true; } // Data & info card --------------------------------------- -- cgit 1.3.0-6-gf8a5 From 7a234a0b80f5db5d84388f661f473b561b2b0953 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 11 Nov 2023 17:42:40 -0400 Subject: client: more specialized tooltip focus behavior --- src/static/client3.js | 95 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 57bc21a8..d4e47f0a 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -358,13 +358,26 @@ if ( const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { settings: { + // Hovering has two speed settings. The normal setting is used by default, + // and once a tooltip is displayed as a result of hover, the entire tooltip + // system will enter a "fast hover mode" - hovering will activate tooltips + // sooner. "Fast hover mode" is disabled after a sustained duration of not + // hovering over any hoverables; it's meant only to accelerate switching + // tooltips while still deciding, or getting a quick overview across more + // than one tooltip. normalHoverInfoDelay: 400, fastHoveringInfoDelay: 150, + endFastHoveringDelay: 500, + // Focusing has a single speed setting, which is how long it will take to + // enter a functional "focus mode" (though it's not actually implemented + // in terms of this state). As soon as "focus mode" is entered, the tooltip + // for the current hoverable is displayed, and focusing another hoverable + // will cause the current tooltip to be swapped for that one immediately. + // "Focus mode" ends as soon as anything apart from a tooltip or hoverable + // is focused, and it will be necessary to wait on this delay again. focusInfoDelay: 750, - endFastHoveringDelay: 500, - hideTooltipDelay: 500, }, @@ -383,6 +396,7 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { hideTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, + tooltipWasJustHidden: false, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -419,17 +433,17 @@ function registerTooltipElement(tooltip) { handleTooltipMouseLeft(tooltip); }); - tooltip.addEventListener('focusin', () => { - handleTooltipReceivedFocus(tooltip); + tooltip.addEventListener('focusin', event => { + handleTooltipReceivedFocus(tooltip, event.relatedTarget); }); tooltip.addEventListener('focusout', event => { // This event gets activated for tabbing *between* links inside the // tooltip, which is no good and certainly doesn't represent the focus // leaving the tooltip. - if (tooltip.contains(event.relatedTarget)) return; + if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; - handleTooltipLostFocus(tooltip); + handleTooltipLostFocus(tooltip, event.relatedTarget); }); } @@ -459,12 +473,12 @@ function registerTooltipHoverableElement(hoverable, tooltip) { handleTooltipHoverableMouseLeft(hoverable); }); - hoverable.addEventListener('focusin', () => { - handleTooltipHoverableReceivedFocus(hoverable); + hoverable.addEventListener('focusin', event => { + handleTooltipHoverableReceivedFocus(hoverable, event.relatedTarget); }); - hoverable.addEventListener('focusout', () => { - handleTooltipHoverableLostFocus(hoverable); + hoverable.addEventListener('focusout', event => { + handleTooltipHoverableLostFocus(hoverable, event.relatedTarget); }); } @@ -508,20 +522,11 @@ function handleTooltipReceivedFocus(tooltip) { } } -function handleTooltipLostFocus(tooltip) { +function handleTooltipLostFocus(tooltip, newlyFocusedElement) { const {settings, state} = hoverableTooltipInfo; - // Start timing out the current tooltip when it loses focus. This will be - // canceled if the tooltip receives focus again. Another tooltip might also - // display before this timeout runs, but since this is the same timeout name - // as all tooltip interactions, it'll get cleared appropriately. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); - } + // Hide the current tooltip right away when it loses focus. + hideCurrentlyShownTooltip(); } function handleTooltipHoverableMouseEntered(hoverable) { @@ -586,18 +591,30 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } -function handleTooltipHoverableReceivedFocus(hoverable) { +function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement) { const {settings, state} = hoverableTooltipInfo; - // Start a timer to show the corresponding tooltip. + // By default, display the corresponding tooltip after a delay. + state.focusTimeout = setTimeout(() => { state.focusTimeout = null; showTooltipFromHoverable(hoverable); }, settings.focusInfoDelay); + + // If a tooltip was just hidden - which is almost certainly a result of the + // focus changing - then display this tooltip immediately, canceling the + // above timeout. + + if (state.tooltipWasJustHidden) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + + showTooltipFromHoverable(hoverable); + } } -function handleTooltipHoverableLostFocus(hoverable) { +function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip from focusing a hoverable if it isn't focused @@ -608,19 +625,15 @@ function handleTooltipHoverableLostFocus(hoverable) { state.focusTimeout = null; } - // Start timing out the current tooltip when the hoverable loses focus. - // Yes, even if focus is going *into* that very tooltip! This timeout will - // be immediately canceled, in that case. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); + // Unless focus is entering the tooltip itself, hide the tooltip immediately. + // This will set the tooltipWasJustHidden flag, which is detected by a newly + // focused hoverable, if applicable. + if (!currentlyShownTooltipHasFocus(newlyFocusedElement)) { + hideCurrentlyShownTooltip(); } } -function currentlyShownTooltipHasFocus() { +function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { const {state} = hoverableTooltipInfo; const { @@ -633,11 +646,11 @@ function currentlyShownTooltipHasFocus() { // If the tooltip literally contains (or is) the focused element, then that's // the principle condition we're looking for. - if (tooltip.contains(document.activeElement)) return true; + if (tooltip.contains(focusElement)) return true; // If the hoverable *which opened the tooltip* is focused, then that also // represents the tooltip being focused (in its currently shown state). - if (hoverable.contains(document.activeElement)) return true; + if (hoverable.contains(focusElement)) return true; return false; } @@ -656,6 +669,12 @@ function hideCurrentlyShownTooltip() { state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; + // Set this for one tick of the event cycle. + state.tooltipWasJustHidden = true; + setTimeout(() => { + state.tooltipWasJustHidden = false; + }); + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); return true; @@ -670,6 +689,8 @@ function showTooltipFromHoverable(hoverable) { state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; + state.tooltipWasJustHidden = false; + dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); return true; -- cgit 1.3.0-6-gf8a5 From db44a5ea5fd8cb3be1d491687acb64eba966abea Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 10:59:17 -0400 Subject: client: most of touch implementation for tooltips --- src/static/client3.js | 195 +++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 105 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index d4e47f0a..5cc34461 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -393,10 +393,12 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // focused, or otherwise active at once. hoverTimeout: null, focusTimeout: null, + touchTimeout: null, hideTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, tooltipWasJustHidden: false, + hoverableWasRecentlyTouched: false, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -474,11 +476,19 @@ function registerTooltipHoverableElement(hoverable, tooltip) { }); hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event.relatedTarget); + handleTooltipHoverableReceivedFocus(hoverable, event); }); hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event.relatedTarget); + handleTooltipHoverableLostFocus(hoverable, event); + }); + + hoverable.addEventListener('touchend', event => { + handleTooltipHoverableTouchEnded(hoverable, event); + }); + + hoverable.addEventListener('click', event => { + handleTooltipHoverableClicked(hoverable, event); }); } @@ -522,7 +532,7 @@ function handleTooltipReceivedFocus(tooltip) { } } -function handleTooltipLostFocus(tooltip, newlyFocusedElement) { +function handleTooltipLostFocus(tooltip) { const {settings, state} = hoverableTooltipInfo; // Hide the current tooltip right away when it loses focus. @@ -591,7 +601,7 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } -function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement) { +function handleTooltipHoverableReceivedFocus(hoverable) { const {settings, state} = hoverableTooltipInfo; // By default, display the corresponding tooltip after a delay. @@ -614,7 +624,7 @@ function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement } } -function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { +function handleTooltipHoverableLostFocus(hoverable, domEvent) { const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip from focusing a hoverable if it isn't focused @@ -628,11 +638,63 @@ function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { // Unless focus is entering the tooltip itself, hide the tooltip immediately. // This will set the tooltipWasJustHidden flag, which is detected by a newly // focused hoverable, if applicable. - if (!currentlyShownTooltipHasFocus(newlyFocusedElement)) { + if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { hideCurrentlyShownTooltip(); } } +function handleTooltipHoverableTouchEnded(hoverable, domEvent) { + const {settings, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't proceed if this hoverable's tooltip is already visible - in that + // case touching the hoverable again should behave just like a normal click. + if (state.currentlyShownTooltip === tooltip) return; + + const touchEndedOverHoverable = + Array.from(domEvent.changedTouches) + .some(touch => + hoverable.contains( + document.elementFromPoint(touch.clientX, touch.clientY))); + + if (!touchEndedOverHoverable) { + return; + } + + if (state.touchTimeout) { + clearTimeout(state.touchTimeout); + state.touchTimeout = null; + } + + // Show the tooltip right away. + showTooltipFromHoverable(hoverable); + + // Set a state, for a brief but not instantaneous period, indicating that a + // hoverable was recently touched. The touchend event may precede the click + // event by some time, and we don't want to navigate away from the page as + // a result of the click event which this touch precipitated. + state.hoverableWasRecentlyTouched = true; + state.touchTimeout = + setTimeout(() => { + state.hoverableWasRecentlyTouched = false; + }, 250); +} + +function handleTooltipHoverableClicked(hoverable, domEvent) { + const {state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't navigate away from the page if the this hoverable was recently + // touched (and had its tooltip activated). That flag won't be set if its + // tooltip was already open before the touch. + if ( + state.currentlyActiveHoverable === hoverable && + state.hoverableWasRecentlyTouched + ) { + event.preventDefault(); + } +} + function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { const {state} = hoverableTooltipInfo; @@ -696,6 +758,28 @@ function showTooltipFromHoverable(hoverable) { return true; } +function addHoverableTooltipPageListeners() { + document.body.addEventListener('touchend', domEvent => { + const {state} = hoverableTooltipInfo; + + const touches = [...domEvent.changedTouches, ...domEvent.touches]; + const hoverables = Array.from(state.registeredHoverables.keys()); + + // TODO: https://github.com/tc39/proposal-iterator-helpers + const anyTouchOverAnyHoverable = + touches.some(({clientX, clientY}) => { + const element = document.elementFromPoint(clientX, clientY); + return hoverables.some(hoverable => hoverable.contains(element)); + }); + + if (!anyTouchOverAnyHoverable) { + hideCurrentlyShownTooltip(); + } + }); +} + +clientSteps.addPageListeners.push(addHoverableTooltipPageListeners); + // Data & info card --------------------------------------- /* @@ -864,53 +948,6 @@ const infoCard = (() => { }; })(); -function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout( - () => { - fastHover = true; - infoCard.show(type, evt.target); - }, - fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); - - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; - - infoCard.cancelHide(); - }, - - mouseleave() { - clearTimeout(hoverTimeout); - - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } - - infoCard.readyHide(); - }, - }; -} - -const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track'), -}; - -function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [eventName, handler] of Object.entries( - infoCardLinkHandlers[type] - )) { - a.addEventListener(eventName, handler); - } - } -} - // Info cards are disa8led for now since they aren't quite ready for release, // 8ut you can try 'em out 8y setting this localStorage flag! // @@ -1793,58 +1830,6 @@ for (const info of linkIconTooltipInfo) { info.mainLink.addEventListener('blur', () => { requestAnimationFrame(considerHiding); }); - - // Touch (finger) - - let justTouched = false; - let touchTimeout; - - info.mainLink.addEventListener('touchend', event => { - let wasTarget = false; - - for (const touch of event.changedTouches) { - if (touch.target === info.mainLink) { - wasTarget = true; - break; - } - } - - if (!wasTarget) { - return; - } - - justTouched = true; - - clearTimeout(touchTimeout); - touchTimeout = setTimeout(() => { - justTouched = false; - }, 250); - - show(); - }); - - info.mainLink.addEventListener('click', event => { - if (hidden && justTouched) { - event.preventDefault(); - event.target.focus(); - show(); - } - }); - - document.body.addEventListener('touchend', event => { - const touches = [...event.changedTouches, ...event.touches]; - for (const {clientX, clientY} of touches) { - const touchEl = document.elementFromPoint(clientX, clientY); - if (!touchEl) continue; - - for (const hoverEl of hoverElements) { - if (touchEl === hoverEl) return; - if (hoverEl.contains(touchEl)) return; - } - } - - hide(); - }); } */ -- cgit 1.3.0-6-gf8a5 From 2d31d6daa66d711a6dc22b84ec0d4b79d776c4ba Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 11:29:45 -0400 Subject: client: avoid processing touch events related to scrolling --- src/static/client3.js | 76 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 5cc34461..db9e5505 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -406,6 +406,15 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // it will never be turned off while idling over a hoverable. fastHovering: false, endFastHoveringTimeout: false, + + // These track the identifiers of current touches and a record of current + // identifiers that are "banished" by scrolling - that is, touches which + // existed while the page scrolled and were probably responsible for that + // scrolling. This is a bit loose (we can't actually tell which touches + // caused the page to scroll) but it's intended to keep scrolling the page + // from causing the current tooltip to be hidden. + currentTouchIdentifiers: new Set(), + touchIdentifiersBanishedByScrolling: new Set(), }, event: { @@ -651,13 +660,25 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // case touching the hoverable again should behave just like a normal click. if (state.currentlyShownTooltip === tooltip) return; - const touchEndedOverHoverable = - Array.from(domEvent.changedTouches) - .some(touch => - hoverable.contains( - document.elementFromPoint(touch.clientX, touch.clientY))); + const endedTouches = Array.from(domEvent.changedTouches); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + const unbanishedTouches = + endedTouches.filter(touch => + !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + + if (empty(unbanishedTouches)) return; - if (!touchEndedOverHoverable) { + // Don't proceed if none of the (just-ended) touches ended over the + // hoverable. + const anyTouchEndedOverHoverable = + unbanishedTouches.some(touch => + hoverable.contains( + document.elementFromPoint(touch.clientX, touch.clientY))); + + if (!anyTouchEndedOverHoverable) { return; } @@ -759,15 +780,54 @@ function showTooltipFromHoverable(hoverable) { } function addHoverableTooltipPageListeners() { + document.body.addEventListener('touchstart', domEvent => { + const {state} = hoverableTooltipInfo; + for (const {identifier} of domEvent.changedTouches) { + state.currentTouchIdentifiers.add(identifier); + } + }); + + window.addEventListener('scroll', domEvent => { + const {state} = hoverableTooltipInfo; + for (const identifier of state.currentTouchIdentifiers) { + state.touchIdentifiersBanishedByScrolling.add(identifier); + } + }); + + document.body.addEventListener('touchend', domEvent => { + const {state} = hoverableTooltipInfo; + + const identifiers = + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier); + + setTimeout(() => { + for (const identifier of identifiers) { + state.currentTouchIdentifiers.delete(identifier); + state.touchIdentifiersBanishedByScrolling.delete(identifier); + } + }); + }); + document.body.addEventListener('touchend', domEvent => { const {state} = hoverableTooltipInfo; - const touches = [...domEvent.changedTouches, ...domEvent.touches]; const hoverables = Array.from(state.registeredHoverables.keys()); + const endedTouches = Array.from(domEvent.changedTouches); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + const unbanishedTouches = + endedTouches.filter(touch => + !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + + if (empty(unbanishedTouches)) return; + // TODO: https://github.com/tc39/proposal-iterator-helpers const anyTouchOverAnyHoverable = - touches.some(({clientX, clientY}) => { + unbanishedTouches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); return hoverables.some(hoverable => hoverable.contains(element)); }); -- cgit 1.3.0-6-gf8a5 From 5127fd36dcf5987f402cce0353768b1421d9b7b4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 11:34:43 -0400 Subject: client, css: minor tooltip fixes --- src/static/client3.js | 10 ++++++---- src/static/site5.css | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index db9e5505..88df58de 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -813,6 +813,7 @@ function addHoverableTooltipPageListeners() { const {state} = hoverableTooltipInfo; const hoverables = Array.from(state.registeredHoverables.keys()); + const tooltips = Array.from(state.registeredTooltips.keys()); const endedTouches = Array.from(domEvent.changedTouches); @@ -825,14 +826,15 @@ function addHoverableTooltipPageListeners() { if (empty(unbanishedTouches)) return; - // TODO: https://github.com/tc39/proposal-iterator-helpers - const anyTouchOverAnyHoverable = + const anyTouchOverAnyHoverableOrTooltip = unbanishedTouches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); - return hoverables.some(hoverable => hoverable.contains(element)); + if (hoverables.some(el => el.contains(element))) return true; + if (tooltips.some(el => el.contains(element))) return true; + return false; }); - if (!anyTouchOverAnyHoverable) { + if (!anyTouchOverAnyHoverableOrTooltip) { hideCurrentlyShownTooltip(); } }); diff --git a/src/static/site5.css b/src/static/site5.css index 06696799..582681bb 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -489,7 +489,7 @@ a:not([href]):hover { .icons-tooltip { position: absolute; - z-index: 999; + z-index: 1; left: -12px; top: calc(1em - 2px); padding: 4px 12px 6px 8px; -- cgit 1.3.0-6-gf8a5 From 834a087643306090905a2c2f080324b1100c0710 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 12:28:04 -0400 Subject: client: tooltip touch syntax cleanup --- src/static/client3.js | 49 ++++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 88df58de..9db9fc6c 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -660,21 +660,22 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // case touching the hoverable again should behave just like a normal click. if (state.currentlyShownTooltip === tooltip) return; - const endedTouches = Array.from(domEvent.changedTouches); + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); // Don't process touch events that were "banished" because the page was // scrolled while those touches were active, and most likely as a result of // them. - const unbanishedTouches = - endedTouches.filter(touch => - !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); - if (empty(unbanishedTouches)) return; + if (empty(touches)) return; // Don't proceed if none of the (just-ended) touches ended over the // hoverable. const anyTouchEndedOverHoverable = - unbanishedTouches.some(touch => + touches.some(touch => hoverable.contains( document.elementFromPoint(touch.clientX, touch.clientY))); @@ -780,29 +781,28 @@ function showTooltipFromHoverable(hoverable) { } function addHoverableTooltipPageListeners() { + const {state} = hoverableTooltipInfo; + + const getTouchIdentifiers = domEvent => + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier) + .filter(identifier => typeof identifier !== 'undefined'); + document.body.addEventListener('touchstart', domEvent => { - const {state} = hoverableTooltipInfo; - for (const {identifier} of domEvent.changedTouches) { + for (const identifier of getTouchIdentifiers(domEvent)) { state.currentTouchIdentifiers.add(identifier); } }); - window.addEventListener('scroll', domEvent => { - const {state} = hoverableTooltipInfo; + window.addEventListener('scroll', () => { for (const identifier of state.currentTouchIdentifiers) { state.touchIdentifiersBanishedByScrolling.add(identifier); } }); document.body.addEventListener('touchend', domEvent => { - const {state} = hoverableTooltipInfo; - - const identifiers = - Array.from(domEvent.changedTouches) - .map(touch => touch.identifier); - setTimeout(() => { - for (const identifier of identifiers) { + for (const identifier of getTouchIdentifiers(domEvent)) { state.currentTouchIdentifiers.delete(identifier); state.touchIdentifiersBanishedByScrolling.delete(identifier); } @@ -810,24 +810,23 @@ function addHoverableTooltipPageListeners() { }); document.body.addEventListener('touchend', domEvent => { - const {state} = hoverableTooltipInfo; - const hoverables = Array.from(state.registeredHoverables.keys()); const tooltips = Array.from(state.registeredTooltips.keys()); - const endedTouches = Array.from(domEvent.changedTouches); + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); // Don't process touch events that were "banished" because the page was // scrolled while those touches were active, and most likely as a result of // them. - const unbanishedTouches = - endedTouches.filter(touch => - !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); - if (empty(unbanishedTouches)) return; + if (empty(touches)) return; const anyTouchOverAnyHoverableOrTooltip = - unbanishedTouches.some(({clientX, clientY}) => { + touches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); if (hoverables.some(el => el.contains(element))) return true; if (tooltips.some(el => el.contains(element))) return true; -- cgit 1.3.0-6-gf8a5 From d443e32d044dd74cd1923e3538af0a63ff6c6835 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 15:01:22 -0400 Subject: css: quick tooltip tweaks --- src/static/site5.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/static/site5.css b/src/static/site5.css index 582681bb..8c2b07a1 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -506,8 +506,12 @@ a:not([href]):hover { border: 1px dotted var(--primary-color); border-radius: 4px; + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + -webkit-user-select: none; - user-select: none; + user-select: none; + cursor: default; } -- cgit 1.3.0-6-gf8a5 From a1d50400b858e40471bc1bb78408d69d39907c5f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 20:05:48 -0400 Subject: content: generateContributionList: use tooltip style contrib icons --- src/content/dependencies/generateContributionList.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js index 731cfba5..6401e65e 100644 --- a/src/content/dependencies/generateContributionList.js +++ b/src/content/dependencies/generateContributionList.js @@ -16,5 +16,6 @@ export default { showIcons: true, showContribution: true, preventWrapping: false, + iconMode: 'tooltip', })))), }; -- cgit 1.3.0-6-gf8a5 From 0202375db8ccd03d98ed6c2ffbb800b67c026639 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 09:16:23 -0400 Subject: content, css: vertical tooltips + basic external parsing --- src/content/dependencies/linkContribution.js | 14 +- src/content/dependencies/linkExternalAsIcon.js | 265 +++++++++++++++++++++---- src/static/site5.css | 36 +++- src/strings-default.yaml | 24 +-- 4 files changed, 272 insertions(+), 67 deletions(-) diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index 5bc398de..ef61c766 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -1,15 +1,8 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'linkArtist', - 'linkExternalAsIcon', - ], - - extraDependencies: [ - 'html', - 'language', - ], + contentDependencies: ['linkArtist', 'linkExternalAsIcon'], + extraDependencies: ['html', 'language'], relations(relation, contribution) { const relations = {}; @@ -85,7 +78,8 @@ export default { [html.joinChildren]: '', class: 'icons-tooltip-content', }, - relations.artistIcons)), + relations.artistIcons + .map(icon => icon.slot('withText', true)))), ]; } diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js index cd168992..d3ed9122 100644 --- a/src/content/dependencies/linkExternalAsIcon.js +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -1,6 +1,202 @@ -// TODO: Define these as extra dependencies and pass them somewhere -const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; -const MASTODON_DOMAINS = ['types.pl']; +import {stitchArrays} from '#sugar'; + +const fallbackDescriptor = { + icon: 'globe', + string: 'external', + + normal: 'domain', + compact: 'domain', +}; + +// TODO: Define all this stuff in data! +const externalSpec = [ + { + matchDomain: 'bandcamp.com', + + icon: 'bandcamp', + string: 'bandcamp', + + compact: 'handle', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomains: ['bc.s3m.us', 'music.solatrux.com'], + + icon: 'bandcamp', + string: 'bandcamp', + + normal: 'domain', + compact: 'domain', + }, + + { + matchDomains: ['types.pl'], + + icon: 'mastodon', + string: 'mastodon', + + compact: 'domain', + }, + + { + matchDomains: ['youtube.com', 'youtu.be'], + + icon: 'youtube', + string: 'youtube', + + compact: 'handle', + + handle: { + pathname: /^(@.*?)\/?$/, + }, + }, + + { + matchDomain: 'soundcloud.com', + + icon: 'soundcloud', + string: 'soundcloud', + + compact: 'handle', + + handle: /[^/]*\/?$/, + }, + + { + matchDomain: 'tumblr.com', + + icon: 'tumblr', + string: 'tumblr', + + compact: 'handle', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomain: 'twitter.com', + + icon: 'twitter', + string: 'twitter', + + compact: 'handle', + + handle: { + prefix: '@', + pathname: /^@?.*\/?$/, + }, + }, + + { + matchDomain: 'deviantart.com', + + icon: 'deviantart', + string: 'deviantart', + }, + + { + matchDomain: 'instagram.com', + + icon: 'instagram', + string: 'instagram', + }, + + { + matchDomain: 'newgrounds.com', + + icon: 'newgrounds', + string: 'newgrounds', + }, +]; + +function determineLinkText(url, descriptor, {language}) { + const prefix = 'misc.external'; + + const { + hostname: domain, + pathname, + } = new URL(url); + + let normal = null; + let compact = null; + + const place = language.$(prefix, descriptor.string); + + if (descriptor.normal === 'domain') { + normal = language.$(prefix, 'withDomain', {place, domain}); + } + + if (descriptor.compact === 'domain') { + compact = domain.replace(/^www\./, ''); + } + + let handle = null; + + if (descriptor.handle) { + let regexen = []; + let tests = []; + + let handlePrefix = ''; + + if (descriptor.handle instanceof RegExp) { + regexen.push(descriptor.handle); + tests.push(url); + } else { + for (const [key, value] of Object.entries(descriptor.handle)) { + switch (key) { + case 'prefix': + handlePrefix = value; + continue; + + case 'url': + tests.push(url); + break; + + case 'domain': + case 'hostname': + tests.push(domain); + break; + + case 'path': + case 'pathname': + tests.push(pathname.slice(1)); + break; + + default: + tests.push(''); + break; + } + + regexen.push(value); + } + } + + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + handle = handlePrefix + (match[1] ?? match[0]); + break; + } + } + } + + if (descriptor.compact === 'handle') { + compact = handle; + } + + if (normal === 'handle' && handle) { + normal = language.$(prefix, 'withHandle', {place, handle}); + } + + normal ??= language.$(prefix, descriptor.string); + + return {normal, compact}; +} export default { extraDependencies: ['html', 'language', 'to'], @@ -9,38 +205,39 @@ export default { return {url}; }, - generate(data, {html, language, to}) { - const domain = new URL(data.url).hostname; - const [id, msg] = ( - domain.includes('bandcamp.com') - ? ['bandcamp', language.$('misc.external.bandcamp')] - : BANDCAMP_DOMAINS.includes(domain) - ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] - : MASTODON_DOMAINS.includes(domain) - ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] - : domain.includes('youtu') - ? ['youtube', language.$('misc.external.youtube')] - : domain.includes('soundcloud') - ? ['soundcloud', language.$('misc.external.soundcloud')] - : domain.includes('tumblr.com') - ? ['tumblr', language.$('misc.external.tumblr')] - : domain.includes('twitter.com') - ? ['twitter', language.$('misc.external.twitter')] - : domain.includes('deviantart.com') - ? ['deviantart', language.$('misc.external.deviantart')] - : domain.includes('instagram.com') - ? ['instagram', language.$('misc.external.bandcamp')] - : domain.includes('newgrounds.com') - ? ['newgrounds', language.$('misc.external.newgrounds')] - : ['globe', language.$('misc.external.domain', {domain})]); + slots: { + withText: {type: 'boolean'}, + }, + + generate(data, slots, {html, language, to}) { + const {hostname: domain} = new URL(data.url); + + const descriptor = + externalSpec.find(({matchDomain, matchDomains}) => { + const compare = d => domain.includes(d); + if (matchDomain && compare(matchDomain)) return true; + if (matchDomains && matchDomains.some(compare)) return true; + return false; + }) ?? fallbackDescriptor; + + const {normal: normalText, compact: compactText} = + determineLinkText(data.url, descriptor, {language}); return html.tag('a', - {href: data.url, class: 'icon'}, - html.tag('svg', [ - html.tag('title', msg), - html.tag('use', { - href: to('shared.staticIcon', id), - }), - ])); + {href: data.url, class: ['icon', slots.withText && 'has-text']}, + [ + html.tag('svg', [ + !slots.withText && + html.tag('title', normalText), + + html.tag('use', { + href: to('shared.staticIcon', descriptor.icon), + }), + ]), + + slots.withText && + html.tag('span', {class: 'icon-text'}, + compactText ?? normalText), + ]); }, }; diff --git a/src/static/site5.css b/src/static/site5.css index 8c2b07a1..c1dfff82 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -477,7 +477,7 @@ a:not([href]):hover { position: relative; } -.contribution.has-tooltip a { +.contribution.has-tooltip > a { text-decoration: underline; text-decoration-style: dotted; } @@ -489,8 +489,8 @@ a:not([href]):hover { .icons-tooltip { position: absolute; - z-index: 1; - left: -12px; + z-index: 3; + left: -36px; top: calc(1em - 2px); padding: 4px 12px 6px 8px; } @@ -504,14 +504,21 @@ a:not([href]):hover { padding: 6px 2px 2px 2px; background: var(--bg-black-color); border: 1px dotted var(--primary-color); - border-radius: 4px; + border-radius: 6px; - -webkit-backdrop-filter: blur(2px); - backdrop-filter: blur(2px); + -webkit-backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); -webkit-user-select: none; user-select: none; + box-shadow: + 0 3px 4px 4px #000000aa, + 0 -2px 4px -2px var(--primary-color) inset; + cursor: default; } @@ -538,6 +545,23 @@ a:not([href]):hover { fill: var(--primary-color); } +.icon.has-text { + display: block; + width: unset; + height: 1.4em; +} + +.icon.has-text > svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.icon.has-text > .icon-text { + margin-left: 24px; + padding-right: 8px; +} + .rerelease, .other-group-accent { opacity: 0.7; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index e6b8d6db..9fdf0182 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -404,19 +404,15 @@ misc: # wiki - sorry! external: + external: "External" - # domain: - # General domain when one the URL doesn't match one of the - # sites below. + withDomain: + "{PLACE} ({DOMAIN})" - domain: "External ({DOMAIN})" - - # local: - # Files which are locally available on the wiki (under its media - # directory). + withHandle: + "{PLACE} ({HANDLE})" local: "Wiki Archive (local upload)" - deviantart: "DeviantArt" instagram: "Instagram" newgrounds: "Newgrounds" @@ -427,14 +423,8 @@ misc: tumblr: "Tumblr" twitter: "Twitter" wikipedia: "Wikipedia" - - bandcamp: - _: "Bandcamp" - domain: "Bandcamp ({DOMAIN})" - - mastodon: - _: "Mastodon" - domain: "Mastodon ({DOMAIN})" + bandcamp: "Bandcamp" + mastodon: "Mastodon" youtube: _: "YouTube" -- cgit 1.3.0-6-gf8a5 From 8b81a3aa4e266548ef2c8083391f6bb859915133 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:22:40 -0400 Subject: sugar: fix async decorateError not providing calling arguments --- src/util/sugar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/sugar.js b/src/util/sugar.js index 9646be37..3f0eb2ea 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -685,7 +685,7 @@ export function asyncAdaptiveDecorateError(fn, callback) { try { return await fn(...args); } catch (caughtError) { - throw callback(caughtError); + throw callback(caughtError, ...args); } }; -- cgit 1.3.0-6-gf8a5 From 82bb115d8404b88fe8b1af1bf714b3c70969f99b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:23:58 -0400 Subject: css: allow custom scroll margin offset per-element --- src/static/site5.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/static/site5.css b/src/static/site5.css index c1dfff82..fb3cf057 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1380,6 +1380,10 @@ h3.content-heading { /* Sticky heading */ +[id] { + --custom-scroll-offset: 0px; +} + #content [id] { /* Adjust scroll margin. */ scroll-margin-top: calc( @@ -1387,6 +1391,7 @@ h3.content-heading { + 33px /* Sticky subheading */ - 1em /* One line of text (align bottom) */ - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ ); } -- cgit 1.3.0-6-gf8a5 From 921f2d421d6ffb87fab1a2059a6c313b9c81f4f8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:25:30 -0400 Subject: css: allow sticky heading text to occupy blank area in thin layout --- src/static/site5.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/static/site5.css b/src/static/site5.css index fb3cf057..5a769545 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1836,6 +1836,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content z-index: 2; } + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + /* Disable grid features, just line header children up vertically */ #header { -- cgit 1.3.0-6-gf8a5 From 8f17782a5f2adbafd031b269195879eb7f79e05f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 11:16:48 -0400 Subject: data, content: extract external link parsing into nicer interface --- package.json | 1 + src/content/dependencies/linkExternalAsIcon.js | 223 +----------------- src/data/language.js | 14 +- src/data/things/language.js | 34 ++- src/util/external-links.js | 308 +++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 225 deletions(-) create mode 100644 src/util/external-links.js diff --git a/package.json b/package.json index 194c4060..abf9e1df 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "#content-dependencies": "./src/content/dependencies/index.js", "#content-function": "./src/content-function.js", "#cli": "./src/util/cli.js", + "#external-links": "./src/util/external-links.js", "#find": "./src/find.js", "#html": "./src/util/html.js", "#language": "./src/data/language.js", diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js index d3ed9122..58bd896d 100644 --- a/src/content/dependencies/linkExternalAsIcon.js +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -1,237 +1,28 @@ -import {stitchArrays} from '#sugar'; - -const fallbackDescriptor = { - icon: 'globe', - string: 'external', - - normal: 'domain', - compact: 'domain', -}; - -// TODO: Define all this stuff in data! -const externalSpec = [ - { - matchDomain: 'bandcamp.com', - - icon: 'bandcamp', - string: 'bandcamp', - - compact: 'handle', - - handle: {domain: /^[^.]*/}, - }, - - { - matchDomains: ['bc.s3m.us', 'music.solatrux.com'], - - icon: 'bandcamp', - string: 'bandcamp', - - normal: 'domain', - compact: 'domain', - }, - - { - matchDomains: ['types.pl'], - - icon: 'mastodon', - string: 'mastodon', - - compact: 'domain', - }, - - { - matchDomains: ['youtube.com', 'youtu.be'], - - icon: 'youtube', - string: 'youtube', - - compact: 'handle', - - handle: { - pathname: /^(@.*?)\/?$/, - }, - }, - - { - matchDomain: 'soundcloud.com', - - icon: 'soundcloud', - string: 'soundcloud', - - compact: 'handle', - - handle: /[^/]*\/?$/, - }, - - { - matchDomain: 'tumblr.com', - - icon: 'tumblr', - string: 'tumblr', - - compact: 'handle', - - handle: {domain: /^[^.]*/}, - }, - - { - matchDomain: 'twitter.com', - - icon: 'twitter', - string: 'twitter', - - compact: 'handle', - - handle: { - prefix: '@', - pathname: /^@?.*\/?$/, - }, - }, - - { - matchDomain: 'deviantart.com', - - icon: 'deviantart', - string: 'deviantart', - }, - - { - matchDomain: 'instagram.com', - - icon: 'instagram', - string: 'instagram', - }, - - { - matchDomain: 'newgrounds.com', - - icon: 'newgrounds', - string: 'newgrounds', - }, -]; - -function determineLinkText(url, descriptor, {language}) { - const prefix = 'misc.external'; - - const { - hostname: domain, - pathname, - } = new URL(url); - - let normal = null; - let compact = null; - - const place = language.$(prefix, descriptor.string); - - if (descriptor.normal === 'domain') { - normal = language.$(prefix, 'withDomain', {place, domain}); - } - - if (descriptor.compact === 'domain') { - compact = domain.replace(/^www\./, ''); - } - - let handle = null; - - if (descriptor.handle) { - let regexen = []; - let tests = []; - - let handlePrefix = ''; - - if (descriptor.handle instanceof RegExp) { - regexen.push(descriptor.handle); - tests.push(url); - } else { - for (const [key, value] of Object.entries(descriptor.handle)) { - switch (key) { - case 'prefix': - handlePrefix = value; - continue; - - case 'url': - tests.push(url); - break; - - case 'domain': - case 'hostname': - tests.push(domain); - break; - - case 'path': - case 'pathname': - tests.push(pathname.slice(1)); - break; - - default: - tests.push(''); - break; - } - - regexen.push(value); - } - } - - for (const {regex, test} of stitchArrays({ - regex: regexen, - test: tests, - })) { - const match = test.match(regex); - if (match) { - handle = handlePrefix + (match[1] ?? match[0]); - break; - } - } - } - - if (descriptor.compact === 'handle') { - compact = handle; - } - - if (normal === 'handle' && handle) { - normal = language.$(prefix, 'withHandle', {place, handle}); - } - - normal ??= language.$(prefix, descriptor.string); - - return {normal, compact}; -} - export default { extraDependencies: ['html', 'language', 'to'], - data(url) { - return {url}; - }, + data: (url) => ({url}), slots: { withText: {type: 'boolean'}, }, generate(data, slots, {html, language, to}) { - const {hostname: domain} = new URL(data.url); - - const descriptor = - externalSpec.find(({matchDomain, matchDomains}) => { - const compare = d => domain.includes(d); - if (matchDomain && compare(matchDomain)) return true; - if (matchDomains && matchDomains.some(compare)) return true; - return false; - }) ?? fallbackDescriptor; + const {url} = data; - const {normal: normalText, compact: compactText} = - determineLinkText(data.url, descriptor, {language}); + const normalText = language.formatExternalLink(url, {style: 'normal'}); + const compactText = language.formatExternalLink(url, {style: 'compact'}); + const iconId = language.formatExternalLink(url, {style: 'icon-id'}); return html.tag('a', - {href: data.url, class: ['icon', slots.withText && 'has-text']}, + {href: url, class: ['icon', slots.withText && 'has-text']}, [ html.tag('svg', [ !slots.withText && html.tag('title', normalText), html.tag('use', { - href: to('shared.staticIcon', descriptor.icon), + href: to('shared.staticIcon', iconId), }), ]), diff --git a/src/data/language.js b/src/data/language.js index 3fc14da7..6f774f27 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -7,15 +7,11 @@ import chokidar from 'chokidar'; import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. import yaml from 'js-yaml'; -import T from '#things'; +import {externalLinkSpec} from '#external-links'; import {colors, logWarn} from '#cli'; - -import { - annotateError, - annotateErrorWithFile, - showAggregate, - withAggregate, -} from '#sugar'; +import {annotateError, annotateErrorWithFile, showAggregate, withAggregate} + from '#sugar'; +import T from '#things'; const {Language} = T; @@ -114,6 +110,8 @@ export function initializeLanguageObject() { language.escapeHTML = string => he.encode(string, {useNamedReferences: true}); + language.externalLinkSpec = externalLinkSpec; + return language; } diff --git a/src/data/things/language.js b/src/data/things/language.js index 646eb6d1..185488e2 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,5 +1,11 @@ -import {Tag} from '#html'; import {isLanguageCode} from '#validators'; +import {Tag} from '#html'; + +import { + getExternalLinkStringsFromDescriptors, + isExternalLinkSpec, + isExternalLinkStyle, +} from '#external-links'; import { externalFunction, @@ -72,6 +78,13 @@ export class Language extends Thing { update: {validate: (t) => typeof t === 'object'}, }, + // List of descriptors for providing to external link utilities when using + // language.formatExternalLink - refer to util/external-links.js for info. + externalLinkSpec: { + flags: {update: true, expose: true}, + update: {validate: isExternalLinkSpec}, + }, + // Update only escapeHTML: externalFunction(), @@ -299,6 +312,25 @@ export class Language extends Thing { : duration; } + formatExternalLink(url, {style = 'normal'} = {}) { + if (!this.externalLinkSpec) { + throw new TypeError(`externalLinkSpec unavailable`); + } + + if (style !== 'all') { + isExternalLinkStyle(style); + } + + const results = + getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, this); + + if (style === 'all') { + return results; + } else { + return results[style]; + } + } + formatIndex(value) { this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); diff --git a/src/util/external-links.js b/src/util/external-links.js new file mode 100644 index 00000000..8e1c3ca9 --- /dev/null +++ b/src/util/external-links.js @@ -0,0 +1,308 @@ +import {empty, stitchArrays} from '#sugar'; + +import { + is, + isStringNonEmpty, + optional, + validateArrayItems, + validateInstanceOf, + validateProperties, +} from '#validators'; + +export const externalLinkStyles = [ + 'normal', + 'compact', + 'icon-id', +]; + +export const isExternalLinkStyle = is(...externalLinkStyles); + +// This might need to be adjusted for YAML importing... +const isExternalLinkSpecRegex = + validateInstanceOf(RegExp); + +export const isExternalLinkHandleSpec = + validateProperties({ + prefix: optional(isStringNonEmpty), + + url: optional(isExternalLinkSpecRegex), + + // TODO: Don't allow specifying both of these (they're aliases) + domain: optional(isExternalLinkSpecRegex), + hostname: optional(isExternalLinkSpecRegex), + + // TODO: Don't allow specifying both of these (they're aliases) + path: optional(isExternalLinkSpecRegex), + pathname: optional(isExternalLinkSpecRegex), + }); + +export const isExternalLinkSpec = + validateArrayItems( + validateProperties({ + // TODO: Don't allow providing both of these, and require providing one + matchDomain: optional(isStringNonEmpty), + matchDomains: optional(validateArrayItems(isStringNonEmpty)), + + string: isStringNonEmpty, + + // TODO: Don't allow 'handle' options if handle isn't provided + normal: optional(is('domain', 'handle')), + compact: optional(is('domain', 'handle')), + icon: optional(isStringNonEmpty), + + handle: optional(isExternalLinkHandleSpec), + })); + +export const fallbackDescriptor = { + string: 'external', + + normal: 'domain', + compact: 'domain', + icon: 'globe', +}; + +// TODO: Define all this stuff in data as YAML! +export const externalLinkSpec = [ + { + matchDomain: 'bandcamp.com', + + string: 'bandcamp', + + compact: 'handle', + icon: 'bandcamp', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomains: ['bc.s3m.us', 'music.solatrux.com'], + + icon: 'bandcamp', + string: 'bandcamp', + + normal: 'domain', + compact: 'domain', + }, + + { + matchDomains: ['types.pl'], + + icon: 'mastodon', + string: 'mastodon', + + compact: 'domain', + }, + + { + matchDomains: ['youtube.com', 'youtu.be'], + + icon: 'youtube', + string: 'youtube', + + compact: 'handle', + + handle: { + pathname: /^(@.*?)\/?$/, + }, + }, + + { + matchDomain: 'soundcloud.com', + + icon: 'soundcloud', + string: 'soundcloud', + + compact: 'handle', + + handle: /[^/]*\/?$/, + }, + + { + matchDomain: 'tumblr.com', + + icon: 'tumblr', + string: 'tumblr', + + compact: 'handle', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomain: 'twitter.com', + + icon: 'twitter', + string: 'twitter', + + compact: 'handle', + + handle: { + prefix: '@', + pathname: /^@?.*\/?$/, + }, + }, + + { + matchDomain: 'deviantart.com', + + icon: 'deviantart', + string: 'deviantart', + }, + + { + matchDomain: 'instagram.com', + + icon: 'instagram', + string: 'instagram', + }, + + { + matchDomain: 'newgrounds.com', + + icon: 'newgrounds', + string: 'newgrounds', + }, +]; + +export function getMatchingDescriptorsForExternalLink(url, descriptors) { + const {hostname: domain} = new URL(url); + const compare = d => domain.includes(d); + + const matchingDescriptors = + descriptors.filter(spec => { + if (spec.matchDomain && compare(spec.matchDomain)) return true; + if (spec.matchDomains && spec.matchDomains.some(compare)) return true; + return false; + }); + + return [...matchingDescriptors, fallbackDescriptor]; +} + +export function getExternalLinkStringsFromDescriptor(url, descriptor, language) { + const prefix = 'misc.external'; + + const results = { + 'normal': null, + 'compact': null, + 'icon-id': null, + }; + + const {hostname: domain, pathname} = new URL(url); + + const place = language.$(prefix, descriptor.string); + + if (descriptor.icon) { + results['icon-id'] = descriptor.icon; + } + + if (descriptor.normal === 'domain') { + results['normal'] = language.$(prefix, 'withDomain', {place, domain}); + } + + if (descriptor.compact === 'domain') { + results['compact'] = language.sanitize(domain.replace(/^www\./, '')); + } + + let handle = null; + + if (descriptor.handle) { + let regexen = []; + let tests = []; + + let handlePrefix = ''; + + if (descriptor.handle instanceof RegExp) { + regexen.push(descriptor.handle); + tests.push(url); + } else { + for (const [key, value] of Object.entries(descriptor.handle)) { + switch (key) { + case 'prefix': + handlePrefix = value; + continue; + + case 'url': + tests.push(url); + break; + + case 'domain': + case 'hostname': + tests.push(domain); + break; + + case 'path': + case 'pathname': + tests.push(pathname.slice(1)); + break; + + default: + tests.push(''); + break; + } + + regexen.push(value); + } + } + + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + handle = handlePrefix + (match[1] ?? match[0]); + break; + } + } + } + + if (descriptor.compact === 'handle') { + results.compact = language.sanitize(handle); + } + + if (descriptor.normal === 'handle' && handle) { + results.normal = language.$(prefix, 'withHandle', {place, handle}); + } + + results.normal ??= language.$(prefix, descriptor.string); + + return results; +} + +export function getExternalLinkStringsFromDescriptors(url, descriptors, language) { + const results = { + 'normal': null, + 'compact': null, + 'icon-id': null, + }; + + const remainingKeys = + new Set(Object.keys(results)); + + const matchingDescriptors = + getMatchingDescriptorsForExternalLink(url, descriptors); + + for (const descriptor of matchingDescriptors) { + const descriptorResults = + getExternalLinkStringsFromDescriptor(url, descriptor, language); + + const descriptorKeys = + new Set( + Object.entries(descriptorResults) + .filter(entry => entry[1]) + .map(entry => entry[0])); + + for (const key of remainingKeys) { + if (descriptorKeys.has(key)) { + results[key] = descriptorResults[key]; + remainingKeys.delete(key); + } + } + + if (empty(remainingKeys)) { + return results; + } + } + + return results; +} -- cgit 1.3.0-6-gf8a5 From c5e02f9d314118a534fd0e942d87e74864674498 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 17:42:49 -0400 Subject: content: *mostly* port linkExternal to language.formatExternalLink --- src/content/dependencies/linkExternal.js | 64 ++++---------------------------- src/util/external-links.js | 17 ++++----- 2 files changed, 15 insertions(+), 66 deletions(-) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 5de612e2..7f090084 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,7 +1,3 @@ -// TODO: Define these as extra dependencies and pass them somewhere -const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; -const MASTODON_DOMAINS = ['types.pl']; - export default { extraDependencies: ['html', 'language', 'wikiData'], @@ -27,6 +23,13 @@ export default { }, generate(data, slots, {html, language}) { + return ( + html.tag('a', + {href: data.url, class: 'nowrap'}, + language.formatExternalLink(data.url, {style: 'platform'}))); + }, + + /* let isLocal; let domain; let pathname; @@ -49,25 +52,6 @@ export default { isLocal = true; } - const link = html.tag('a', - { - href: data.url, - class: 'nowrap', - }, - - // truly unhinged indentation here - isLocal - ? language.$('misc.external.local') - - : domain.includes('bandcamp.com') - ? language.$('misc.external.bandcamp') - - : BANDCAMP_DOMAINS.includes(domain) - ? language.$('misc.external.bandcamp.domain', {domain}) - - : MASTODON_DOMAINS.includes(domain) - ? language.$('misc.external.mastodon.domain', {domain}) - : domain.includes('youtu') ? slots.mode === 'album' ? data.url.includes('list=') @@ -75,38 +59,6 @@ export default { : language.$('misc.external.youtube.fullAlbum') : language.$('misc.external.youtube') - : domain.includes('soundcloud') - ? language.$('misc.external.soundcloud') - - : domain.includes('tumblr.com') - ? language.$('misc.external.tumblr') - - : domain.includes('twitter.com') - ? language.$('misc.external.twitter') - - : domain.includes('deviantart.com') - ? language.$('misc.external.deviantart') - - : domain.includes('wikipedia.org') - ? language.$('misc.external.wikipedia') - - : domain.includes('poetryfoundation.org') - ? language.$('misc.external.poetryFoundation') - - : domain.includes('instagram.com') - ? language.$('misc.external.instagram') - - : domain.includes('patreon.com') - ? language.$('misc.external.patreon') - - : domain.includes('spotify.com') - ? language.$('misc.external.spotify') - - : domain.includes('newgrounds.com') - ? language.$('misc.external.newgrounds') - - : domain); - switch (slots.mode) { case 'flash': { const wrap = content => @@ -136,5 +88,5 @@ export default { default: return link; } - } + */ }; diff --git a/src/util/external-links.js b/src/util/external-links.js index 8e1c3ca9..2047a720 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -12,6 +12,7 @@ import { export const externalLinkStyles = [ 'normal', 'compact', + 'platform', 'icon-id', ]; @@ -181,16 +182,15 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors) { export function getExternalLinkStringsFromDescriptor(url, descriptor, language) { const prefix = 'misc.external'; - const results = { - 'normal': null, - 'compact': null, - 'icon-id': null, - }; + const results = + Object.fromEntries(externalLinkStyles.map(style => [style, null])); const {hostname: domain, pathname} = new URL(url); const place = language.$(prefix, descriptor.string); + results['platform'] = place; + if (descriptor.icon) { results['icon-id'] = descriptor.icon; } @@ -270,11 +270,8 @@ export function getExternalLinkStringsFromDescriptor(url, descriptor, language) } export function getExternalLinkStringsFromDescriptors(url, descriptors, language) { - const results = { - 'normal': null, - 'compact': null, - 'icon-id': null, - }; + const results = + Object.fromEntries(externalLinkStyles.map(style => [style, null])); const remainingKeys = new Set(Object.keys(results)); -- cgit 1.3.0-6-gf8a5 From 0ee5269cd196cd14f06aac6c586e7104159eac74 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 17:47:18 -0400 Subject: content: implement "local" links much more rudimentarily --- src/content/dependencies/linkExternal.js | 22 ---------------------- src/util/external-links.js | 8 ++++++++ 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 7f090084..1b81efcc 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -30,28 +30,6 @@ export default { }, /* - let isLocal; - let domain; - let pathname; - - try { - const url = new URL(data.url); - domain = url.hostname; - pathname = url.pathname; - } catch (error) { - // No support for relative local URLs yet, sorry! (I.e, local URLs must - // be absolute relative to the domain name in order to work.) - isLocal = true; - domain = null; - pathname = null; - } - - // isLocal also applies for URLs which match the 'Canonical Base' under - // wiki-info.yaml, if present. - if (data.canonicalDomain && domain === data.canonicalDomain) { - isLocal = true; - } - : domain.includes('youtu') ? slots.mode === 'album' ? data.url.includes('list=') diff --git a/src/util/external-links.js b/src/util/external-links.js index 2047a720..7a34fa9e 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -64,6 +64,14 @@ export const fallbackDescriptor = { // TODO: Define all this stuff in data as YAML! export const externalLinkSpec = [ + { + matchDomain: 'hsmusic.wiki', + + string: 'local', + + icon: 'globe', + }, + { matchDomain: 'bandcamp.com', -- cgit 1.3.0-6-gf8a5 From cf08893d48db6f8082a176f54d0d92cb82716b3a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 18:50:59 -0400 Subject: external-links: general support for page-contextual formatting --- src/data/things/language.js | 16 ++- src/strings-default.yaml | 25 +++-- src/util/external-links.js | 263 +++++++++++++++++++++++++++++++++----------- 3 files changed, 224 insertions(+), 80 deletions(-) diff --git a/src/data/things/language.js b/src/data/things/language.js index 185488e2..f83b4218 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -3,6 +3,7 @@ import {Tag} from '#html'; import { getExternalLinkStringsFromDescriptors, + isExternalLinkContext, isExternalLinkSpec, isExternalLinkStyle, } from '#external-links'; @@ -312,17 +313,22 @@ export class Language extends Thing { : duration; } - formatExternalLink(url, {style = 'normal'} = {}) { + formatExternalLink(url, { + style = 'normal', + context = 'generic', + } = {}) { if (!this.externalLinkSpec) { throw new TypeError(`externalLinkSpec unavailable`); } - if (style !== 'all') { - isExternalLinkStyle(style); - } + if (style !== 'all') isExternalLinkStyle(style); + isExternalLinkContext(context); const results = - getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, this); + getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + language: this, + context, + }); if (style === 'all') { return results; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 9fdf0182..698e3c9f 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -413,8 +413,17 @@ misc: "{PLACE} ({HANDLE})" local: "Wiki Archive (local upload)" + + bandcamp: "Bandcamp" + + bgreco: + _: "bgreco.net" + flash: "bgreco.net (high quality audio)" + deviantart: "DeviantArt" + homestuck: "Homestuck" instagram: "Instagram" + mastodon: "Mastodon" newgrounds: "Newgrounds" patreon: "Patreon" poetryFoundation: "Poetry Foundation" @@ -423,20 +432,20 @@ misc: tumblr: "Tumblr" twitter: "Twitter" wikipedia: "Wikipedia" - bandcamp: "Bandcamp" - mastodon: "Mastodon" youtube: _: "YouTube" + flash: "YouTube (on any device)" playlist: "YouTube (playlist)" fullAlbum: "YouTube (full album)" - flash: - bgreco: "{LINK} (HQ Audio)" - youtube: "{LINK} (on any device)" - homestuck: - page: "{LINK} (page {PAGE})" - secret: "{LINK} (secret page)" + # flashLink: + # Flashes can be positioned by page! They're accented with this + # information, if available. + + flashLink: + page: "{LINK} (page {PAGE})" + secret: "{LINK} (secret page)" # missingImage: # Fallback text displayed in an image when it's sourced to a file diff --git a/src/util/external-links.js b/src/util/external-links.js index 7a34fa9e..07f46bd3 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -18,31 +18,48 @@ export const externalLinkStyles = [ export const isExternalLinkStyle = is(...externalLinkStyles); +export const externalLinkContexts = [ + 'album', + 'artist', + 'flash', + 'generic', + 'group', + 'track', +]; + +export const isExternalLinkContext = is(...externalLinkContexts); + // This might need to be adjusted for YAML importing... -const isExternalLinkSpecRegex = +const isRegExp = validateInstanceOf(RegExp); export const isExternalLinkHandleSpec = validateProperties({ prefix: optional(isStringNonEmpty), - url: optional(isExternalLinkSpecRegex), - - // TODO: Don't allow specifying both of these (they're aliases) - domain: optional(isExternalLinkSpecRegex), - hostname: optional(isExternalLinkSpecRegex), - - // TODO: Don't allow specifying both of these (they're aliases) - path: optional(isExternalLinkSpecRegex), - pathname: optional(isExternalLinkSpecRegex), + url: optional(isRegExp), + domain: optional(isRegExp), + pathname: optional(isRegExp), }); export const isExternalLinkSpec = validateArrayItems( validateProperties({ - // TODO: Don't allow providing both of these, and require providing one - matchDomain: optional(isStringNonEmpty), - matchDomains: optional(validateArrayItems(isStringNonEmpty)), + match: validateProperties({ + // TODO: Don't allow providing both of these, and require providing one + domain: optional(isStringNonEmpty), + domains: optional(validateArrayItems(isStringNonEmpty)), + + // TODO: Don't allow providing both of these + pathname: optional(isRegExp), + pathnames: optional(validateArrayItems(isRegExp)), + + // TODO: Don't allow providing both of these + query: optional(isRegExp), + queries: optional(validateArrayItems(isRegExp)), + + context: optional(isExternalLinkContext), + }), string: isStringNonEmpty, @@ -64,27 +81,84 @@ export const fallbackDescriptor = { // TODO: Define all this stuff in data as YAML! export const externalLinkSpec = [ + // Special handling for album links + { - matchDomain: 'hsmusic.wiki', + match: { + context: 'album', + domain: 'youtube.com', + pathname: /^playlist/, + }, - string: 'local', + string: 'youtube.playlist', + icon: 'youtube', + }, - icon: 'globe', + { + match: { + context: 'album', + domain: 'youtube.com', + pathname: /^watch/, + }, + + string: 'youtube.fullAlbum', + icon: 'youtube', }, { - matchDomain: 'bandcamp.com', + match: { + context: 'album', + domain: 'youtu.be', + }, - string: 'bandcamp', + string: 'youtube.fullAlbum', + icon: 'youtube', + }, + + // Special handling for artist links + + { + match: { + context: 'artist', + domains: ['youtube.com', 'youtu.be'], + }, + + string: 'youtube', + icon: 'youtube', compact: 'handle', - icon: 'bandcamp', - handle: {domain: /^[^.]*/}, + handle: { + pathname: /^(@.*?)\/?$/, + }, + }, + + // Special handling for flash links + + { + match: { + context: 'flash', + domain: 'bgreco.net', + }, + + string: 'bgreco.flash', + icon: 'external', + }, + + { + match: { + context: 'flash', + domains: ['youtube.com', 'youtu.be'], + }, + + string: 'youtube.flash', + icon: 'youtube', }, + // Generic domains, sorted alphabetically (by string) + { - matchDomains: ['bc.s3m.us', 'music.solatrux.com'], + match: {domains: ['bc.s3m.us', 'music.solatrux.com']}, icon: 'bandcamp', string: 'bandcamp', @@ -94,7 +168,47 @@ export const externalLinkSpec = [ }, { - matchDomains: ['types.pl'], + match: {domain: 'bandcamp.com'}, + + string: 'bandcamp', + + compact: 'handle', + icon: 'bandcamp', + + handle: {domain: /^[^.]*/}, + }, + + { + match: {domain: 'deviantart.com'}, + + string: 'deviantart', + icon: 'deviantart', + }, + + { + match: {domain: 'instagram.com'}, + + string: 'instagram', + icon: 'instagram', + }, + + { + match: {domain: 'homestuck.com'}, + + string: 'homestuck', + icon: 'globe', // The horror! + }, + + { + match: {domain: 'hsmusic.wiki'}, + + string: 'local', + + icon: 'globe', + }, + + { + match: {domains: ['types.pl']}, icon: 'mastodon', string: 'mastodon', @@ -103,23 +217,17 @@ export const externalLinkSpec = [ }, { - matchDomains: ['youtube.com', 'youtu.be'], - - icon: 'youtube', - string: 'youtube', - - compact: 'handle', + match: {domain: 'newgrounds.com'}, - handle: { - pathname: /^(@.*?)\/?$/, - }, + string: 'newgrounds', + icon: 'newgrounds', }, { - matchDomain: 'soundcloud.com', + match: {domain: 'soundcloud.com'}, - icon: 'soundcloud', string: 'soundcloud', + icon: 'soundcloud', compact: 'handle', @@ -127,10 +235,10 @@ export const externalLinkSpec = [ }, { - matchDomain: 'tumblr.com', + match: {domain: 'tumblr.com'}, - icon: 'tumblr', string: 'tumblr', + icon: 'tumblr', compact: 'handle', @@ -138,62 +246,80 @@ export const externalLinkSpec = [ }, { - matchDomain: 'twitter.com', + match: {domain: 'twitter.com'}, - icon: 'twitter', string: 'twitter', + icon: 'twitter', compact: 'handle', handle: { prefix: '@', - pathname: /^@?.*\/?$/, + pathname: /^@?([a-zA-Z0-9_]*)\/?$/, }, }, { - matchDomain: 'deviantart.com', + match: {domains: ['youtube.com', 'youtu.be']}, - icon: 'deviantart', - string: 'deviantart', + string: 'youtube', + icon: 'youtube', }, +]; - { - matchDomain: 'instagram.com', - - icon: 'instagram', - string: 'instagram', - }, +function urlParts(url) { + const { + hostname: domain, + pathname, + search: query, + } = new URL(url); - { - matchDomain: 'newgrounds.com', + return {domain, pathname, query}; +} - icon: 'newgrounds', - string: 'newgrounds', - }, -]; +export function getMatchingDescriptorsForExternalLink(url, descriptors, { + context = 'generic', +} = {}) { + const {domain, pathname, query} = urlParts(url); -export function getMatchingDescriptorsForExternalLink(url, descriptors) { - const {hostname: domain} = new URL(url); - const compare = d => domain.includes(d); + const compareDomain = string => domain.includes(string); + const comparePathname = regex => regex.test(pathname.slice(1)); + const compareQuery = regex => regex.test(query.slice(1)); const matchingDescriptors = - descriptors.filter(spec => { - if (spec.matchDomain && compare(spec.matchDomain)) return true; - if (spec.matchDomains && spec.matchDomains.some(compare)) return true; - return false; - }); + descriptors + .filter(({match}) => { + if (match.domain) return compareDomain(match.domain); + if (match.domains) return match.domains.some(compareDomain); + return false; + }) + .filter(({match}) => { + if (match.context) return context === match.context; + return true; + }) + .filter(({match}) => { + if (match.pathname) return comparePathname(match.pathname); + if (match.pathnames) return match.pathnames.some(comparePathname); + return true; + }) + .filter(({match}) => { + if (match.query) return compareQuery(match.query); + if (match.queries) return match.quieries.some(compareQuery); + return true; + }); return [...matchingDescriptors, fallbackDescriptor]; } -export function getExternalLinkStringsFromDescriptor(url, descriptor, language) { +export function getExternalLinkStringsFromDescriptor(url, descriptor, { + language, +}) { const prefix = 'misc.external'; const results = Object.fromEntries(externalLinkStyles.map(style => [style, null])); - const {hostname: domain, pathname} = new URL(url); + const {domain, pathname, query} = urlParts(url); const place = language.$(prefix, descriptor.string); @@ -240,7 +366,7 @@ export function getExternalLinkStringsFromDescriptor(url, descriptor, language) case 'path': case 'pathname': - tests.push(pathname.slice(1)); + tests.push(pathname.slice(1) + query); break; default: @@ -277,7 +403,10 @@ export function getExternalLinkStringsFromDescriptor(url, descriptor, language) return results; } -export function getExternalLinkStringsFromDescriptors(url, descriptors, language) { +export function getExternalLinkStringsFromDescriptors(url, descriptors, { + language, + context = 'generic', +}) { const results = Object.fromEntries(externalLinkStyles.map(style => [style, null])); @@ -285,11 +414,11 @@ export function getExternalLinkStringsFromDescriptors(url, descriptors, language new Set(Object.keys(results)); const matchingDescriptors = - getMatchingDescriptorsForExternalLink(url, descriptors); + getMatchingDescriptorsForExternalLink(url, descriptors, {context}); for (const descriptor of matchingDescriptors) { const descriptorResults = - getExternalLinkStringsFromDescriptor(url, descriptor, language); + getExternalLinkStringsFromDescriptor(url, descriptor, {language}); const descriptorKeys = new Set( -- cgit 1.3.0-6-gf8a5 From 8c69ef2b14c4719fa0cd0c7daca27c613167b7ca Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 18:52:04 -0400 Subject: content: contextual external links --- .../dependencies/generateAlbumReleaseInfo.js | 2 +- .../dependencies/generateAlbumSidebarGroupBox.js | 5 ++++- src/content/dependencies/generateArtistInfoPage.js | 5 ++++- src/content/dependencies/generateFlashInfoPage.js | 2 +- src/content/dependencies/generateGroupInfoPage.js | 5 ++++- .../dependencies/generateTrackReleaseInfo.js | 5 ++++- src/content/dependencies/linkContribution.js | 5 +++-- src/content/dependencies/linkExternal.js | 14 +++++++++++--- src/content/dependencies/linkExternalAsIcon.js | 21 ++++++++++++++++----- src/content/dependencies/linkExternalFlash.js | 4 ++++ 10 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js index d6405283..4b819091 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -94,7 +94,7 @@ export default { links: language.formatDisjunctionList( relations.externalLinks - .map(link => link.slot('mode', 'album'))), + .map(link => link.slot('context', 'album'))), })), ]); }, diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js index 331ddaba..f3705450 100644 --- a/src/content/dependencies/generateAlbumSidebarGroupBox.js +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -66,7 +66,10 @@ export default { !empty(relations.externalLinks) && html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(relations.externalLinks), + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'group'))), })), slots.mode === 'album' && diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index 03bc0af5..ac9209a7 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -161,7 +161,10 @@ export default { sec.visit && html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(sec.visit.externalLinks), + links: + language.formatDisjunctionList( + sec.visit.externalLinks + .map(link => link.slot('context', 'artist'))), })), sec.artworks?.artistGalleryLink && diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 09c6b37c..c60f9696 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -133,7 +133,7 @@ export default { links: language.formatDisjunctionList( relations.externalLinks - .map(link => link.slot('mode', 'flash'))), + .map(link => link.slot('context', 'flash'))), })), sec.featuredTracks && [ diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 0583755e..05df33fb 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -107,7 +107,10 @@ export default { sec.info.visitLinks && html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(sec.info.visitLinks), + links: + language.formatDisjunctionList( + sec.info.visitLinks + .map(link => link.slot('context', 'group'))), })), html.tag('blockquote', diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js index 9a7478ca..c347dbce 100644 --- a/src/content/dependencies/generateTrackReleaseInfo.js +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -77,7 +77,10 @@ export default { html.tag('p', (relations.externalLinks ? language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList(relations.externalLinks), + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'track'))), }) : language.$('releaseInfo.listenOn.noLinks', { name: html.tag('i', data.name), diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index ef61c766..790afa4f 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -58,7 +58,8 @@ export default { }, language.formatUnitList( relations.artistIcons - .slice(0, 4))); + .slice(0, 4) + .map(icon => icon.slot('context', 'artist')))); } let content = language.formatString(parts.join('.'), options); @@ -79,7 +80,7 @@ export default { class: 'icons-tooltip-content', }, relations.artistIcons - .map(icon => icon.slot('withText', true)))), + .map(icon => icon.slots({context: 'artist', withText: true})))), ]; } diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 1b81efcc..e51ea89e 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,3 +1,5 @@ +import {isExternalLinkContext} from '#external-links'; + export default { extraDependencies: ['html', 'language', 'wikiData'], @@ -16,8 +18,11 @@ export default { }, slots: { - mode: { - validate: v => v.is('generic', 'album', 'flash'), + context: { + // This awkward syntax is because the slot descriptor validator can't + // differentiate between a function that returns a validator (the usual + // syntax) and a function that is itself a validator. + validate: () => isExternalLinkContext, default: 'generic', }, }, @@ -26,7 +31,10 @@ export default { return ( html.tag('a', {href: data.url, class: 'nowrap'}, - language.formatExternalLink(data.url, {style: 'platform'}))); + language.formatExternalLink(data.url, { + style: 'platform', + context: slots.context, + }))); }, /* diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js index 58bd896d..357c835c 100644 --- a/src/content/dependencies/linkExternalAsIcon.js +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -1,21 +1,32 @@ +import {isExternalLinkContext} from '#external-links'; + export default { extraDependencies: ['html', 'language', 'to'], data: (url) => ({url}), slots: { + context: { + // This awkward syntax is because the slot descriptor validator can't + // differentiate between a function that returns a validator (the usual + // syntax) and a function that is itself a validator. + validate: () => isExternalLinkContext, + default: 'generic', + }, + withText: {type: 'boolean'}, }, generate(data, slots, {html, language, to}) { - const {url} = data; + const format = style => + language.formatExternalLink(data.url, {style, context: slots.context}); - const normalText = language.formatExternalLink(url, {style: 'normal'}); - const compactText = language.formatExternalLink(url, {style: 'compact'}); - const iconId = language.formatExternalLink(url, {style: 'icon-id'}); + const normalText = format('normal'); + const compactText = format('compact'); + const iconId = format('icon-id'); return html.tag('a', - {href: url, class: ['icon', slots.withText && 'has-text']}, + {href: data.url, class: ['icon', slots.withText && 'has-text']}, [ html.tag('svg', [ !slots.withText && diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js index 65158ff8..e2147da1 100644 --- a/src/content/dependencies/linkExternalFlash.js +++ b/src/content/dependencies/linkExternalFlash.js @@ -1,6 +1,8 @@ // Note: This function is seriously hard-coded for HSMusic, with custom // presentation of links to Homestuck flashes hosted various places. +// This also appears to be dead code, apart from a single snapshot test?? + export default { contentDependencies: ['linkExternal'], extraDependencies: ['html', 'language'], @@ -22,6 +24,8 @@ export default { const {link} = relations; const {url, page} = data; + link.setSlot('context', 'flash'); + return html.tag('span', {class: 'nowrap'}, -- cgit 1.3.0-6-gf8a5 From ba6c4e043b3364481ac3beff1e2a141d1bfcf6fb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 20:47:34 -0400 Subject: external-links: cleaner per-style logic --- src/data/things/language.js | 16 +-- src/strings-default.yaml | 19 ++- src/util/external-links.js | 340 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 282 insertions(+), 93 deletions(-) diff --git a/src/data/things/language.js b/src/data/things/language.js index f83b4218..70481299 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -2,6 +2,7 @@ import {isLanguageCode} from '#validators'; import {Tag} from '#html'; import { + getExternalLinkStringOfStyleFromDescriptors, getExternalLinkStringsFromDescriptors, isExternalLinkContext, isExternalLinkSpec, @@ -321,20 +322,19 @@ export class Language extends Thing { throw new TypeError(`externalLinkSpec unavailable`); } - if (style !== 'all') isExternalLinkStyle(style); isExternalLinkContext(context); - const results = - getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + if (style === 'all') { + return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { language: this, context, }); - - if (style === 'all') { - return results; - } else { - return results[style]; } + + return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + language: this, + context, + }); } formatIndex(value) { diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 698e3c9f..d0d46998 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -407,10 +407,10 @@ misc: external: "External" withDomain: - "{PLACE} ({DOMAIN})" + "{PLATFORM} ({DOMAIN})" withHandle: - "{PLACE} ({HANDLE})" + "{PLATFORM} ({HANDLE})" local: "Wiki Archive (local upload)" @@ -421,7 +421,12 @@ misc: flash: "bgreco.net (high quality audio)" deviantart: "DeviantArt" - homestuck: "Homestuck" + + homestuck: + _: "Homestuck" + page: "Homestuck (page {PAGE})" + secretPage: "Homestuck (secret page)" + instagram: "Instagram" mastodon: "Mastodon" newgrounds: "Newgrounds" @@ -439,14 +444,6 @@ misc: playlist: "YouTube (playlist)" fullAlbum: "YouTube (full album)" - # flashLink: - # Flashes can be positioned by page! They're accented with this - # information, if available. - - flashLink: - page: "{LINK} (page {PAGE})" - secret: "{LINK} (secret page)" - # missingImage: # Fallback text displayed in an image when it's sourced to a file # that isn't available under the wiki's media directory. While it diff --git a/src/util/external-links.js b/src/util/external-links.js index 07f46bd3..a0301c9c 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -2,6 +2,7 @@ import {empty, stitchArrays} from '#sugar'; import { is, + isObject, isStringNonEmpty, optional, validateArrayItems, @@ -33,13 +34,14 @@ export const isExternalLinkContext = is(...externalLinkContexts); const isRegExp = validateInstanceOf(RegExp); -export const isExternalLinkHandleSpec = +export const isExternalLinkExtractSpec = validateProperties({ prefix: optional(isStringNonEmpty), url: optional(isRegExp), domain: optional(isRegExp), pathname: optional(isRegExp), + query: optional(isRegExp), }); export const isExternalLinkSpec = @@ -63,12 +65,16 @@ export const isExternalLinkSpec = string: isStringNonEmpty, - // TODO: Don't allow 'handle' options if handle isn't provided - normal: optional(is('domain', 'handle')), - compact: optional(is('domain', 'handle')), + // TODO: Don't allow 'handle' or 'custom' options if the corresponding + // properties aren't provided + normal: optional(is('domain', 'handle', 'custom')), + compact: optional(is('domain', 'handle', 'custom')), icon: optional(isStringNonEmpty), - handle: optional(isExternalLinkHandleSpec), + handle: optional(isExternalLinkExtractSpec), + + // TODO: This should validate each value with isExternalLinkExtractSpec. + custom: optional(isObject), })); export const fallbackDescriptor = { @@ -145,6 +151,38 @@ export const externalLinkSpec = [ icon: 'external', }, + // This takes precedence over the secretPage match below. + { + match: { + context: 'flash', + domain: 'homestuck.com', + pathname: /^story\/[0-9]+\/?$/, + }, + + platform: 'homestuck', + string: 'homestuck.page', + icon: 'globe', + + normal: 'custom', + + custom: { + page: { + pathname: /[0-9]+/, + }, + }, + }, + + { + match: { + context: 'flash', + domain: 'homestuck.com', + pathname: /^story\/.+\/?$/, + }, + + string: 'homestuck.secretPage', + icon: 'globe', + }, + { match: { context: 'flash', @@ -277,6 +315,10 @@ function urlParts(url) { return {domain, pathname, query}; } +function createEmptyResults() { + return Object.fromEntries(externalLinkStyles.map(style => [style, null])); +} + export function getMatchingDescriptorsForExternalLink(url, descriptors, { context = 'generic', } = {}) { @@ -311,107 +353,257 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { return [...matchingDescriptors, fallbackDescriptor]; } -export function getExternalLinkStringsFromDescriptor(url, descriptor, { - language, -}) { - const prefix = 'misc.external'; +export function extractPartFromExternalLink(url, extract) { + const {domain, pathname, query} = urlParts(url); - const results = - Object.fromEntries(externalLinkStyles.map(style => [style, null])); + let regexen = []; + let tests = []; + let prefix = ''; + + if (extract instanceof RegExp) { + regexen.push(descriptor.handle); + tests.push(url); + } else { + for (const [key, value] of Object.entries(extract)) { + switch (key) { + case 'prefix': + prefix = value; + continue; + + case 'url': + tests.push(url); + break; + + case 'domain': + tests.push(domain); + break; + + case 'pathname': + tests.push(pathname.slice(1)); + break; + + case 'query': + tests.push(query.slice(1)); + + default: + tests.push(''); + break; + } - const {domain, pathname, query} = urlParts(url); + regexen.push(value); + } + } - const place = language.$(prefix, descriptor.string); + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + return prefix + (match[1] ?? match[0]); + } + } - results['platform'] = place; + return null; +} - if (descriptor.icon) { - results['icon-id'] = descriptor.icon; +export function extractAllCustomPartsFromExternalLink(url, custom) { + const customParts = {}; + + // All or nothing: if one part doesn't match, all results are scrapped. + for (const [key, value] of Object.entries(custom)) { + customParts[key] = extractPartFromExternalLink(url, value); + if (!customParts[key]) return null; } - if (descriptor.normal === 'domain') { - results['normal'] = language.$(prefix, 'withDomain', {place, domain}); + return customParts; +} + +const prefix = 'misc.external'; + +export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { + function getPlatform() { + if (descriptor.custom) { + return null; + } + + return language.$(prefix, descriptor.string); } - if (descriptor.compact === 'domain') { - results['compact'] = language.sanitize(domain.replace(/^www\./, '')); + function getDomain() { + return urlParts(url).domain; } - let handle = null; + function getCustom() { + if (!descriptor.custom) { + return null; + } - if (descriptor.handle) { - let regexen = []; - let tests = []; + const customParts = + extractAllCustomPartsFromExternalLink(url, descriptor.custom); - let handlePrefix = ''; + if (!customParts) { + return null; + } - if (descriptor.handle instanceof RegExp) { - regexen.push(descriptor.handle); - tests.push(url); - } else { - for (const [key, value] of Object.entries(descriptor.handle)) { - switch (key) { - case 'prefix': - handlePrefix = value; - continue; - - case 'url': - tests.push(url); - break; - - case 'domain': - case 'hostname': - tests.push(domain); - break; - - case 'path': - case 'pathname': - tests.push(pathname.slice(1) + query); - break; - - default: - tests.push(''); - break; - } - - regexen.push(value); + return language.$(prefix, descriptor.string, customParts); + } + + function getHandle() { + if (!descriptor.handle) { + return null; + } + + return extractPartFromExternalLink(url, descriptor.handle); + } + + function getNormal() { + if (descriptor.custom) { + if (descriptor.normal === 'custom') { + return getCustom(); + } else { + return null; + } + } + + if (descriptor.normal === 'domain') { + const platform = getPlatform(); + const domain = getDomain(); + + if (!platform || !domain) { + return null; } + + return language.$(prefix, 'withDomain', {platform, domain}); } - for (const {regex, test} of stitchArrays({ - regex: regexen, - test: tests, - })) { - const match = test.match(regex); - if (match) { - handle = handlePrefix + (match[1] ?? match[0]); - break; + if (descriptor.normal === 'handle') { + const platform = getPlatform(); + const handle = getHandle(); + + if (!platform || !handle) { + return null; } + + return language.$(prefix, 'withHandle', {platform, handle}); } + + return language.$(prefix, descriptor.string); } - if (descriptor.compact === 'handle') { - results.compact = language.sanitize(handle); + function getCompact() { + if (descriptor.custom) { + if (descriptor.compact === 'custom') { + return getCustom(); + } else { + return null; + } + } + + if (descriptor.compact === 'domain') { + const domain = getDomain(); + + if (!domain) { + return null; + } + + return language.sanitize(domain.replace(/^www\./, '')); + } + + if (descriptor.compact === 'handle') { + const handle = getHandle(); + + if (!handle) { + return null; + } + + return language.sanitize(handle); + } + } + + function getIconId() { + return descriptor.icon ?? null; } - if (descriptor.normal === 'handle' && handle) { - results.normal = language.$(prefix, 'withHandle', {place, handle}); + switch (style) { + case 'normal': return getNormal(); + case 'compact': return getCompact(); + case 'platform': return getPlatform(); + case 'icon-id': return getIconId(); } +} - results.normal ??= language.$(prefix, descriptor.string); +export function couldDescriptorSupportStyle(descriptor, style) { + if (style === 'platform') { + return !descriptor.custom; + } - return results; + if (style === 'icon-id') { + return !!descriptor.icon; + } + + if (style === 'normal') { + if (descriptor.custom) { + return descriptor.normal === 'custom'; + } else { + return true; + } + } + + if (style === 'compact') { + if (descriptor.custom) { + return descriptor.compact === 'custom'; + } else { + return !!descriptor.compact; + } + } } -export function getExternalLinkStringsFromDescriptors(url, descriptors, { +export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { language, context = 'generic', }) { - const results = - Object.fromEntries(externalLinkStyles.map(style => [style, null])); + const matchingDescriptors = + getMatchingDescriptorsForExternalLink(url, descriptors, {context}); + + console.log('match-filtered:', matchingDescriptors); + + const styleFilteredDescriptors = + matchingDescriptors.filter(descriptor => + couldDescriptorSupportStyle(descriptor, style)); + + console.log('style-filtered:', styleFilteredDescriptors); + + for (const descriptor of styleFilteredDescriptors) { + const descriptorResult = + getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); - const remainingKeys = - new Set(Object.keys(results)); + if (descriptorResult) { + return descriptorResult; + } + } + + return null; +} + +export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) { + const getStyle = style => + getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); + + return { + 'normal': getStyle('normal'), + 'compact': getStyle('compact'), + 'platform': getStyle('platform'), + 'icon-id': getStyle('icon-id'), + }; +} + +export function getExternalLinkStringsFromDescriptors(url, descriptors, { + language, + context = 'generic', +}) { + const results = createEmptyResults(); + const remainingKeys = new Set(Object.keys(results)); const matchingDescriptors = getMatchingDescriptorsForExternalLink(url, descriptors, {context}); -- cgit 1.3.0-6-gf8a5 From face11b98dbaa866055718b7731f61a21fcf9088 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:01:27 -0400 Subject: content: linkExternal: make direct wrapper for formatExternalLink --- src/content/dependencies/linkExternal.js | 78 ++++++-------------------------- 1 file changed, 15 insertions(+), 63 deletions(-) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index e51ea89e..4941e48a 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,78 +1,30 @@ -import {isExternalLinkContext} from '#external-links'; +import {isExternalLinkContext, isExternalLinkStyle} from '#external-links'; export default { extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({wikiInfo}), - - data(sprawl, url) { - const data = {url}; - - const {canonicalBase} = sprawl.wikiInfo; - if (canonicalBase) { - const {hostname: canonicalDomain} = new URL(canonicalBase); - Object.assign(data, {canonicalDomain}); - } - - return data; - }, + data: (url) => ({url}), slots: { - context: { + style: { // This awkward syntax is because the slot descriptor validator can't // differentiate between a function that returns a validator (the usual // syntax) and a function that is itself a validator. + validate: () => isExternalLinkStyle, + default: 'platform', + }, + + context: { validate: () => isExternalLinkContext, default: 'generic', }, }, - generate(data, slots, {html, language}) { - return ( - html.tag('a', - {href: data.url, class: 'nowrap'}, - language.formatExternalLink(data.url, { - style: 'platform', - context: slots.context, - }))); - }, - - /* - : domain.includes('youtu') - ? slots.mode === 'album' - ? data.url.includes('list=') - ? language.$('misc.external.youtube.playlist') - : language.$('misc.external.youtube.fullAlbum') - : language.$('misc.external.youtube') - - switch (slots.mode) { - case 'flash': { - const wrap = content => - html.tag('span', {class: 'nowrap'}, content); - - if (domain.includes('homestuck.com')) { - const match = pathname.match(/\/story\/(.*)\/?/); - if (match) { - if (isNaN(Number(match[1]))) { - return wrap(language.$('misc.external.flash.homestuck.secret', {link})); - } else { - return wrap(language.$('misc.external.flash.homestuck.page', { - link, - page: match[1], - })); - } - } - } else if (domain.includes('bgreco.net')) { - return wrap(language.$('misc.external.flash.bgreco', {link})); - } else if (domain.includes('youtu')) { - return wrap(language.$('misc.external.flash.youtube', {link})); - } - - return link; - } - - default: - return link; - } - */ + generate: (data, slots, {html, language}) => + html.tag('a', + {href: data.url, class: 'nowrap'}, + language.formatExternalLink(data.url, { + style: slots.style, + context: slots.context, + })), }; -- cgit 1.3.0-6-gf8a5 From 370aab15cb6ba60c95b33f7c4a1ed9b6daf51d98 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:02:07 -0400 Subject: content: generateFlashInfoPage: use 'normal' style links --- src/content/dependencies/generateFlashInfoPage.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index c60f9696..919996a2 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -132,8 +132,11 @@ export default { language.$('releaseInfo.playOn', { links: language.formatDisjunctionList( - relations.externalLinks - .map(link => link.slot('context', 'flash'))), + relations.externalLinks.map(link => + link.slots({ + context: 'flash', + style: 'normal', + }))), })), sec.featuredTracks && [ -- cgit 1.3.0-6-gf8a5 From 841daeb4a29657485488ac55a743492b010658de Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:03:17 -0400 Subject: external-links: spec in terms of platform + substring --- src/util/external-links.js | 115 +++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/src/util/external-links.js b/src/util/external-links.js index a0301c9c..c8cb1670 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -63,7 +63,8 @@ export const isExternalLinkSpec = context: optional(isExternalLinkContext), }), - string: isStringNonEmpty, + platform: isStringNonEmpty, + substring: optional(isStringNonEmpty), // TODO: Don't allow 'handle' or 'custom' options if the corresponding // properties aren't provided @@ -78,7 +79,7 @@ export const isExternalLinkSpec = })); export const fallbackDescriptor = { - string: 'external', + platform: 'external', normal: 'domain', compact: 'domain', @@ -96,7 +97,9 @@ export const externalLinkSpec = [ pathname: /^playlist/, }, - string: 'youtube.playlist', + platform: 'youtube', + substring: 'playlist', + icon: 'youtube', }, @@ -107,7 +110,9 @@ export const externalLinkSpec = [ pathname: /^watch/, }, - string: 'youtube.fullAlbum', + platform: 'youtube', + substring: 'fullAlbum', + icon: 'youtube', }, @@ -117,7 +122,9 @@ export const externalLinkSpec = [ domain: 'youtu.be', }, - string: 'youtube.fullAlbum', + platform: 'youtube', + substring: 'fullAlbum', + icon: 'youtube', }, @@ -129,10 +136,11 @@ export const externalLinkSpec = [ domains: ['youtube.com', 'youtu.be'], }, - string: 'youtube', - icon: 'youtube', + platform: 'youtube', + normal: 'handle', compact: 'handle', + icon: 'youtube', handle: { pathname: /^(@.*?)\/?$/, @@ -147,7 +155,9 @@ export const externalLinkSpec = [ domain: 'bgreco.net', }, - string: 'bgreco.flash', + platform: 'bgreco', + substring: 'flash', + icon: 'external', }, @@ -160,10 +170,10 @@ export const externalLinkSpec = [ }, platform: 'homestuck', - string: 'homestuck.page', - icon: 'globe', + substring: 'page', normal: 'custom', + icon: 'globe', custom: { page: { @@ -179,7 +189,9 @@ export const externalLinkSpec = [ pathname: /^story\/.+\/?$/, }, - string: 'homestuck.secretPage', + platform: 'homestuck', + substring: 'secretPage', + icon: 'globe', }, @@ -189,7 +201,9 @@ export const externalLinkSpec = [ domains: ['youtube.com', 'youtu.be'], }, - string: 'youtube.flash', + platform: 'youtube', + substring: 'flash', + icon: 'youtube', }, @@ -198,17 +212,17 @@ export const externalLinkSpec = [ { match: {domains: ['bc.s3m.us', 'music.solatrux.com']}, - icon: 'bandcamp', - string: 'bandcamp', + platform: 'bandcamp', normal: 'domain', compact: 'domain', + icon: 'bandcamp', }, { match: {domain: 'bandcamp.com'}, - string: 'bandcamp', + platform: 'bandcamp', compact: 'handle', icon: 'bandcamp', @@ -219,28 +233,31 @@ export const externalLinkSpec = [ { match: {domain: 'deviantart.com'}, - string: 'deviantart', + platform: 'deviantart', + icon: 'deviantart', }, { match: {domain: 'instagram.com'}, - string: 'instagram', + platform: 'instagram', + icon: 'instagram', }, { match: {domain: 'homestuck.com'}, - string: 'homestuck', - icon: 'globe', // The horror! + platform: 'homestuck', + + icon: 'globe', }, { match: {domain: 'hsmusic.wiki'}, - string: 'local', + platform: 'local', icon: 'globe', }, @@ -248,37 +265,38 @@ export const externalLinkSpec = [ { match: {domains: ['types.pl']}, - icon: 'mastodon', - string: 'mastodon', + platform: 'mastodon', compact: 'domain', + icon: 'mastodon', }, { match: {domain: 'newgrounds.com'}, - string: 'newgrounds', + platform: 'newgrounds', + icon: 'newgrounds', }, { match: {domain: 'soundcloud.com'}, - string: 'soundcloud', - icon: 'soundcloud', + platform: 'soundcloud', compact: 'handle', + icon: 'soundcloud', - handle: /[^/]*\/?$/, + handle: /([^/]*)\/?$/, }, { match: {domain: 'tumblr.com'}, - string: 'tumblr', - icon: 'tumblr', + platform: 'tumblr', compact: 'handle', + icon: 'tumblr', handle: {domain: /^[^.]*/}, }, @@ -286,10 +304,10 @@ export const externalLinkSpec = [ { match: {domain: 'twitter.com'}, - string: 'twitter', - icon: 'twitter', + platform: 'twitter', compact: 'handle', + icon: 'twitter', handle: { prefix: '@', @@ -300,7 +318,8 @@ export const externalLinkSpec = [ { match: {domains: ['youtube.com', 'youtu.be']}, - string: 'youtube', + platform: 'youtube', + icon: 'youtube', }, ]; @@ -419,15 +438,11 @@ export function extractAllCustomPartsFromExternalLink(url, custom) { return customParts; } -const prefix = 'misc.external'; - export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { - function getPlatform() { - if (descriptor.custom) { - return null; - } + const prefix = 'misc.external'; - return language.$(prefix, descriptor.string); + function getPlatform() { + return language.$(prefix, descriptor.platform); } function getDomain() { @@ -446,7 +461,7 @@ export function getExternalLinkStringOfStyleFromDescriptor(url, style, descripto return null; } - return language.$(prefix, descriptor.string, customParts); + return language.$(prefix, descriptor.platform, descriptor.substring, customParts); } function getHandle() { @@ -488,7 +503,7 @@ export function getExternalLinkStringOfStyleFromDescriptor(url, style, descripto return language.$(prefix, 'withHandle', {platform, handle}); } - return language.$(prefix, descriptor.string); + return language.$(prefix, descriptor.platform, descriptor.substring); } function getCompact() { @@ -534,14 +549,6 @@ export function getExternalLinkStringOfStyleFromDescriptor(url, style, descripto } export function couldDescriptorSupportStyle(descriptor, style) { - if (style === 'platform') { - return !descriptor.custom; - } - - if (style === 'icon-id') { - return !!descriptor.icon; - } - if (style === 'normal') { if (descriptor.custom) { return descriptor.normal === 'custom'; @@ -557,6 +564,14 @@ export function couldDescriptorSupportStyle(descriptor, style) { return !!descriptor.compact; } } + + if (style === 'platform') { + return true; + } + + if (style === 'icon-id') { + return !!descriptor.icon; + } } export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { @@ -566,14 +581,10 @@ export function getExternalLinkStringOfStyleFromDescriptors(url, style, descript const matchingDescriptors = getMatchingDescriptorsForExternalLink(url, descriptors, {context}); - console.log('match-filtered:', matchingDescriptors); - const styleFilteredDescriptors = matchingDescriptors.filter(descriptor => couldDescriptorSupportStyle(descriptor, style)); - console.log('style-filtered:', styleFilteredDescriptors); - for (const descriptor of styleFilteredDescriptors) { const descriptorResult = getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); -- cgit 1.3.0-6-gf8a5 From db786c25a9fafc4cac37b108b4ea433019741c07 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:33:15 -0400 Subject: content, external-links: minor fixes --- src/content/dependencies/generateAlbumReleaseInfo.js | 6 +++++- src/util/external-links.js | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js index 4b819091..dd5baab9 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -94,7 +94,11 @@ export default { links: language.formatDisjunctionList( relations.externalLinks - .map(link => link.slot('context', 'album'))), + .map(link => + link.slots({ + context: 'album', + style: 'normal', + }))), })), ]); }, diff --git a/src/util/external-links.js b/src/util/external-links.js index c8cb1670..07a83bc1 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -133,7 +133,7 @@ export const externalLinkSpec = [ { match: { context: 'artist', - domains: ['youtube.com', 'youtu.be'], + domain: 'youtube.com', }, platform: 'youtube', @@ -158,7 +158,7 @@ export const externalLinkSpec = [ platform: 'bgreco', substring: 'flash', - icon: 'external', + icon: 'globe', }, // This takes precedence over the secretPage match below. -- cgit 1.3.0-6-gf8a5 From 3898fbe9380c7a8bc745eff548b112ad2e9c605b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:40:52 -0400 Subject: content, test: remove unused linkExternalFlash function --- src/content/dependencies/linkExternalFlash.js | 45 ---------------------- .../test/snapshot/linkExternalFlash.js.test.cjs | 18 --------- test/snapshot/linkExternalFlash.js | 24 ------------ 3 files changed, 87 deletions(-) delete mode 100644 src/content/dependencies/linkExternalFlash.js delete mode 100644 tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs delete mode 100644 test/snapshot/linkExternalFlash.js diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js deleted file mode 100644 index e2147da1..00000000 --- a/src/content/dependencies/linkExternalFlash.js +++ /dev/null @@ -1,45 +0,0 @@ -// Note: This function is seriously hard-coded for HSMusic, with custom -// presentation of links to Homestuck flashes hosted various places. - -// This also appears to be dead code, apart from a single snapshot test?? - -export default { - contentDependencies: ['linkExternal'], - extraDependencies: ['html', 'language'], - - relations(relation, url) { - return { - link: relation('linkExternal', url), - }; - }, - - data(url, flash) { - return { - url, - page: flash.page, - }; - }, - - generate(data, relations, {html, language}) { - const {link} = relations; - const {url, page} = data; - - link.setSlot('context', 'flash'); - - return html.tag('span', - {class: 'nowrap'}, - - url.includes('homestuck.com') - ? isNaN(Number(page)) - ? language.$('misc.external.flash.homestuck.secret', {link}) - : language.$('misc.external.flash.homestuck.page', {link, page}) - - : url.includes('bgreco.net') - ? language.$('misc.external.flash.bgreco', {link}) - - : url.includes('youtu') - ? language.$('misc.external.flash.youtube', {link}) - - : link); - }, -}; diff --git a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs b/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs deleted file mode 100644 index d29d0dde..00000000 --- a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs +++ /dev/null @@ -1,18 +0,0 @@ -/* IMPORTANT - * This snapshot file is auto-generated, but designed for humans. - * It should be checked into source control and tracked carefully. - * Re-generate by setting TAP_SNAPSHOT=1 and running tests. - * Make sure to inspect the output below. Do not ignore changes! - */ -'use strict' -exports[`test/snapshot/linkExternalFlash.js > TAP > linkExternalFlash (snapshot) > basic behavior 1`] = ` -homestuck.com (page 4109) -YouTube (on any device) -www.bgreco.net (HQ Audio) -Newgrounds -` - -exports[`test/snapshot/linkExternalFlash.js > TAP > linkExternalFlash (snapshot) > secret page 1`] = ` -homestuck.com (secret page) -YouTube (on any device) -` diff --git a/test/snapshot/linkExternalFlash.js b/test/snapshot/linkExternalFlash.js deleted file mode 100644 index a4d44aff..00000000 --- a/test/snapshot/linkExternalFlash.js +++ /dev/null @@ -1,24 +0,0 @@ -import t from 'tap'; -import {testContentFunctions} from '#test-lib'; - -testContentFunctions(t, 'linkExternalFlash (snapshot)', async (t, evaluate) => { - await evaluate.load(); - - evaluate.snapshot('basic behavior', { - name: 'linkExternalFlash', - multiple: [ - {args: ['https://homestuck.com/story/4109/', {page: '4109'}]}, - {args: ['https://youtu.be/FDt-SLyEcjI', {page: '4109'}]}, - {args: ['https://www.bgreco.net/hsflash/006009.html', {page: '4109'}]}, - {args: ['https://www.newgrounds.com/portal/view/582345', {page: '4109'}]}, - ], - }); - - evaluate.snapshot('secret page', { - name: 'linkExternalFlash', - multiple: [ - {args: ['https://homestuck.com/story/pony/', {page: 'pony'}]}, - {args: ['https://youtu.be/USB1pj6hAjU', {page: 'pony'}]}, - ], - }); -}); -- cgit 1.3.0-6-gf8a5 From 153a9f927073d69773af9bf30a53e94cac91d5a9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:42:14 -0400 Subject: test: generateAlbumReleaseInfo: update snapshot --- tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs index 3335a2eb..e9dbfea2 100644 --- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs @@ -7,11 +7,9 @@ 'use strict' exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `

                      - By Toby Fox (music probably) and Tensei (hot jams) - - Bandcamp - - + By Toby Fox (music probably) and Tensei (hot jams) + + tenseimusic .
                      Cover art by Hanni Brosh. -- cgit 1.3.0-6-gf8a5 From 803a17296249e1521089451c9d077cc524b4acf5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:43:34 -0400 Subject: external-links: minor code fixes --- src/util/external-links.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/external-links.js b/src/util/external-links.js index 07a83bc1..dee65cc5 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -380,7 +380,7 @@ export function extractPartFromExternalLink(url, extract) { let prefix = ''; if (extract instanceof RegExp) { - regexen.push(descriptor.handle); + regexen.push(extract); tests.push(url); } else { for (const [key, value] of Object.entries(extract)) { @@ -403,6 +403,7 @@ export function extractPartFromExternalLink(url, extract) { case 'query': tests.push(query.slice(1)); + break; default: tests.push(''); -- cgit 1.3.0-6-gf8a5 From 4507d7a8e42d3f7fd8c6cf4b1d9f270370078b08 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:50:27 -0400 Subject: test: linkContribution: update snapshot --- .../test/snapshot/linkContribution.js.test.cjs | 112 ++++++++------------- 1 file changed, 42 insertions(+), 70 deletions(-) diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs index 4cf3aa3f..acb8faf4 100644 --- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs @@ -30,46 +30,30 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = ` -Lorem Ipsum Lover - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - +Lorem Ipsum Lover + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io ` @@ -128,23 +112,17 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = ` -Clark Powell - - SoundCloud - - +Clark Powell + + plazmataz Grounder & Scratch (Snooping) -Toby Fox (Arrangement) - - Bandcamp - - - - - External (toby.fox) - - +Toby Fox (Arrangement) + + tobyfox + + + toby.fox ` @@ -170,22 +148,16 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = ` -Clark Powell - - SoundCloud - - +Clark Powell + + plazmataz Grounder & Scratch (Snooping) -Toby Fox (Arrangement) - - Bandcamp - - - - - External (toby.fox) - - +Toby Fox (Arrangement) + + tobyfox + + + toby.fox ` -- cgit 1.3.0-6-gf8a5 From ad1ae12ab182dd50cf3ca6ec653d371d77b5fabb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:14:27 -0400 Subject: external-links: quick spec tweaks --- src/util/external-links.js | 77 +++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/util/external-links.js b/src/util/external-links.js index dee65cc5..0a4a77cf 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -4,6 +4,7 @@ import { is, isObject, isStringNonEmpty, + oneOf, optional, validateArrayItems, validateInstanceOf, @@ -60,7 +61,10 @@ export const isExternalLinkSpec = query: optional(isRegExp), queries: optional(validateArrayItems(isRegExp)), - context: optional(isExternalLinkContext), + context: + optional(oneOf( + isExternalLinkContext, + validateArrayItems(isExternalLinkContext))), }), platform: isStringNonEmpty, @@ -130,6 +134,21 @@ export const externalLinkSpec = [ // Special handling for artist links + { + match: { + domain: 'patreon.com', + context: 'artist', + }, + + platform: 'patreon', + + normal: 'handle', + compact: 'handle', + icon: 'globe', + + handle: /([^/]*)\/?$/, + }, + { match: { context: 'artist', @@ -210,7 +229,7 @@ export const externalLinkSpec = [ // Generic domains, sorted alphabetically (by string) { - match: {domains: ['bc.s3m.us', 'music.solatrux.com']}, + match: {domains: ['bc.s3m.us', 'music.solatrus.com']}, platform: 'bandcamp', @@ -220,7 +239,7 @@ export const externalLinkSpec = [ }, { - match: {domain: 'bandcamp.com'}, + match: {domain: '.bandcamp.com'}, platform: 'bandcamp', @@ -232,53 +251,56 @@ export const externalLinkSpec = [ { match: {domain: 'deviantart.com'}, - platform: 'deviantart', - icon: 'deviantart', }, - { - match: {domain: 'instagram.com'}, - - platform: 'instagram', - - icon: 'instagram', - }, - { match: {domain: 'homestuck.com'}, - platform: 'homestuck', - icon: 'globe', }, { match: {domain: 'hsmusic.wiki'}, - platform: 'local', - icon: 'globe', }, + { + match: {domain: 'instagram.com'}, + platform: 'instagram', + icon: 'instagram', + }, + { match: {domains: ['types.pl']}, platform: 'mastodon', + normal: 'domain', compact: 'domain', icon: 'mastodon', }, { match: {domain: 'newgrounds.com'}, - platform: 'newgrounds', - icon: 'newgrounds', }, + { + match: {domain: 'patreon.com'}, + platform: 'patreon', + icon: 'globe', + }, + + { + match: {domain: 'poetryfoundation.org'}, + platform: 'poetryFoundation', + icon: 'globe', + }, + { match: {domain: 'soundcloud.com'}, @@ -291,7 +313,13 @@ export const externalLinkSpec = [ }, { - match: {domain: 'tumblr.com'}, + match: {domain: 'spotify.com'}, + platform: 'spotify', + icon: 'globe', + }, + + { + match: {domain: '.tumblr.com'}, platform: 'tumblr', @@ -316,10 +344,14 @@ export const externalLinkSpec = [ }, { - match: {domains: ['youtube.com', 'youtu.be']}, + match: {domain: 'wikipedia.org'}, + platform: 'wikipedia', + icon: 'misc', + }, + { + match: {domains: ['youtube.com', 'youtu.be']}, platform: 'youtube', - icon: 'youtube', }, ]; @@ -355,6 +387,7 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { return false; }) .filter(({match}) => { + if (Array.isArray(match.context)) return match.context.includes(context); if (match.context) return context === match.context; return true; }) -- cgit 1.3.0-6-gf8a5 From 25956ed1bbc7b894a0921530a2d7cbb892cb56c5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:14:53 -0400 Subject: test: linkExternal: update snapshot and loosely tidy test --- .../test/snapshot/linkExternal.js.test.cjs | 65 ++++++++++++++---- test/snapshot/linkExternal.js | 78 +++++++++++----------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs index cd6dca76..dd5493c0 100644 --- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs @@ -5,7 +5,41 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > basic domain matches 1`] = ` +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: compact 1`] = ` +youtu.be +youtube.com +youtube.com +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: normal 1`] = ` +YouTube (full album) +YouTube (full album) +YouTube +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: platform 1`] = ` +YouTube +YouTube +YouTube +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: compact 1`] = ` +homestuck +plazmataz +aeritus +@awkwarddoesart +deviantart.com +en.wikipedia.org +poetryfoundation.org +instagram.com +patreon.com +open.spotify.com +buzinkai.newgrounds.com +music.solatrus.com +types.pl +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: normal 1`] = ` Bandcamp SoundCloud Tumblr @@ -17,23 +51,26 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > basic d Patreon Spotify Newgrounds -` - -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom domains for common platforms 1`] = ` -music.solatrus.com +Bandcamp (music.solatrus.com) Mastodon (types.pl) ` -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom matches - album 1`] = ` -YouTube (full album) -YouTube (full album) -YouTube (playlist) -` - -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > missing domain (arbitrary local path) 1`] = ` -Wiki Archive (local upload) +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: platform 1`] = ` +Bandcamp +SoundCloud +Tumblr +Twitter +DeviantArt +Wikipedia +Poetry Foundation +Instagram +Patreon +Spotify +Newgrounds +Bandcamp +Mastodon ` exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > unknown domain (arbitrary world wide web path) 1`] = ` -snoo.ping.as +External ` diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js index 3e8aee0d..f9877aad 100644 --- a/test/snapshot/linkExternal.js +++ b/test/snapshot/linkExternal.js @@ -4,51 +4,49 @@ import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => { await evaluate.load(); - evaluate.snapshot('missing domain (arbitrary local path)', { - name: 'linkExternal', - args: ['/foo/bar/baz.mp3'] - }); - evaluate.snapshot('unknown domain (arbitrary world wide web path)', { name: 'linkExternal', args: ['https://snoo.ping.as/usual/i/see/'], }); - evaluate.snapshot('basic domain matches', { - name: 'linkExternal', - multiple: [ - {args: ['https://homestuck.bandcamp.com/']}, - {args: ['https://soundcloud.com/plazmataz']}, - {args: ['https://aeritus.tumblr.com/']}, - {args: ['https://twitter.com/awkwarddoesart']}, - {args: ['https://www.deviantart.com/chesswanderlust-sama']}, - {args: ['https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)']}, - {args: ['https://www.poetryfoundation.org/poets/christina-rossetti']}, - {args: ['https://www.instagram.com/levc_egm/']}, - {args: ['https://www.patreon.com/CecilyRenns']}, - {args: ['https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3']}, - {args: ['https://buzinkai.newgrounds.com/']}, - ], - }); + const urlsToArgs = urls => + urls.map(url => ({args: [url]})); - evaluate.snapshot('custom matches - album', { - name: 'linkExternal', - multiple: [ - {args: ['https://youtu.be/abc']}, - {args: ['https://youtube.com/watch?v=abc']}, - {args: ['https://youtube.com/Playlist?list=kweh']}, - ], - slots: { - mode: 'album', - }, - }); + const quickSnapshot = (message, urls, slots) => + evaluate.snapshot(message, { + name: 'linkExternal', + slots, + multiple: urlsToArgs(urls), + }); - evaluate.snapshot('custom domains for common platforms', { - name: 'linkExternal', - multiple: [ - // Just one domain of each platform is OK here - {args: ['https://music.solatrus.com/']}, - {args: ['https://types.pl/']}, - ], - }); + const quickSnapshotAllStyles = (context, urls) => { + for (const style of ['platform', 'normal', 'compact']) { + const message = `context: ${context}, style: ${style}`; + quickSnapshot(message, urls, {context, style}); + } + }; + + quickSnapshotAllStyles('generic', [ + 'https://homestuck.bandcamp.com/', + 'https://soundcloud.com/plazmataz', + 'https://aeritus.tumblr.com/', + 'https://twitter.com/awkwarddoesart', + 'https://www.deviantart.com/chesswanderlust-sama', + 'https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)', + 'https://www.poetryfoundation.org/poets/christina-rossetti', + 'https://www.instagram.com/levc_egm/', + 'https://www.patreon.com/CecilyRenns', + 'https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3', + 'https://buzinkai.newgrounds.com/', + + // Just one custom domain of each platform is OK here + 'https://music.solatrus.com/', + 'https://types.pl/', + ]); + + quickSnapshotAllStyles('album', [ + 'https://youtu.be/abc', + 'https://youtube.com/watch?v=abc', + 'https://youtube.com/Playlist?list=kweh', + ]); }); -- cgit 1.3.0-6-gf8a5 From 1c58d918e8126c4423608e9504a7f0fcbce2a64e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:17:40 -0400 Subject: test: linkExternal: add flash snapshots --- .../test/snapshot/linkExternal.js.test.cjs | 27 ++++++++++++++++++++++ test/snapshot/linkExternal.js | 9 ++++++++ 2 files changed, 36 insertions(+) diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs index dd5493c0..bc022e4c 100644 --- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs @@ -23,6 +23,33 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context YouTube ` +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: compact 1`] = ` +bgreco.net +homestuck.com +homestuck.com +youtube.com +youtu.be +some.external.site +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: normal 1`] = ` +bgreco.net (high quality audio) +Homestuck (page 1234) +Homestuck (secret page) +YouTube (on any device) +YouTube (on any device) +External (some.external.site) +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: platform 1`] = ` +bgreco.net +Homestuck +Homestuck +YouTube +YouTube +External +` + exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: compact 1`] = ` homestuck plazmataz diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js index f9877aad..434372a9 100644 --- a/test/snapshot/linkExternal.js +++ b/test/snapshot/linkExternal.js @@ -49,4 +49,13 @@ testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => { 'https://youtube.com/watch?v=abc', 'https://youtube.com/Playlist?list=kweh', ]); + + quickSnapshotAllStyles('flash', [ + 'https://www.bgreco.net/hsflash/002238.html', + 'https://homestuck.com/story/1234', + 'https://homestuck.com/story/pony', + 'https://www.youtube.com/watch?v=wKgOp3Kg2wI', + 'https://youtu.be/IOcvkkklWmY', + 'https://some.external.site/foo/bar/', + ]); }); -- cgit 1.3.0-6-gf8a5 From 45fba07af02d4f161cce494b683918bc76453b82 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:39:06 -0400 Subject: content: linkExternal: default to 'normal' style --- src/content/dependencies/generateArtistInfoPage.js | 7 +++++-- src/content/dependencies/generateFlashInfoPage.js | 7 ++----- src/content/dependencies/linkExternal.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index ac9209a7..be9f9b86 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -163,8 +163,11 @@ export default { language.$('releaseInfo.visitOn', { links: language.formatDisjunctionList( - sec.visit.externalLinks - .map(link => link.slot('context', 'artist'))), + sec.visit.externalLinks.map(link => + link.slots({ + context: 'artist', + mode: 'platform', + }))), })), sec.artworks?.artistGalleryLink && diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 919996a2..c60f9696 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -132,11 +132,8 @@ export default { language.$('releaseInfo.playOn', { links: language.formatDisjunctionList( - relations.externalLinks.map(link => - link.slots({ - context: 'flash', - style: 'normal', - }))), + relations.externalLinks + .map(link => link.slot('context', 'flash'))), })), sec.featuredTracks && [ diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 4941e48a..0a079614 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -11,7 +11,7 @@ export default { // differentiate between a function that returns a validator (the usual // syntax) and a function that is itself a validator. validate: () => isExternalLinkStyle, - default: 'platform', + default: 'normal', }, context: { -- cgit 1.3.0-6-gf8a5 From 9f58ba688c8a6ac3acf7b4bc435e2ccaed20b011 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:01:33 -0400 Subject: test: isCommentary: basic unit test update --- test/unit/data/things/validators.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js index bb33bf86..aa56a10e 100644 --- a/test/unit/data/things/validators.js +++ b/test/unit/data/things/validators.js @@ -149,13 +149,18 @@ t.test('isColor', t => { }); t.test('isCommentary', t => { - t.plan(6); + t.plan(9); + + // TODO: Test specific error messages. t.ok(isCommentary(`Toby Fox:\ndogsong.mp3`)); - t.ok(isCommentary(`Technically, this works:
                      `)); - t.ok(isCommentary(`Whodunnit:`)); - t.throws(() => isCommentary(123), TypeError); - t.throws(() => isCommentary(``), TypeError); - t.throws(() => isCommentary(`Toby Fox:`)); + t.ok(isCommentary(`Toby Fox: (music)\ndogsong.mp3`)); + t.throws(() => isCommentary(`dogsong.mp3\nToby Fox:\ndogsong.mp3`)); + t.throws(() => isCommentary(`Toby Fox: dogsong.mp3`)); + t.throws(() => isCommentary(`Toby Fox: (music) dogsong.mp3`)); + t.throws(() => isCommentary(`I Have Nothing To Say:`)); + t.throws(() => isCommentary(123)); + t.throws(() => isCommentary(``)); + t.throws(() => isCommentary(`Technically, ah, er:
                      \nCorrect`)); }); t.test('isContribution', t => { -- cgit 1.3.0-6-gf8a5 From 4263dc13d48b385e78bbc5e1112dfe7a47054909 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:17:09 -0400 Subject: client: get results from dispatchInternalEvent + merge fixes --- src/static/client3.js | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index af0c381c..866b9ba2 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -96,14 +96,18 @@ function dispatchInternalEvent(event, eventName, ...args) { throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); } + let results = []; for (const listener of listeners) { try { - listener(...args); + results.push(listener(...args)); } catch (error) { console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); console.debug(error); + results.push(undefined); } } + + return results; } // JS-based links ----------------------------------------- @@ -1102,13 +1106,14 @@ function addHashLinkListeners() { } // Allow event handlers to prevent scrolling. - for (const handler of event.beforeHashLinkScrolls) { - if (handler({ + const listenerResults = + dispatchInternalEvent(event, 'beforeHashLinkScrolls', { link: hashLink, target, - }) === false) { - return; - } + }); + + if (listenerResults.includes(false)) { + return; } // Hide skipper box right away, so the layout is updated on time for the @@ -1145,14 +1150,10 @@ function addHashLinkListeners() { processScrollingAfterHashLinkClicked(); - dispatchInternalEvent(event, 'whenHashLinkClicked', {link: hashLink}); - - for (const handler of event.whenHashLinkClicked) { - handler({ - link: hashLink, - target, - }); - } + dispatchInternalEvent(event, 'whenHashLinkClicked', { + link: hashLink, + target, + }); }); } @@ -1399,12 +1400,10 @@ function updateStickySubheadingContent(index) { state.displayedHeading = closestHeading; - for (const handler of event.whenDisplayedHeadingChanges) { - handler(index, { - oldHeading: oldDisplayedHeading, - newHeading: closestHeading, - }); - } + dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); } function updateStickyHeadings(index) { -- cgit 1.3.0-6-gf8a5 From ce46282c4d6cadb3a90243b7e0aa452e2dfa90d3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:20:01 -0400 Subject: test: generate{Album,Track}CoverArtwork: update snapshots --- tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs | 4 ++-- tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs | 8 ++++---- test/snapshot/generateAlbumCoverArtwork.js | 1 + test/snapshot/generateTrackCoverArtwork.js | 2 ++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs index 2c679fc8..26d9be49 100644 --- a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs @@ -16,7 +16,7 @@ exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverAr { name: 'creepy crawlies', isContentWarning: true } ] ] - slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

                      Tags: Damara, Cronus, Bees

                      ` @@ -31,5 +31,5 @@ exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverAr { name: 'creepy crawlies', isContentWarning: true } ] ] - slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }] ` diff --git a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs index 78063c48..0e4a15f4 100644 --- a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs @@ -16,7 +16,7 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr { name: 'creepy crawlies', isContentWarning: true } ] ] - slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

                      Tags: Damara, Cronus, Bees

                      ` @@ -25,7 +25,7 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr
                      [mocked: image args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ] - slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }] + slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

                      Tags: Bees

                      ` @@ -40,11 +40,11 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr { name: 'creepy crawlies', isContentWarning: true } ] ] - slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'small', reveal: false, link: false, square: true }] ` exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - unique art 1`] = ` [mocked: image args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ] - slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], thumb: 'small', reveal: false, link: false, square: true }] + slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }] ` diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js index b1c7885f..9244c034 100644 --- a/test/snapshot/generateAlbumCoverArtwork.js +++ b/test/snapshot/generateAlbumCoverArtwork.js @@ -13,6 +13,7 @@ testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evalua const album = { directory: 'bee-forus-seatbelt-safebee', coverArtFileExtension: 'png', + color: '#f28514', artTags: [ {name: 'Damara', directory: 'damara', isContentWarning: false}, {name: 'Cronus', directory: 'cronus', isContentWarning: false}, diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js index 03a181e7..1e651eb1 100644 --- a/test/snapshot/generateTrackCoverArtwork.js +++ b/test/snapshot/generateTrackCoverArtwork.js @@ -23,6 +23,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua directory: 'beesmp3', hasUniqueCoverArt: true, coverArtFileExtension: 'jpg', + color: '#f28514', artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}], album, }; @@ -30,6 +31,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua const track2 = { directory: 'fake-bonus-track', hasUniqueCoverArt: false, + color: '#abcdef', album, }; -- cgit 1.3.0-6-gf8a5 From 7b54aafa477ca8998f24d86e2b79e018bd93512d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:22:08 -0400 Subject: content: generateArtistInfoPage: quick slot fix --- src/content/dependencies/generateArtistInfoPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index be9f9b86..1b85680f 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -166,7 +166,7 @@ export default { sec.visit.externalLinks.map(link => link.slots({ context: 'artist', - mode: 'platform', + style: 'platform', }))), })), -- cgit 1.3.0-6-gf8a5 From 5931dde25d47695444419e9576dcdcc7d21d4a3f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:22:20 -0400 Subject: test: miscellaneous snapshot updates --- tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs | 4 ++-- tap-snapshots/test/snapshot/image.js.test.cjs | 2 +- tap-snapshots/test/snapshot/linkExternal.js.test.cjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs index 304717d1..d3702e65 100644 --- a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs @@ -16,7 +16,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > basic behavior, with track sections 1`] = `
                      -
                      First section (~1:30):
                      +
                      First section (~1:30):
                      • (0:20) Track 1
                      • @@ -24,7 +24,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
                      • (0:40) Track 3
                      -
                      Second section (0:05):
                      +
                      Second section (0:05):
                      ` diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs index f88141d5..5119eb03 100644 --- a/tap-snapshots/test/snapshot/image.js.test.cjs +++ b/tap-snapshots/test/snapshot/image.js.test.cjs @@ -68,7 +68,7 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > thumb requested but s ` exports[`test/snapshot/image.js > TAP > image (snapshot) > thumbnail details 1`] = ` -
                      +
                      ` exports[`test/snapshot/image.js > TAP > image (snapshot) > width & height 1`] = ` diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs index bc022e4c..a9116be4 100644 --- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs @@ -99,5 +99,5 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context ` exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > unknown domain (arbitrary world wide web path) 1`] = ` -External +External (snoo.ping.as) ` -- cgit 1.3.0-6-gf8a5 From daf6f5b39ebaf8cf62b0dab72edac41ceff72989 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:26:05 -0400 Subject: css: site5.css -> site6.css, just to be safe --- src/content/dependencies/generatePageLayout.js | 2 +- src/static/site5.css | 1950 ------------------------ src/static/site6.css | 1950 ++++++++++++++++++++++++ 3 files changed, 1951 insertions(+), 1951 deletions(-) delete mode 100644 src/static/site5.css create mode 100644 src/static/site6.css diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 7dee8cc3..1591223a 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -624,7 +624,7 @@ export default { html.tag('link', { rel: 'stylesheet', - href: to('shared.staticFile', 'site5.css', cachebust), + href: to('shared.staticFile', 'site6.css', cachebust), }), html.tag('style', [ diff --git a/src/static/site5.css b/src/static/site5.css deleted file mode 100644 index 4c083527..00000000 --- a/src/static/site5.css +++ /dev/null @@ -1,1950 +0,0 @@ -/* A frontend file! Wow. - * This file is just loaded statically 8y s in the HTML files, so there's - * no need to re-run upd8.js when tweaking values here. Handy! - */ - -/* Layout - Common */ - -body { - margin: 10px; - overflow-y: scroll; -} - -body::before { - content: ""; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; - - /* NB: these are 100 LVW, "largest view width", etc. - * Stabilizes background on viewports with modal dimensions, - * e.g. expanding/shrinking tab bar or collapsible find bar. - * 100% dimensions are kept above for browser compatibility. - */ - width: 100lvw; - height: 100lvh; -} - -#page-container { - max-width: 1100px; - margin: 10px auto 50px; - padding: 15px 0; -} - -#page-container > * { - margin-left: 15px; - margin-right: 15px; -} - -#skippers:focus-within { - position: static; - width: unset; - height: unset; -} - -#banner { - margin: 10px 0; - width: 100%; - position: relative; -} - -#banner::after { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -#banner img { - display: block; - width: 100%; - height: auto; -} - -#skippers { - position: absolute; - left: -10000px; - top: auto; - width: 1px; - height: 1px; -} - -.layout-columns { - display: flex; - align-items: stretch; -} - -#header, -#secondary-nav, -#skippers, -#footer { - padding: 5px; -} - -#header, -#secondary-nav, -#skippers { - margin-bottom: 10px; -} - -#footer { - margin-top: 10px; -} - -#header { - display: grid; -} - -#header.nav-has-main-links.nav-has-content { - grid-template-columns: 2.5fr 3fr; - grid-template-rows: min-content 1fr; - grid-template-areas: - "main-links content" - "bottom-row content"; -} - -#header.nav-has-main-links:not(.nav-has-content) { - grid-template-columns: 1fr; - grid-template-areas: - "main-links" - "bottom-row"; -} - -.nav-main-links { - grid-area: main-links; - margin-right: 20px; -} - -.nav-content { - grid-area: content; -} - -.nav-bottom-row { - grid-area: bottom-row; - align-self: start; -} - -.sidebar-column { - flex: 1 1 20%; - min-width: 150px; - max-width: 250px; - flex-basis: 250px; - align-self: flex-start; -} - -.sidebar-column.wide { - max-width: 350px; - flex-basis: 300px; - flex-shrink: 0; - flex-grow: 1; -} - -.sidebar-multiple { - display: flex; - flex-direction: column; -} - -.sidebar-multiple .sidebar:not(:first-child) { - margin-top: 15px; -} - -.sidebar { - --content-padding: 5px; - padding: var(--content-padding); -} - -#sidebar-left { - margin-right: 10px; -} - -#sidebar-right { - margin-left: 10px; -} - -#content { - position: relative; - --content-padding: 20px; - box-sizing: border-box; - padding: var(--content-padding); - flex-grow: 1; - flex-shrink: 3; -} - -.footer-content { - margin: 5px 12%; -} - -.footer-content > :first-child { - margin-top: 0; -} - -.footer-content > :last-child { - margin-bottom: 0; -} - -.footer-localization-links { - margin: 5px 12%; -} - -/* Design & Appearance - Layout elements */ - -body { - background: black; -} - -body::before { - background-image: url("../media/bg.jpg"); - background-position: center; - background-size: cover; - opacity: 0.5; -} - -#page-container { - background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); - color: #ffffff; - box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); -} - -#skippers > * { - display: inline-block; -} - -#skippers > .skipper-list:not(:last-child)::after { - display: inline-block; - content: "\00a0"; - margin-left: 2px; - margin-right: -2px; - border-left: 1px dotted; -} - -#skippers .skipper-list > .skipper:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; -} - -#banner { - background: black; - background-color: var(--dim-color); - border-bottom: 1px solid var(--primary-color); -} - -#banner::after { - box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); - pointer-events: none; -} - -#banner.dim img { - opacity: 0.8; -} - -#header, -#secondary-nav, -#skippers, -#footer, -.sidebar { - font-size: 0.85em; -} - -.sidebar, -#content, -#header, -#secondary-nav, -#skippers, -#footer { - background-color: rgba(0, 0, 0, 0.6); - border: 1px dotted var(--primary-color); - border-radius: 3px; - transition: background-color 0.2s; -} - -/* -.sidebar:focus-within, -#content:focus-within, -#header:focus-within, -#secondary-nav:focus-within, -#skippers:focus-within, -#footer:focus-within { - background-color: rgba(0, 0, 0, 0.85); - border-style: solid; -} -*/ - -.sidebar > h1, -.sidebar > h2, -.sidebar > h3, -.sidebar > p { - text-align: center; - padding-left: 4px; - padding-right: 4px; -} - -.sidebar h1 { - font-size: 1.25em; -} - -.sidebar h2 { - font-size: 1.1em; - margin: 0; -} - -.sidebar h3 { - font-size: 1.1em; - font-style: oblique; - font-variant: small-caps; - margin-top: 0.3em; - margin-bottom: 0em; -} - -.sidebar > p { - margin: 0.5em 0; - padding: 0 5px; -} - -.sidebar hr { - color: #555; - margin: 10px 5px; -} - -.sidebar > ol, -.sidebar > ul { - padding-left: 30px; - padding-right: 15px; -} - -.sidebar > dl { - padding-right: 15px; - padding-left: 0; -} - -.sidebar > dl dt { - padding-left: 10px; - margin-top: 0.5em; -} - -.sidebar > dl dt.current { - font-weight: 800; -} - -.sidebar > dl dd { - margin-left: 0; -} - -.sidebar > dl dd ul { - padding-left: 30px; - margin-left: 0; -} - -.sidebar > dl .side { - padding-left: 10px; -} - -.sidebar li.current { - font-weight: 800; -} - -.sidebar li { - overflow-wrap: break-word; -} - -.sidebar > details.current summary { - font-weight: 800; -} - -.sidebar > details summary { - margin-top: 0.5em; - padding-left: 5px; -} - -summary > span:hover { - cursor: pointer; - text-decoration: underline; - text-decoration-color: var(--primary-color); -} - -summary .group-name { - color: var(--primary-color); -} - -.sidebar > details ul, -.sidebar > details ol { - margin-top: 0; - margin-bottom: 0; -} - -.sidebar > details:last-child { - margin-bottom: 10px; -} - -.sidebar > details[open] { - margin-bottom: 1em; -} - -.sidebar article { - text-align: left; - margin: 5px 5px 15px 5px; -} - -.sidebar article:last-child { - margin-bottom: 5px; -} - -.sidebar article h2, -.news-index h2 { - border-bottom: 1px dotted; -} - -.sidebar article h2 time, -.news-index time { - float: right; - font-weight: normal; -} - -#content { - overflow-wrap: anywhere; -} - -footer { - text-align: center; - font-style: oblique; -} - -.footer-localization-links > span:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; -} - -/* Design & Appearance - Content elements */ - -a { - color: var(--primary-color); - text-decoration: none; -} - -a:hover { - text-decoration: underline; - text-decoration-style: solid !important; -} - -a.current { - font-weight: 800; -} - -a:not([href]) { - cursor: default; -} - -a:not([href]):hover { - text-decoration: none; -} - -.nav-main-links > span > span { - white-space: nowrap; -} - -.nav-main-links > span.current > span.nav-link-content > a { - font-weight: 800; -} - -.nav-links-index > span:not(:first-child):not(.no-divider)::before, -.nav-links-groups > span:not(:first-child):not(.no-divider)::before { - content: "\0020\00b7\0020"; - font-weight: 800; -} - -.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before { - content: "\0020/\0020"; -} - -#header .chronology .heading, -#header .chronology .buttons { - white-space: nowrap; -} - -#secondary-nav { - text-align: center; -} - -.nowrap { - white-space: nowrap; -} - -.contribution { - position: relative; -} - -.contribution.has-tooltip > a { - text-decoration: underline; - text-decoration-style: dotted; -} - -.icons { - font-style: normal; - white-space: nowrap; -} - -.icons-tooltip { - position: absolute; - z-index: 3; - left: -36px; - top: calc(1em - 2px); - padding: 4px 12px 6px 8px; -} - -.icons-tooltip:not(.visible) { - display: none; -} - -.icons-tooltip-content { - display: block; - padding: 6px 2px 2px 2px; - background: var(--bg-black-color); - border: 1px dotted var(--primary-color); - border-radius: 6px; - - -webkit-backdrop-filter: - brightness(1.5) saturate(1.4) blur(4px); - - backdrop-filter: - brightness(1.5) saturate(1.4) blur(4px); - - -webkit-user-select: none; - user-select: none; - - box-shadow: - 0 3px 4px 4px #000000aa, - 0 -2px 4px -2px var(--primary-color) inset; - - cursor: default; -} - -.icons a:hover { - filter: brightness(1.4); -} - -.icons a { - padding: 0 3px; -} - -.icon { - display: inline-block; - width: 24px; - height: 1em; - position: relative; -} - -.icon > svg { - width: 24px; - height: 24px; - top: -0.25em; - position: absolute; - fill: var(--primary-color); -} - -.icon.has-text { - display: block; - width: unset; - height: 1.4em; -} - -.icon.has-text > svg { - width: 18px; - height: 18px; - top: -0.1em; -} - -.icon.has-text > .icon-text { - margin-left: 24px; - padding-right: 8px; -} - -.rerelease, -.other-group-accent { - opacity: 0.7; - font-style: oblique; -} - -.other-group-accent { - white-space: nowrap; -} - -.content-columns { - columns: 2; -} - -.content-columns .column { - break-inside: avoid; -} - -.content-columns .column h2 { - margin-top: 0; - font-size: 1em; -} - -p .current { - font-weight: 800; -} - -#cover-art-container { - font-size: 0.8em; -} - -#cover-art .square { - box-shadow: 0 0 3px 6px rgba(0, 0, 0, 0.35); -} - -#cover-art img { - display: block; - width: 100%; - height: 100%; -} - -#cover-art-container p { - margin-top: 5px; -} - -.commentary-entry-heading { - margin-left: 15px; - padding-left: 5px; - max-width: 625px; - padding-bottom: 0.2em; - border-bottom: 1px dotted var(--primary-color); -} - -.commentary-entry-accent { - font-style: oblique; -} - -.commentary-art { - float: right; - width: 30%; - max-width: 250px; - margin: 15px 0 10px 20px; - box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35); -} - -.js-hide, -.js-show-once-data, -.js-hide-once-data { - display: none; -} - -.content-image { - margin-top: 1em; - margin-bottom: 1em; -} - -a.box:focus { - outline: 3px double var(--primary-color); -} - -a.box:focus:not(:focus-visible) { - outline: none; -} - -a.box img { - display: block; - max-width: 100%; - height: auto; -} - -.square .image-container { - width: 100%; - height: 100%; -} - -h1 { - font-size: 1.5em; -} - -#content li { - margin-bottom: 4px; -} - -#content li i { - white-space: nowrap; -} - -#content.top-index h1, -#content.flash-index h1 { - text-align: center; - font-size: 2em; -} - -html[data-url-key="localized.home"] #content h1 { - text-align: center; - font-size: 2.5em; -} - -#content.flash-index h2 { - text-align: center; - font-size: 2.5em; - font-variant: small-caps; - font-style: oblique; - margin-bottom: 0; - text-align: center; - width: 100%; -} - -#content.top-index h2 { - text-align: center; - font-size: 2em; - font-weight: normal; - margin-bottom: 0.25em; -} - -.quick-info { - text-align: center; -} - -ul.quick-info { - list-style: none; - padding-left: 0; -} - -ul.quick-info li { - display: inline-block; -} - -ul.quick-info li:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; -} - -.carousel-container + .quick-info { - margin-top: 25px; -} - -#intro-menu { - margin: 24px 0; - padding: 10px; - background-color: #222222; - text-align: center; - border: 1px dotted var(--primary-color); - border-radius: 2px; -} - -#intro-menu p { - margin: 12px 0; -} - -#intro-menu a { - margin: 0 6px; -} - -li .by { - display: inline-block; - font-style: oblique; -} - -li .by a { - display: inline-block; -} - -p code { - font-size: 1em; - font-family: "courier new"; - font-weight: 800; -} - -#content blockquote { - margin-left: 40px; - max-width: 600px; - margin-right: 0; -} - -#content blockquote blockquote { - margin-left: 10px; - padding-left: 10px; - margin-right: 20px; - border-left: dotted 1px; - padding-top: 6px; - padding-bottom: 6px; -} - -#content blockquote blockquote > :first-child { - margin-top: 0; -} - -#content blockquote blockquote > :last-child { - margin-bottom: 0; -} - -main.long-content { - --long-content-padding-ratio: 0.10; -} - -main.long-content .main-content-container, -main.long-content > h1 { - padding-left: calc(var(--long-content-padding-ratio) * 100%); - padding-right: calc(var(--long-content-padding-ratio) * 100%); -} - -dl dt { - padding-left: 40px; - max-width: 600px; -} - -dl dt { - margin-bottom: 2px; -} - -dl dd { - margin-bottom: 1em; -} - -dl ul, -dl ol { - margin-top: 0; - margin-bottom: 0; -} - -ul > li.has-details { - list-style-type: none; - margin-left: -17px; -} - -.album-group-list dt { - font-style: oblique; - padding-left: 0; -} - -.album-group-list dd { - margin-left: 0; -} - -.group-chronology-link { - font-style: oblique; -} - -#content hr { - border: 1px inset #808080; - width: 100%; -} - -#content hr.split::before { - content: "(split)"; - color: #808080; -} - -#content hr.split { - position: relative; - overflow: hidden; - border: none; -} - -#content hr.split::after { - display: inline-block; - content: ""; - border: 1px inset #808080; - width: 100%; - position: absolute; - top: 50%; - margin-top: -2px; - margin-left: 10px; -} - -li > ul { - margin-top: 5px; -} - -.group-contributions-table { - display: inline-block; -} - -.group-contributions-table .group-contributions-row { - display: flex; - justify-content: space-between; -} - -.group-contributions-table .group-contributions-metrics { - margin-left: 1.5ch; - white-space: nowrap; -} - -.group-contributions-sorted-by-count:not(.visible), -.group-contributions-sorted-by-duration:not(.visible) { - display: none; -} - -html[data-url-key="localized.albumCommentary"] li.no-commentary { - opacity: 0.7; -} - -html[data-url-key="localized.albumCommentary"] .content-heading-main-title { - margin-right: 0.25em; -} - -html[data-url-key="localized.albumCommentary"] .content-heading-accent { - font-weight: normal; - font-style: oblique; - font-size: 0.9rem; - display: inline-block; -} - -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { - display: none; -} - -html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { - opacity: 0.7; -} - -/* Additional names (heading and box) */ - -h1 a[href="#additional-names-box"] { - color: inherit; - text-decoration: underline; - text-decoration-style: dotted; -} - -h1 a[href="#additional-names-box"]:hover { - text-decoration-style: solid; -} - -#additional-names-box { - --custom-scroll-offset: calc(0.5em - 2px); - - margin: 1em 0 1em -10px; - padding: 15px 20px 10px 20px; - width: max-content; - max-width: min(60vw, 600px); - - border: 1px dotted var(--primary-color); - border-radius: 6px; - - background: - linear-gradient(var(--bg-color), var(--bg-color)), - linear-gradient(#000000bb, #000000bb), - var(--primary-color); - - box-shadow: 0 -2px 6px -1px var(--dim-color) inset; - - display: none; -} - -#additional-names-box > :first-child { margin-top: 0; } -#additional-names-box > :last-child { margin-bottom: 0; } - -#additional-names-box p { - padding-left: 10px; - padding-right: 10px; - margin-bottom: 0; - font-style: oblique; -} - -#additional-names-box ul { - padding-left: 10px; - margin-top: 0.5em; -} - -#additional-names-box li .additional-name { - margin-right: 0.25em; -} - -#additional-names-box li .additional-name .content-image { - margin-bottom: 0.25em; - margin-top: 0.5em; -} - -#additional-names-box li .annotation { - opacity: 0.8; - display: inline-block; -} - -/* Images */ - -.image-container { - border: 2px solid var(--primary-color); - box-sizing: border-box; - position: relative; - padding: 5px; - text-align: left; - background-color: var(--dim-color); - color: white; - display: inline-block; - height: 100%; -} - -.image-text-area { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 5px 15px; - background: rgba(0, 0, 0, 0.65); - box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset; - line-height: 1.35em; - color: var(--primary-color); - font-style: oblique; - text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); -} - -.image-inner-area { - width: 100%; - height: 100%; -} - -img { - object-fit: cover; -} - -.reveal { - position: relative; - width: 100%; - height: 100%; -} - -.reveal img { - filter: blur(20px); - opacity: 0.4; -} - -.reveal-text-container { - position: absolute; - top: 15px; - left: 10px; - right: 10px; - bottom: 10px; - display: flex; - flex-direction: column; - justify-content: flex-start; -} - -.reveal-text { - color: white; - text-align: center; - font-weight: bold; -} - -.reveal-interaction { - opacity: 0.8; -} - -.reveal.revealed img { - filter: none; - opacity: 1; -} - -.reveal.revealed .reveal-text { - display: none; -} - -.sidebar .image-container { - max-width: 350px; -} - -/* Grid listings */ - -.grid-listing { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: flex-start; - padding: 5px 15px; -} - -.grid-item { - font-size: 0.9em; -} - -.grid-item { - display: inline-block; - text-align: center; - background-color: #111111; - border: 1px dotted var(--primary-color); - border-radius: 2px; - padding: 5px; - margin: 10px; -} - -.grid-item img { - width: 100%; - height: 100% !important; - margin-top: auto; - margin-bottom: auto; -} - -.grid-item:hover { - text-decoration: none; -} - -.grid-actions .grid-item:hover { - text-decoration: underline; -} - -.grid-item > span { - display: block; - overflow-wrap: break-word; - hyphens: auto; -} - -.grid-item > span:not(:first-child) { - margin-top: 2px; -} - -.grid-item > span:first-of-type { - margin-top: 6px; -} - -.grid-item > span:not(:first-of-type) { - font-size: 0.9em; - opacity: 0.8; -} - -.grid-item:hover > span:first-of-type { - text-decoration: underline; -} - -.grid-listing > .grid-item { - flex: 1 25%; - max-width: 200px; -} - -.grid-actions { - display: flex; - flex-direction: row; - margin: 15px; - align-self: center; - flex-wrap: wrap; - justify-content: center; -} - -.grid-actions > .grid-item { - flex-basis: unset !important; - margin: 5px; - width: 120px; - --primary-color: inherit !important; - --dim-color: inherit !important; -} - -/* Carousel */ - -.carousel-container { - --carousel-tile-min-width: 120px; - --carousel-row-count: 3; - --carousel-column-count: 6; - - position: relative; - overflow: hidden; - margin: 20px 0 5px 0; - padding: 8px 0; -} - -.carousel-container::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: -20; - background-color: var(--dim-color); - filter: brightness(0.6); -} - -.carousel-container::after { - content: ""; - pointer-events: none; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 1px solid var(--primary-color); - border-radius: 4px; - z-index: 40; - box-shadow: - inset 20px 2px 40px var(--shadow-color), - inset -20px -2px 40px var(--shadow-color); -} - -.carousel-container:hover .carousel-grid { - animation-play-state: running; -} - -html[data-url-key="localized.home"] .carousel-container { - --carousel-tile-size: 140px; -} - -.carousel-container[data-carousel-rows="1"] { --carousel-row-count: 1; } -.carousel-container[data-carousel-rows="2"] { --carousel-row-count: 2; } -.carousel-container[data-carousel-rows="3"] { --carousel-row-count: 3; } -.carousel-container[data-carousel-columns="4"] { --carousel-column-count: 4; } -.carousel-container[data-carousel-columns="5"] { --carousel-column-count: 5; } -.carousel-container[data-carousel-columns="6"] { --carousel-column-count: 6; } - -.carousel-grid:nth-child(2), -.carousel-grid:nth-child(3) { - position: absolute; - top: 8px; - left: 0; - right: 0; -} - -.carousel-grid:nth-child(2) { - animation-name: carousel-marquee2; -} - -.carousel-grid:nth-child(3) { - animation-name: carousel-marquee3; -} - -@keyframes carousel-marquee1 { - 0% { - transform: translateX(-100%) translateX(70px); - } - - 100% { - transform: translateX(-200%) translateX(70px); - } -} - -@keyframes carousel-marquee2 { - 0% { - transform: translateX(0%) translateX(70px); - } - - 100% { - transform: translateX(-100%) translateX(70px); - } -} - -@keyframes carousel-marquee3 { - 0% { - transform: translateX(100%) translateX(70px); - } - - 100% { - transform: translateX(0%) translateX(70px); - } -} - -.carousel-grid { - /* Thanks to: https://css-tricks.com/an-auto-filling-css-grid-with-max-columns/ */ - --carousel-gap-count: calc(var(--carousel-column-count) - 1); - --carousel-total-gap-width: calc(var(--carousel-gap-count) * 10px); - --carousel-calculated-tile-max-width: calc((100% - var(--carousel-total-gap-width)) / var(--carousel-column-count)); - - display: grid; - grid-template-columns: repeat(auto-fill, minmax(max(var(--carousel-tile-min-width), var(--carousel-calculated-tile-max-width)), 1fr)); - grid-template-rows: repeat(var(--carousel-row-count), auto); - grid-auto-flow: dense; - grid-auto-rows: 0; - overflow: hidden; - margin: auto; - - transform: translateX(0); - animation: carousel-marquee1 40s linear infinite; - animation-play-state: paused; - z-index: 5; -} - -.carousel-item { - display: inline-block; - margin: 0; - flex: 1 1 150px; - padding: 3px; - border-radius: 10px; - filter: brightness(0.8); -} - -.carousel-item .image-container { - border: none; - padding: 0; -} - -.carousel-item img { - width: 100%; - height: 100%; - margin-top: auto; - margin-bottom: auto; - border-radius: 6px; -} - -.carousel-item:hover { - filter: brightness(1); - background: var(--dim-color); -} - -/* Squares */ - -.square { - position: relative; - width: 100%; -} - -.square::after { - content: ""; - display: block; - padding-bottom: 100%; -} - -.square-content { - position: absolute; - width: 100%; - height: 100%; -} - -/* Info card */ - -#info-card-container { - position: absolute; - - left: 0; - right: 10px; - - pointer-events: none; /* Padding area shouldn't 8e interactive. */ - display: none; -} - -#info-card-container.show, -#info-card-container.hide { - display: flex; -} - -#info-card-container > * { - flex-basis: 400px; -} - -#info-card-container.show { - animation: 0.2s linear forwards info-card-show; - transition: top 0.1s, left 0.1s; -} - -#info-card-container.hide { - animation: 0.2s linear forwards info-card-hide; -} - -@keyframes info-card-show { - 0% { - opacity: 0; - margin-top: -5px; - } - - 100% { - opacity: 1; - margin-top: 0; - } -} - -@keyframes info-card-hide { - 0% { - opacity: 1; - margin-top: 0; - } - - 100% { - opacity: 0; - margin-top: 5px; - display: none !important; - } -} - -.info-card-decor { - padding-left: 3ch; - border-top: 1px solid white; -} - -.info-card { - background-color: black; - color: white; - - border: 1px dotted var(--primary-color); - border-radius: 3px; - box-shadow: 0 5px 5px black; - - padding: 5px; - font-size: 0.9em; - - pointer-events: none; -} - -.info-card::after { - content: ""; - display: block; - clear: both; -} - -#info-card-container.show .info-card { - animation: 0.01s linear 0.2s forwards info-card-become-interactive; -} - -@keyframes info-card-become-interactive { - to { - pointer-events: auto; - } -} - -.info-card-art-container { - float: right; - - width: 40%; - margin: 5px; - font-size: 0.8em; - - /* Dynamically shown. */ - display: none; -} - -.info-card-art-container .image-container { - padding: 2px; -} - -.info-card-art { - display: block; - width: 100%; - height: 100%; -} - -.info-card-name { - font-size: 1em; - border-bottom: 1px dotted; - margin: 0; -} - -.info-card p { - margin-top: 0.25em; - margin-bottom: 0.25em; -} - -.info-card p:last-child { - margin-bottom: 0; -} - -/* Custom hash links */ - -.content-heading { - border-bottom: 3px double transparent; - margin-bottom: -3px; -} - -.content-heading.highlight-hash-link { - animation: highlight-hash-link 4s; - animation-delay: 125ms; -} - -h3.content-heading { - clear: both; -} - -/* This animation's name is referenced in JavaScript */ -@keyframes highlight-hash-link { - 0% { - border-bottom-color: transparent; - } - - 10% { - border-bottom-color: white; - } - - 25% { - border-bottom-color: white; - } - - 100% { - border-bottom-color: transparent; - } -} - -/* Sticky heading */ - -[id] { - --custom-scroll-offset: 0px; -} - -#content [id] { - /* Adjust scroll margin. */ - scroll-margin-top: calc( - 74px /* Sticky heading */ - + 33px /* Sticky subheading */ - - 1em /* One line of text (align bottom) */ - - 12px /* Padding for hanging letters & focus ring */ - + var(--custom-scroll-offset) /* Customizable offset */ - ); -} - -.content-sticky-heading-container { - position: sticky; - top: 0; - - margin: calc(-1 * var(--content-padding)); - margin-bottom: calc(0.5 * var(--content-padding)); - - transform: translateY(-5px); -} - -main.long-content .content-sticky-heading-container { - padding-left: 0; - padding-right: 0; -} - -main.long-content .content-sticky-heading-container .content-sticky-heading-row, -main.long-content .content-sticky-heading-container .content-sticky-subheading-row { - padding-left: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); - padding-right: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); -} - -.content-sticky-heading-row { - box-sizing: border-box; - padding: - calc(1.25 * var(--content-padding) + 5px) - 20px - calc(0.75 * var(--content-padding)) - 20px; - - width: 100%; - margin: 0; - - background: var(--bg-black-color); - border-bottom: 1px dotted rgba(220, 220, 220, 0.4); - - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); -} - -.content-sticky-heading-container.has-cover .content-sticky-heading-row, -.content-sticky-heading-container.has-cover .content-sticky-subheading-row { - display: grid; - grid-template-areas: - "title cover"; - grid-template-columns: 1fr min(40%, 400px); -} - -.content-sticky-heading-row h1 { - margin: 0; - padding-right: 10px; -} - -.content-sticky-heading-cover-container { - position: relative; - height: 0; - margin: -15px 0px -5px -5px; -} - -.content-sticky-heading-cover-needs-reveal { - display: none; -} - -.content-sticky-heading-cover { - position: absolute; - top: 0; - width: 80px; - right: 10px; - box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.25); - transition: transform 0.35s, opacity 0.25s; -} - -.content-sticky-heading-cover-container:not(.visible) .content-sticky-heading-cover { - opacity: 0; - transform: translateY(15px); -} - -.content-sticky-heading-cover .image-container { - border-width: 1px; - padding: 2px; -} - -.content-sticky-heading-cover img { - display: block; - width: 100%; - height: 100%; -} - -.content-sticky-subheading-row { - position: absolute; - width: 100%; - box-sizing: border-box; - padding: 10px 40px 5px 20px; - margin-top: 0; - z-index: -1; - - background: var(--bg-black-color); - border-bottom: 1px dotted rgba(220, 220, 220, 0.4); - - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); - - transition: margin-top 0.35s, opacity 0.25s; -} - -.content-sticky-subheading-row h2 { - margin: 0; - - font-size: 0.9em !important; - font-weight: normal; - font-style: oblique; - color: #eee; -} - -.content-sticky-subheading-row:not(.visible) { - margin-top: -20px; - opacity: 0; -} - -.content-sticky-heading-container h2.visible { - margin-top: 0; - opacity: 1; -} - -.content-sticky-heading-row { - box-shadow: - inset 0 10px 10px -5px var(--shadow-color), - 0 4px 4px rgba(0, 0, 0, 0.8); -} - -.content-sticky-heading-container h2.visible { - box-shadow: - inset 0 10px 10px -5px var(--shadow-color), - 0 4px 4px rgba(0, 0, 0, 0.8); -} - -#content, .sidebar { - contain: paint; -} - -/* Sticky sidebar */ - -.sidebar-column.sidebar.sticky-column, -.sidebar-column.sidebar.sticky-last, -.sidebar-multiple.sticky-last > .sidebar:last-child, -.sidebar-multiple.sticky-column { - position: sticky; - top: 10px; -} - -.sidebar-multiple.sticky-last { - align-self: stretch; -} - -.sidebar-multiple.sticky-column { - align-self: flex-start; -} - -.sidebar-column.sidebar.sticky-column { - max-height: calc(100vh - 20px); - align-self: start; - padding-bottom: 0; - box-sizing: border-box; - flex-basis: 275px; - padding-top: 0; - overflow-y: scroll; - scrollbar-width: thin; - scrollbar-color: var(--dark-color); -} - -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar { - background: var(--dark-color); - width: 12px; -} - -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb { - transition: background 0.2s; - background: rgba(255, 255, 255, 0.2); - border: 3px solid transparent; - border-radius: 10px; - background-clip: content-box; -} - -.sidebar-column.sidebar.sticky-column > h1 { - position: sticky; - top: 0; - margin: 0 calc(-1 * var(--content-padding)); - margin-bottom: 10px; - - border-bottom: 1px dotted rgba(220, 220, 220, 0.4); - padding: 10px 5px; - - background: var(--bg-black-color); - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); -} - -/* Image overlay */ - -#image-overlay-container { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 20px 40px; - box-sizing: border-box; - - opacity: 0; - pointer-events: none; - transition: opacity 0.4s; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -#image-overlay-container.visible { - opacity: 1; - pointer-events: auto; -} - -#image-overlay-content-container { - border-radius: 0 0 8px 8px; - border: 2px solid var(--primary-color); - background: var(--dim-ghost-color); - padding: 3px; - overflow: hidden; - - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); -} - -#image-overlay-image-container { - display: block; - position: relative; - overflow: hidden; - width: 80vmin; - height: 80vmin; -} - -#image-overlay-image, -#image-overlay-image-thumb { - display: inline-block; - object-fit: contain; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.65); -} - -#image-overlay-image { - position: absolute; - top: 0; - left: 0; -} - -#image-overlay-image-thumb { - filter: blur(16px); - transform: scale(1.5); -} - -#image-overlay-container.loaded #image-overlay-image-thumb { - opacity: 0; - pointer-events: none; - transition: opacity 0.25s; -} - -#image-overlay-image-container::after { - content: ""; - display: block; - position: absolute; - bottom: 0; - left: 0; - height: 4px; - width: var(--download-progress); - background: var(--primary-color); - box-shadow: 0 -3px 12px 4px var(--primary-color); - transition: 0.25s; -} - -#image-overlay-container.loaded #image-overlay-image-container::after { - width: 100%; - background: white; - opacity: 0; -} - -#image-overlay-container.errored #image-overlay-image-container::after { - width: 100%; - background: red; -} - -#image-overlay-container:not(.visible) #image-overlay-image-container::after { - width: 0 !important; -} - -#image-overlay-action-container { - padding: 4px 4px 6px 4px; - border-radius: 0 0 5px 5px; - background: var(--bg-black-color); - color: white; - font-style: oblique; - text-align: center; -} - -#image-overlay-container #image-overlay-action-content-without-size:not(.visible), -#image-overlay-container #image-overlay-action-content-with-size:not(.visible), -#image-overlay-container #image-overlay-file-size-warning:not(.visible), -#image-overlay-container #image-overlay-file-size-kilobytes:not(.visible), -#image-overlay-container #image-overlay-file-size-megabytes:not(.visible) { - display: none; -} - -#image-overlay-file-size-warning { - opacity: 0.8; - font-size: 0.9em; -} - -/* important easter egg mode */ - -html[data-language-code="preview-en"][data-url-key="localized.home"] #content - h1::after { - font-family: cursive; - display: block; - content: "(Preview Build)"; - font-size: 0.8em; -} - -/* Layout - Wide (most computers) */ - -@media (min-width: 900px) { - #page-container:not(.has-zero-sidebars) #secondary-nav { - display: none; - } -} - -/* Layout - Medium (tablets, some landscape mobiles) - * - * Note: Rules defined here are exclusive to "medium" width, i.e. they don't - * additionally apply to "thin". Use the later section which applies to both - * if so desired. - */ - -@media (min-width: 600px) and (max-width: 899.98px) { - /* Medim layout is mainly defined (to the user) by hiding the sidebar, so - * don't apply the similar layout change of widening the long-content area - * if this page doesn't have a sidebar to hide in the first place. - */ - #page-container:not(.has-zero-sidebars) main.long-content { - --long-content-padding-ratio: 0.06; - } -} - -/* Layout - Wide or Medium */ - -@media (min-width: 600px) { - .content-sticky-heading-container { - /* Safari doesn't always play nicely with position: sticky, - * this seems to fix images sometimes displaying above the - * position: absolute subheading (h2) child - * - * See also: https://stackoverflow.com/questions/50224855/ - */ - transform: translate3d(0, 0, 0); - z-index: 1; - } - - /* Cover art floats to the right. It's positioned in HTML beneath the - * heading, so pull it up a little to "float" on top. - */ - #cover-art-container { - float: right; - width: 40%; - max-width: 400px; - margin: -60px 0 10px 20px; - - position: relative; - z-index: 2; - } - - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+10)) { - flex-basis: 23%; - margin: 15px; - } - - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+10) { - flex-basis: 18%; - margin: 10px; - } -} - -/* Layout - Medium or Thin */ - -@media (max-width: 899.98px) { - .sidebar-column:not(.no-hide) { - display: none; - } - - #secondary-nav { - display: block; - } - - .layout-columns.vertical-when-thin { - flex-direction: column; - } - - .layout-columns.vertical-when-thin > *:not(:last-child) { - margin-bottom: 10px; - } - - .sidebar-column.no-hide { - max-width: unset !important; - flex-basis: unset !important; - margin-right: 0 !important; - margin-left: 0 !important; - width: 100%; - } - - .sidebar .news-entry:not(.first-news-entry) { - display: none; - } - - .grid-listing > .grid-item { - flex-basis: 40%; - } -} - -/* Layout - Thin (phones) */ - -@media (max-width: 600px) { - .content-columns { - columns: 1; - } - - main.long-content { - --long-content-padding-ratio: 0.02; - } - - #cover-art-container { - margin: 25px 0 5px 0; - width: 100%; - max-width: unset; - } - - #additional-names-box { - max-width: unset; - } - - /* Show sticky heading above cover art */ - - .content-sticky-heading-container { - z-index: 2; - } - - /* Let sticky heading text span past lower-index cover art */ - - .content-sticky-heading-container.has-cover .content-sticky-heading-row, - .content-sticky-heading-container.has-cover .content-sticky-subheading-row { - grid-template-columns: 1fr 90px; - } - - /* Disable grid features, just line header children up vertically */ - - #header { - display: block; - } - - #header > div:not(:first-child) { - margin-top: 0.5em; - } -} diff --git a/src/static/site6.css b/src/static/site6.css new file mode 100644 index 00000000..4c083527 --- /dev/null +++ b/src/static/site6.css @@ -0,0 +1,1950 @@ +/* A frontend file! Wow. + * This file is just loaded statically 8y s in the HTML files, so there's + * no need to re-run upd8.js when tweaking values here. Handy! + */ + +/* Layout - Common */ + +body { + margin: 10px; + overflow-y: scroll; +} + +body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + + /* NB: these are 100 LVW, "largest view width", etc. + * Stabilizes background on viewports with modal dimensions, + * e.g. expanding/shrinking tab bar or collapsible find bar. + * 100% dimensions are kept above for browser compatibility. + */ + width: 100lvw; + height: 100lvh; +} + +#page-container { + max-width: 1100px; + margin: 10px auto 50px; + padding: 15px 0; +} + +#page-container > * { + margin-left: 15px; + margin-right: 15px; +} + +#skippers:focus-within { + position: static; + width: unset; + height: unset; +} + +#banner { + margin: 10px 0; + width: 100%; + position: relative; +} + +#banner::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +#banner img { + display: block; + width: 100%; + height: auto; +} + +#skippers { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; +} + +.layout-columns { + display: flex; + align-items: stretch; +} + +#header, +#secondary-nav, +#skippers, +#footer { + padding: 5px; +} + +#header, +#secondary-nav, +#skippers { + margin-bottom: 10px; +} + +#footer { + margin-top: 10px; +} + +#header { + display: grid; +} + +#header.nav-has-main-links.nav-has-content { + grid-template-columns: 2.5fr 3fr; + grid-template-rows: min-content 1fr; + grid-template-areas: + "main-links content" + "bottom-row content"; +} + +#header.nav-has-main-links:not(.nav-has-content) { + grid-template-columns: 1fr; + grid-template-areas: + "main-links" + "bottom-row"; +} + +.nav-main-links { + grid-area: main-links; + margin-right: 20px; +} + +.nav-content { + grid-area: content; +} + +.nav-bottom-row { + grid-area: bottom-row; + align-self: start; +} + +.sidebar-column { + flex: 1 1 20%; + min-width: 150px; + max-width: 250px; + flex-basis: 250px; + align-self: flex-start; +} + +.sidebar-column.wide { + max-width: 350px; + flex-basis: 300px; + flex-shrink: 0; + flex-grow: 1; +} + +.sidebar-multiple { + display: flex; + flex-direction: column; +} + +.sidebar-multiple .sidebar:not(:first-child) { + margin-top: 15px; +} + +.sidebar { + --content-padding: 5px; + padding: var(--content-padding); +} + +#sidebar-left { + margin-right: 10px; +} + +#sidebar-right { + margin-left: 10px; +} + +#content { + position: relative; + --content-padding: 20px; + box-sizing: border-box; + padding: var(--content-padding); + flex-grow: 1; + flex-shrink: 3; +} + +.footer-content { + margin: 5px 12%; +} + +.footer-content > :first-child { + margin-top: 0; +} + +.footer-content > :last-child { + margin-bottom: 0; +} + +.footer-localization-links { + margin: 5px 12%; +} + +/* Design & Appearance - Layout elements */ + +body { + background: black; +} + +body::before { + background-image: url("../media/bg.jpg"); + background-position: center; + background-size: cover; + opacity: 0.5; +} + +#page-container { + background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); + color: #ffffff; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); +} + +#skippers > * { + display: inline-block; +} + +#skippers > .skipper-list:not(:last-child)::after { + display: inline-block; + content: "\00a0"; + margin-left: 2px; + margin-right: -2px; + border-left: 1px dotted; +} + +#skippers .skipper-list > .skipper:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +#banner { + background: black; + background-color: var(--dim-color); + border-bottom: 1px solid var(--primary-color); +} + +#banner::after { + box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +#banner.dim img { + opacity: 0.8; +} + +#header, +#secondary-nav, +#skippers, +#footer, +.sidebar { + font-size: 0.85em; +} + +.sidebar, +#content, +#header, +#secondary-nav, +#skippers, +#footer { + background-color: rgba(0, 0, 0, 0.6); + border: 1px dotted var(--primary-color); + border-radius: 3px; + transition: background-color 0.2s; +} + +/* +.sidebar:focus-within, +#content:focus-within, +#header:focus-within, +#secondary-nav:focus-within, +#skippers:focus-within, +#footer:focus-within { + background-color: rgba(0, 0, 0, 0.85); + border-style: solid; +} +*/ + +.sidebar > h1, +.sidebar > h2, +.sidebar > h3, +.sidebar > p { + text-align: center; + padding-left: 4px; + padding-right: 4px; +} + +.sidebar h1 { + font-size: 1.25em; +} + +.sidebar h2 { + font-size: 1.1em; + margin: 0; +} + +.sidebar h3 { + font-size: 1.1em; + font-style: oblique; + font-variant: small-caps; + margin-top: 0.3em; + margin-bottom: 0em; +} + +.sidebar > p { + margin: 0.5em 0; + padding: 0 5px; +} + +.sidebar hr { + color: #555; + margin: 10px 5px; +} + +.sidebar > ol, +.sidebar > ul { + padding-left: 30px; + padding-right: 15px; +} + +.sidebar > dl { + padding-right: 15px; + padding-left: 0; +} + +.sidebar > dl dt { + padding-left: 10px; + margin-top: 0.5em; +} + +.sidebar > dl dt.current { + font-weight: 800; +} + +.sidebar > dl dd { + margin-left: 0; +} + +.sidebar > dl dd ul { + padding-left: 30px; + margin-left: 0; +} + +.sidebar > dl .side { + padding-left: 10px; +} + +.sidebar li.current { + font-weight: 800; +} + +.sidebar li { + overflow-wrap: break-word; +} + +.sidebar > details.current summary { + font-weight: 800; +} + +.sidebar > details summary { + margin-top: 0.5em; + padding-left: 5px; +} + +summary > span:hover { + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--primary-color); +} + +summary .group-name { + color: var(--primary-color); +} + +.sidebar > details ul, +.sidebar > details ol { + margin-top: 0; + margin-bottom: 0; +} + +.sidebar > details:last-child { + margin-bottom: 10px; +} + +.sidebar > details[open] { + margin-bottom: 1em; +} + +.sidebar article { + text-align: left; + margin: 5px 5px 15px 5px; +} + +.sidebar article:last-child { + margin-bottom: 5px; +} + +.sidebar article h2, +.news-index h2 { + border-bottom: 1px dotted; +} + +.sidebar article h2 time, +.news-index time { + float: right; + font-weight: normal; +} + +#content { + overflow-wrap: anywhere; +} + +footer { + text-align: center; + font-style: oblique; +} + +.footer-localization-links > span:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +/* Design & Appearance - Content elements */ + +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; + text-decoration-style: solid !important; +} + +a.current { + font-weight: 800; +} + +a:not([href]) { + cursor: default; +} + +a:not([href]):hover { + text-decoration: none; +} + +.nav-main-links > span > span { + white-space: nowrap; +} + +.nav-main-links > span.current > span.nav-link-content > a { + font-weight: 800; +} + +.nav-links-index > span:not(:first-child):not(.no-divider)::before, +.nav-links-groups > span:not(:first-child):not(.no-divider)::before { + content: "\0020\00b7\0020"; + font-weight: 800; +} + +.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before { + content: "\0020/\0020"; +} + +#header .chronology .heading, +#header .chronology .buttons { + white-space: nowrap; +} + +#secondary-nav { + text-align: center; +} + +.nowrap { + white-space: nowrap; +} + +.contribution { + position: relative; +} + +.contribution.has-tooltip > a { + text-decoration: underline; + text-decoration-style: dotted; +} + +.icons { + font-style: normal; + white-space: nowrap; +} + +.icons-tooltip { + position: absolute; + z-index: 3; + left: -36px; + top: calc(1em - 2px); + padding: 4px 12px 6px 8px; +} + +.icons-tooltip:not(.visible) { + display: none; +} + +.icons-tooltip-content { + display: block; + padding: 6px 2px 2px 2px; + background: var(--bg-black-color); + border: 1px dotted var(--primary-color); + border-radius: 6px; + + -webkit-backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + -webkit-user-select: none; + user-select: none; + + box-shadow: + 0 3px 4px 4px #000000aa, + 0 -2px 4px -2px var(--primary-color) inset; + + cursor: default; +} + +.icons a:hover { + filter: brightness(1.4); +} + +.icons a { + padding: 0 3px; +} + +.icon { + display: inline-block; + width: 24px; + height: 1em; + position: relative; +} + +.icon > svg { + width: 24px; + height: 24px; + top: -0.25em; + position: absolute; + fill: var(--primary-color); +} + +.icon.has-text { + display: block; + width: unset; + height: 1.4em; +} + +.icon.has-text > svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.icon.has-text > .icon-text { + margin-left: 24px; + padding-right: 8px; +} + +.rerelease, +.other-group-accent { + opacity: 0.7; + font-style: oblique; +} + +.other-group-accent { + white-space: nowrap; +} + +.content-columns { + columns: 2; +} + +.content-columns .column { + break-inside: avoid; +} + +.content-columns .column h2 { + margin-top: 0; + font-size: 1em; +} + +p .current { + font-weight: 800; +} + +#cover-art-container { + font-size: 0.8em; +} + +#cover-art .square { + box-shadow: 0 0 3px 6px rgba(0, 0, 0, 0.35); +} + +#cover-art img { + display: block; + width: 100%; + height: 100%; +} + +#cover-art-container p { + margin-top: 5px; +} + +.commentary-entry-heading { + margin-left: 15px; + padding-left: 5px; + max-width: 625px; + padding-bottom: 0.2em; + border-bottom: 1px dotted var(--primary-color); +} + +.commentary-entry-accent { + font-style: oblique; +} + +.commentary-art { + float: right; + width: 30%; + max-width: 250px; + margin: 15px 0 10px 20px; + box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35); +} + +.js-hide, +.js-show-once-data, +.js-hide-once-data { + display: none; +} + +.content-image { + margin-top: 1em; + margin-bottom: 1em; +} + +a.box:focus { + outline: 3px double var(--primary-color); +} + +a.box:focus:not(:focus-visible) { + outline: none; +} + +a.box img { + display: block; + max-width: 100%; + height: auto; +} + +.square .image-container { + width: 100%; + height: 100%; +} + +h1 { + font-size: 1.5em; +} + +#content li { + margin-bottom: 4px; +} + +#content li i { + white-space: nowrap; +} + +#content.top-index h1, +#content.flash-index h1 { + text-align: center; + font-size: 2em; +} + +html[data-url-key="localized.home"] #content h1 { + text-align: center; + font-size: 2.5em; +} + +#content.flash-index h2 { + text-align: center; + font-size: 2.5em; + font-variant: small-caps; + font-style: oblique; + margin-bottom: 0; + text-align: center; + width: 100%; +} + +#content.top-index h2 { + text-align: center; + font-size: 2em; + font-weight: normal; + margin-bottom: 0.25em; +} + +.quick-info { + text-align: center; +} + +ul.quick-info { + list-style: none; + padding-left: 0; +} + +ul.quick-info li { + display: inline-block; +} + +ul.quick-info li:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +.carousel-container + .quick-info { + margin-top: 25px; +} + +#intro-menu { + margin: 24px 0; + padding: 10px; + background-color: #222222; + text-align: center; + border: 1px dotted var(--primary-color); + border-radius: 2px; +} + +#intro-menu p { + margin: 12px 0; +} + +#intro-menu a { + margin: 0 6px; +} + +li .by { + display: inline-block; + font-style: oblique; +} + +li .by a { + display: inline-block; +} + +p code { + font-size: 1em; + font-family: "courier new"; + font-weight: 800; +} + +#content blockquote { + margin-left: 40px; + max-width: 600px; + margin-right: 0; +} + +#content blockquote blockquote { + margin-left: 10px; + padding-left: 10px; + margin-right: 20px; + border-left: dotted 1px; + padding-top: 6px; + padding-bottom: 6px; +} + +#content blockquote blockquote > :first-child { + margin-top: 0; +} + +#content blockquote blockquote > :last-child { + margin-bottom: 0; +} + +main.long-content { + --long-content-padding-ratio: 0.10; +} + +main.long-content .main-content-container, +main.long-content > h1 { + padding-left: calc(var(--long-content-padding-ratio) * 100%); + padding-right: calc(var(--long-content-padding-ratio) * 100%); +} + +dl dt { + padding-left: 40px; + max-width: 600px; +} + +dl dt { + margin-bottom: 2px; +} + +dl dd { + margin-bottom: 1em; +} + +dl ul, +dl ol { + margin-top: 0; + margin-bottom: 0; +} + +ul > li.has-details { + list-style-type: none; + margin-left: -17px; +} + +.album-group-list dt { + font-style: oblique; + padding-left: 0; +} + +.album-group-list dd { + margin-left: 0; +} + +.group-chronology-link { + font-style: oblique; +} + +#content hr { + border: 1px inset #808080; + width: 100%; +} + +#content hr.split::before { + content: "(split)"; + color: #808080; +} + +#content hr.split { + position: relative; + overflow: hidden; + border: none; +} + +#content hr.split::after { + display: inline-block; + content: ""; + border: 1px inset #808080; + width: 100%; + position: absolute; + top: 50%; + margin-top: -2px; + margin-left: 10px; +} + +li > ul { + margin-top: 5px; +} + +.group-contributions-table { + display: inline-block; +} + +.group-contributions-table .group-contributions-row { + display: flex; + justify-content: space-between; +} + +.group-contributions-table .group-contributions-metrics { + margin-left: 1.5ch; + white-space: nowrap; +} + +.group-contributions-sorted-by-count:not(.visible), +.group-contributions-sorted-by-duration:not(.visible) { + display: none; +} + +html[data-url-key="localized.albumCommentary"] li.no-commentary { + opacity: 0.7; +} + +html[data-url-key="localized.albumCommentary"] .content-heading-main-title { + margin-right: 0.25em; +} + +html[data-url-key="localized.albumCommentary"] .content-heading-accent { + font-weight: normal; + font-style: oblique; + font-size: 0.9rem; + display: inline-block; +} + +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { + display: none; +} + +html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { + opacity: 0.7; +} + +/* Additional names (heading and box) */ + +h1 a[href="#additional-names-box"] { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; +} + +h1 a[href="#additional-names-box"]:hover { + text-decoration-style: solid; +} + +#additional-names-box { + --custom-scroll-offset: calc(0.5em - 2px); + + margin: 1em 0 1em -10px; + padding: 15px 20px 10px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + + display: none; +} + +#additional-names-box > :first-child { margin-top: 0; } +#additional-names-box > :last-child { margin-bottom: 0; } + +#additional-names-box p { + padding-left: 10px; + padding-right: 10px; + margin-bottom: 0; + font-style: oblique; +} + +#additional-names-box ul { + padding-left: 10px; + margin-top: 0.5em; +} + +#additional-names-box li .additional-name { + margin-right: 0.25em; +} + +#additional-names-box li .additional-name .content-image { + margin-bottom: 0.25em; + margin-top: 0.5em; +} + +#additional-names-box li .annotation { + opacity: 0.8; + display: inline-block; +} + +/* Images */ + +.image-container { + border: 2px solid var(--primary-color); + box-sizing: border-box; + position: relative; + padding: 5px; + text-align: left; + background-color: var(--dim-color); + color: white; + display: inline-block; + height: 100%; +} + +.image-text-area { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 5px 15px; + background: rgba(0, 0, 0, 0.65); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset; + line-height: 1.35em; + color: var(--primary-color); + font-style: oblique; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); +} + +.image-inner-area { + width: 100%; + height: 100%; +} + +img { + object-fit: cover; +} + +.reveal { + position: relative; + width: 100%; + height: 100%; +} + +.reveal img { + filter: blur(20px); + opacity: 0.4; +} + +.reveal-text-container { + position: absolute; + top: 15px; + left: 10px; + right: 10px; + bottom: 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.reveal-text { + color: white; + text-align: center; + font-weight: bold; +} + +.reveal-interaction { + opacity: 0.8; +} + +.reveal.revealed img { + filter: none; + opacity: 1; +} + +.reveal.revealed .reveal-text { + display: none; +} + +.sidebar .image-container { + max-width: 350px; +} + +/* Grid listings */ + +.grid-listing { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + padding: 5px 15px; +} + +.grid-item { + font-size: 0.9em; +} + +.grid-item { + display: inline-block; + text-align: center; + background-color: #111111; + border: 1px dotted var(--primary-color); + border-radius: 2px; + padding: 5px; + margin: 10px; +} + +.grid-item img { + width: 100%; + height: 100% !important; + margin-top: auto; + margin-bottom: auto; +} + +.grid-item:hover { + text-decoration: none; +} + +.grid-actions .grid-item:hover { + text-decoration: underline; +} + +.grid-item > span { + display: block; + overflow-wrap: break-word; + hyphens: auto; +} + +.grid-item > span:not(:first-child) { + margin-top: 2px; +} + +.grid-item > span:first-of-type { + margin-top: 6px; +} + +.grid-item > span:not(:first-of-type) { + font-size: 0.9em; + opacity: 0.8; +} + +.grid-item:hover > span:first-of-type { + text-decoration: underline; +} + +.grid-listing > .grid-item { + flex: 1 25%; + max-width: 200px; +} + +.grid-actions { + display: flex; + flex-direction: row; + margin: 15px; + align-self: center; + flex-wrap: wrap; + justify-content: center; +} + +.grid-actions > .grid-item { + flex-basis: unset !important; + margin: 5px; + width: 120px; + --primary-color: inherit !important; + --dim-color: inherit !important; +} + +/* Carousel */ + +.carousel-container { + --carousel-tile-min-width: 120px; + --carousel-row-count: 3; + --carousel-column-count: 6; + + position: relative; + overflow: hidden; + margin: 20px 0 5px 0; + padding: 8px 0; +} + +.carousel-container::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -20; + background-color: var(--dim-color); + filter: brightness(0.6); +} + +.carousel-container::after { + content: ""; + pointer-events: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 1px solid var(--primary-color); + border-radius: 4px; + z-index: 40; + box-shadow: + inset 20px 2px 40px var(--shadow-color), + inset -20px -2px 40px var(--shadow-color); +} + +.carousel-container:hover .carousel-grid { + animation-play-state: running; +} + +html[data-url-key="localized.home"] .carousel-container { + --carousel-tile-size: 140px; +} + +.carousel-container[data-carousel-rows="1"] { --carousel-row-count: 1; } +.carousel-container[data-carousel-rows="2"] { --carousel-row-count: 2; } +.carousel-container[data-carousel-rows="3"] { --carousel-row-count: 3; } +.carousel-container[data-carousel-columns="4"] { --carousel-column-count: 4; } +.carousel-container[data-carousel-columns="5"] { --carousel-column-count: 5; } +.carousel-container[data-carousel-columns="6"] { --carousel-column-count: 6; } + +.carousel-grid:nth-child(2), +.carousel-grid:nth-child(3) { + position: absolute; + top: 8px; + left: 0; + right: 0; +} + +.carousel-grid:nth-child(2) { + animation-name: carousel-marquee2; +} + +.carousel-grid:nth-child(3) { + animation-name: carousel-marquee3; +} + +@keyframes carousel-marquee1 { + 0% { + transform: translateX(-100%) translateX(70px); + } + + 100% { + transform: translateX(-200%) translateX(70px); + } +} + +@keyframes carousel-marquee2 { + 0% { + transform: translateX(0%) translateX(70px); + } + + 100% { + transform: translateX(-100%) translateX(70px); + } +} + +@keyframes carousel-marquee3 { + 0% { + transform: translateX(100%) translateX(70px); + } + + 100% { + transform: translateX(0%) translateX(70px); + } +} + +.carousel-grid { + /* Thanks to: https://css-tricks.com/an-auto-filling-css-grid-with-max-columns/ */ + --carousel-gap-count: calc(var(--carousel-column-count) - 1); + --carousel-total-gap-width: calc(var(--carousel-gap-count) * 10px); + --carousel-calculated-tile-max-width: calc((100% - var(--carousel-total-gap-width)) / var(--carousel-column-count)); + + display: grid; + grid-template-columns: repeat(auto-fill, minmax(max(var(--carousel-tile-min-width), var(--carousel-calculated-tile-max-width)), 1fr)); + grid-template-rows: repeat(var(--carousel-row-count), auto); + grid-auto-flow: dense; + grid-auto-rows: 0; + overflow: hidden; + margin: auto; + + transform: translateX(0); + animation: carousel-marquee1 40s linear infinite; + animation-play-state: paused; + z-index: 5; +} + +.carousel-item { + display: inline-block; + margin: 0; + flex: 1 1 150px; + padding: 3px; + border-radius: 10px; + filter: brightness(0.8); +} + +.carousel-item .image-container { + border: none; + padding: 0; +} + +.carousel-item img { + width: 100%; + height: 100%; + margin-top: auto; + margin-bottom: auto; + border-radius: 6px; +} + +.carousel-item:hover { + filter: brightness(1); + background: var(--dim-color); +} + +/* Squares */ + +.square { + position: relative; + width: 100%; +} + +.square::after { + content: ""; + display: block; + padding-bottom: 100%; +} + +.square-content { + position: absolute; + width: 100%; + height: 100%; +} + +/* Info card */ + +#info-card-container { + position: absolute; + + left: 0; + right: 10px; + + pointer-events: none; /* Padding area shouldn't 8e interactive. */ + display: none; +} + +#info-card-container.show, +#info-card-container.hide { + display: flex; +} + +#info-card-container > * { + flex-basis: 400px; +} + +#info-card-container.show { + animation: 0.2s linear forwards info-card-show; + transition: top 0.1s, left 0.1s; +} + +#info-card-container.hide { + animation: 0.2s linear forwards info-card-hide; +} + +@keyframes info-card-show { + 0% { + opacity: 0; + margin-top: -5px; + } + + 100% { + opacity: 1; + margin-top: 0; + } +} + +@keyframes info-card-hide { + 0% { + opacity: 1; + margin-top: 0; + } + + 100% { + opacity: 0; + margin-top: 5px; + display: none !important; + } +} + +.info-card-decor { + padding-left: 3ch; + border-top: 1px solid white; +} + +.info-card { + background-color: black; + color: white; + + border: 1px dotted var(--primary-color); + border-radius: 3px; + box-shadow: 0 5px 5px black; + + padding: 5px; + font-size: 0.9em; + + pointer-events: none; +} + +.info-card::after { + content: ""; + display: block; + clear: both; +} + +#info-card-container.show .info-card { + animation: 0.01s linear 0.2s forwards info-card-become-interactive; +} + +@keyframes info-card-become-interactive { + to { + pointer-events: auto; + } +} + +.info-card-art-container { + float: right; + + width: 40%; + margin: 5px; + font-size: 0.8em; + + /* Dynamically shown. */ + display: none; +} + +.info-card-art-container .image-container { + padding: 2px; +} + +.info-card-art { + display: block; + width: 100%; + height: 100%; +} + +.info-card-name { + font-size: 1em; + border-bottom: 1px dotted; + margin: 0; +} + +.info-card p { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + +.info-card p:last-child { + margin-bottom: 0; +} + +/* Custom hash links */ + +.content-heading { + border-bottom: 3px double transparent; + margin-bottom: -3px; +} + +.content-heading.highlight-hash-link { + animation: highlight-hash-link 4s; + animation-delay: 125ms; +} + +h3.content-heading { + clear: both; +} + +/* This animation's name is referenced in JavaScript */ +@keyframes highlight-hash-link { + 0% { + border-bottom-color: transparent; + } + + 10% { + border-bottom-color: white; + } + + 25% { + border-bottom-color: white; + } + + 100% { + border-bottom-color: transparent; + } +} + +/* Sticky heading */ + +[id] { + --custom-scroll-offset: 0px; +} + +#content [id] { + /* Adjust scroll margin. */ + scroll-margin-top: calc( + 74px /* Sticky heading */ + + 33px /* Sticky subheading */ + - 1em /* One line of text (align bottom) */ + - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ + ); +} + +.content-sticky-heading-container { + position: sticky; + top: 0; + + margin: calc(-1 * var(--content-padding)); + margin-bottom: calc(0.5 * var(--content-padding)); + + transform: translateY(-5px); +} + +main.long-content .content-sticky-heading-container { + padding-left: 0; + padding-right: 0; +} + +main.long-content .content-sticky-heading-container .content-sticky-heading-row, +main.long-content .content-sticky-heading-container .content-sticky-subheading-row { + padding-left: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); + padding-right: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); +} + +.content-sticky-heading-row { + box-sizing: border-box; + padding: + calc(1.25 * var(--content-padding) + 5px) + 20px + calc(0.75 * var(--content-padding)) + 20px; + + width: 100%; + margin: 0; + + background: var(--bg-black-color); + border-bottom: 1px dotted rgba(220, 220, 220, 0.4); + + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.content-sticky-heading-container.has-cover .content-sticky-heading-row, +.content-sticky-heading-container.has-cover .content-sticky-subheading-row { + display: grid; + grid-template-areas: + "title cover"; + grid-template-columns: 1fr min(40%, 400px); +} + +.content-sticky-heading-row h1 { + margin: 0; + padding-right: 10px; +} + +.content-sticky-heading-cover-container { + position: relative; + height: 0; + margin: -15px 0px -5px -5px; +} + +.content-sticky-heading-cover-needs-reveal { + display: none; +} + +.content-sticky-heading-cover { + position: absolute; + top: 0; + width: 80px; + right: 10px; + box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.25); + transition: transform 0.35s, opacity 0.25s; +} + +.content-sticky-heading-cover-container:not(.visible) .content-sticky-heading-cover { + opacity: 0; + transform: translateY(15px); +} + +.content-sticky-heading-cover .image-container { + border-width: 1px; + padding: 2px; +} + +.content-sticky-heading-cover img { + display: block; + width: 100%; + height: 100%; +} + +.content-sticky-subheading-row { + position: absolute; + width: 100%; + box-sizing: border-box; + padding: 10px 40px 5px 20px; + margin-top: 0; + z-index: -1; + + background: var(--bg-black-color); + border-bottom: 1px dotted rgba(220, 220, 220, 0.4); + + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + + transition: margin-top 0.35s, opacity 0.25s; +} + +.content-sticky-subheading-row h2 { + margin: 0; + + font-size: 0.9em !important; + font-weight: normal; + font-style: oblique; + color: #eee; +} + +.content-sticky-subheading-row:not(.visible) { + margin-top: -20px; + opacity: 0; +} + +.content-sticky-heading-container h2.visible { + margin-top: 0; + opacity: 1; +} + +.content-sticky-heading-row { + box-shadow: + inset 0 10px 10px -5px var(--shadow-color), + 0 4px 4px rgba(0, 0, 0, 0.8); +} + +.content-sticky-heading-container h2.visible { + box-shadow: + inset 0 10px 10px -5px var(--shadow-color), + 0 4px 4px rgba(0, 0, 0, 0.8); +} + +#content, .sidebar { + contain: paint; +} + +/* Sticky sidebar */ + +.sidebar-column.sidebar.sticky-column, +.sidebar-column.sidebar.sticky-last, +.sidebar-multiple.sticky-last > .sidebar:last-child, +.sidebar-multiple.sticky-column { + position: sticky; + top: 10px; +} + +.sidebar-multiple.sticky-last { + align-self: stretch; +} + +.sidebar-multiple.sticky-column { + align-self: flex-start; +} + +.sidebar-column.sidebar.sticky-column { + max-height: calc(100vh - 20px); + align-self: start; + padding-bottom: 0; + box-sizing: border-box; + flex-basis: 275px; + padding-top: 0; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--dark-color); +} + +.sidebar-column.sidebar.sticky-column::-webkit-scrollbar { + background: var(--dark-color); + width: 12px; +} + +.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb { + transition: background 0.2s; + background: rgba(255, 255, 255, 0.2); + border: 3px solid transparent; + border-radius: 10px; + background-clip: content-box; +} + +.sidebar-column.sidebar.sticky-column > h1 { + position: sticky; + top: 0; + margin: 0 calc(-1 * var(--content-padding)); + margin-bottom: 10px; + + border-bottom: 1px dotted rgba(220, 220, 220, 0.4); + padding: 10px 5px; + + background: var(--bg-black-color); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +/* Image overlay */ + +#image-overlay-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 20px 40px; + box-sizing: border-box; + + opacity: 0; + pointer-events: none; + transition: opacity 0.4s; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +#image-overlay-container.visible { + opacity: 1; + pointer-events: auto; +} + +#image-overlay-content-container { + border-radius: 0 0 8px 8px; + border: 2px solid var(--primary-color); + background: var(--dim-ghost-color); + padding: 3px; + overflow: hidden; + + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +#image-overlay-image-container { + display: block; + position: relative; + overflow: hidden; + width: 80vmin; + height: 80vmin; +} + +#image-overlay-image, +#image-overlay-image-thumb { + display: inline-block; + object-fit: contain; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.65); +} + +#image-overlay-image { + position: absolute; + top: 0; + left: 0; +} + +#image-overlay-image-thumb { + filter: blur(16px); + transform: scale(1.5); +} + +#image-overlay-container.loaded #image-overlay-image-thumb { + opacity: 0; + pointer-events: none; + transition: opacity 0.25s; +} + +#image-overlay-image-container::after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + height: 4px; + width: var(--download-progress); + background: var(--primary-color); + box-shadow: 0 -3px 12px 4px var(--primary-color); + transition: 0.25s; +} + +#image-overlay-container.loaded #image-overlay-image-container::after { + width: 100%; + background: white; + opacity: 0; +} + +#image-overlay-container.errored #image-overlay-image-container::after { + width: 100%; + background: red; +} + +#image-overlay-container:not(.visible) #image-overlay-image-container::after { + width: 0 !important; +} + +#image-overlay-action-container { + padding: 4px 4px 6px 4px; + border-radius: 0 0 5px 5px; + background: var(--bg-black-color); + color: white; + font-style: oblique; + text-align: center; +} + +#image-overlay-container #image-overlay-action-content-without-size:not(.visible), +#image-overlay-container #image-overlay-action-content-with-size:not(.visible), +#image-overlay-container #image-overlay-file-size-warning:not(.visible), +#image-overlay-container #image-overlay-file-size-kilobytes:not(.visible), +#image-overlay-container #image-overlay-file-size-megabytes:not(.visible) { + display: none; +} + +#image-overlay-file-size-warning { + opacity: 0.8; + font-size: 0.9em; +} + +/* important easter egg mode */ + +html[data-language-code="preview-en"][data-url-key="localized.home"] #content + h1::after { + font-family: cursive; + display: block; + content: "(Preview Build)"; + font-size: 0.8em; +} + +/* Layout - Wide (most computers) */ + +@media (min-width: 900px) { + #page-container:not(.has-zero-sidebars) #secondary-nav { + display: none; + } +} + +/* Layout - Medium (tablets, some landscape mobiles) + * + * Note: Rules defined here are exclusive to "medium" width, i.e. they don't + * additionally apply to "thin". Use the later section which applies to both + * if so desired. + */ + +@media (min-width: 600px) and (max-width: 899.98px) { + /* Medim layout is mainly defined (to the user) by hiding the sidebar, so + * don't apply the similar layout change of widening the long-content area + * if this page doesn't have a sidebar to hide in the first place. + */ + #page-container:not(.has-zero-sidebars) main.long-content { + --long-content-padding-ratio: 0.06; + } +} + +/* Layout - Wide or Medium */ + +@media (min-width: 600px) { + .content-sticky-heading-container { + /* Safari doesn't always play nicely with position: sticky, + * this seems to fix images sometimes displaying above the + * position: absolute subheading (h2) child + * + * See also: https://stackoverflow.com/questions/50224855/ + */ + transform: translate3d(0, 0, 0); + z-index: 1; + } + + /* Cover art floats to the right. It's positioned in HTML beneath the + * heading, so pull it up a little to "float" on top. + */ + #cover-art-container { + float: right; + width: 40%; + max-width: 400px; + margin: -60px 0 10px 20px; + + position: relative; + z-index: 2; + } + + html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+10)) { + flex-basis: 23%; + margin: 15px; + } + + html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+10) { + flex-basis: 18%; + margin: 10px; + } +} + +/* Layout - Medium or Thin */ + +@media (max-width: 899.98px) { + .sidebar-column:not(.no-hide) { + display: none; + } + + #secondary-nav { + display: block; + } + + .layout-columns.vertical-when-thin { + flex-direction: column; + } + + .layout-columns.vertical-when-thin > *:not(:last-child) { + margin-bottom: 10px; + } + + .sidebar-column.no-hide { + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + width: 100%; + } + + .sidebar .news-entry:not(.first-news-entry) { + display: none; + } + + .grid-listing > .grid-item { + flex-basis: 40%; + } +} + +/* Layout - Thin (phones) */ + +@media (max-width: 600px) { + .content-columns { + columns: 1; + } + + main.long-content { + --long-content-padding-ratio: 0.02; + } + + #cover-art-container { + margin: 25px 0 5px 0; + width: 100%; + max-width: unset; + } + + #additional-names-box { + max-width: unset; + } + + /* Show sticky heading above cover art */ + + .content-sticky-heading-container { + z-index: 2; + } + + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + + /* Disable grid features, just line header children up vertically */ + + #header { + display: block; + } + + #header > div:not(:first-child) { + margin-top: 0.5em; + } +} -- cgit 1.3.0-6-gf8a5 From 692bd586495e5fcb6d7c5eae2af6a8c34b380b50 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:40:00 -0400 Subject: content: generateCommentaryIndexPage: handle entries individually --- src/content/dependencies/generateCommentaryIndexPage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js index 1d381bff..5d38941a 100644 --- a/src/content/dependencies/generateCommentaryIndexPage.js +++ b/src/content/dependencies/generateCommentaryIndexPage.js @@ -19,13 +19,13 @@ export default { query.albums.map(album => [album, ...album.tracks] .filter(({commentary}) => commentary) - .map(({commentary}) => commentary)); + .flatMap(({commentary}) => commentary)); query.wordCounts = entries.map(entries => accumulateSum( entries, - entry => entry.split(' ').length)); + entry => entry.body.split(' ').length)); query.entryCounts = entries.map(entries => entries.length); -- cgit 1.3.0-6-gf8a5 From 09f67bdb9438618fe8632d0d8ceda8fee12b8cb7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 13:47:40 -0400 Subject: content: generateAlbumCommentaryPage: miscellaneous fixes/updates --- src/content/dependencies/generateAlbumCommentaryPage.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 442d72a7..5a7142e5 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -107,11 +107,15 @@ export default { ? [album, ...tracksWithCommentary] : tracksWithCommentary); - data.entryCount = thingsWithCommentary.length; + data.entryCount = + thingsWithCommentary + .flatMap(({commentary}) => commentary) + .length; data.wordCount = thingsWithCommentary - .map(({commentary}) => commentary) + .flatMap(({commentary}) => commentary) + .map(({body}) => body) .join(' ') .split(' ') .length; @@ -156,7 +160,7 @@ export default { language.countCommentaryEntries(data.entryCount, {unit: true})), })), - relations.albumCommentaryContent && [ + relations.albumCommentaryEntries && [ relations.albumCommentaryHeading.slots({ tag: 'h3', color: data.color, @@ -173,7 +177,7 @@ export default { language.formatUnitList( relations.albumCommentaryListeningLinks .map(link => link.slots({ - mode: 'album', + context: 'album', tab: 'separate', }))), }), @@ -182,8 +186,7 @@ export default { relations.albumCommentaryCover ?.slots({mode: 'commentary'}), - html.tag('blockquote', - relations.albumCommentaryContent), + relations.albumCommentaryEntries, ], stitchArrays({ -- cgit 1.3.0-6-gf8a5 From 49537d408b17f7583cd00d0866f5de6797a0591e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 17:32:28 -0400 Subject: data: shared & inferred additional names (for tracks) --- src/data/composite/things/track/index.js | 3 + .../things/track/trackAdditionalNameList.js | 38 ++++++++++ .../things/track/withInferredAdditionalNames.js | 80 ++++++++++++++++++++++ .../things/track/withSharedAdditionalNames.js | 46 +++++++++++++ src/data/things/track.js | 4 +- 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/data/composite/things/track/trackAdditionalNameList.js create mode 100644 src/data/composite/things/track/withInferredAdditionalNames.js create mode 100644 src/data/composite/things/track/withSharedAdditionalNames.js diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index 3354b1c4..b5f1e3e2 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,9 +1,12 @@ export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js'; +export {default as trackAdditionalNameList} from './trackAdditionalNameList.js'; export {default as trackReverseReferenceList} from './trackReverseReferenceList.js'; export {default as withAlbum} from './withAlbum.js'; export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js'; export {default as withContainingTrackSection} from './withContainingTrackSection.js'; export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; +export {default as withInferredAdditionalNames} from './withInferredAdditionalNames.js'; export {default as withOtherReleases} from './withOtherReleases.js'; export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; +export {default as withSharedAdditionalNames} from './withSharedAdditionalNames.js'; diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js new file mode 100644 index 00000000..65a2263d --- /dev/null +++ b/src/data/composite/things/track/trackAdditionalNameList.js @@ -0,0 +1,38 @@ +// Compiles additional names from various sources. + +import {input, templateCompositeFrom} from '#composite'; +import {isAdditionalNameList} from '#validators'; + +import withInferredAdditionalNames from './withInferredAdditionalNames.js'; +import withSharedAdditionalNames from './withSharedAdditionalNames.js'; + +export default templateCompositeFrom({ + annotation: `trackAdditionalNameList`, + + compose: false, + + update: {validate: isAdditionalNameList}, + + steps: () => [ + withInferredAdditionalNames(), + withSharedAdditionalNames(), + + { + dependencies: [ + '#inferredAdditionalNames', + '#sharedAdditionalNames', + input.updateValue(), + ], + + compute: ({ + ['#inferredAdditionalNames']: inferredAdditionalNames, + ['#sharedAdditionalNames']: sharedAdditionalNames, + [input.updateValue()]: providedAdditionalNames, + }) => [ + ...providedAdditionalNames ?? [], + ...sharedAdditionalNames, + ...inferredAdditionalNames, + ], + }, + ], +}); diff --git a/src/data/composite/things/track/withInferredAdditionalNames.js b/src/data/composite/things/track/withInferredAdditionalNames.js new file mode 100644 index 00000000..659d6b8f --- /dev/null +++ b/src/data/composite/things/track/withInferredAdditionalNames.js @@ -0,0 +1,80 @@ +// Infers additional name entries from other releases that were titled +// differently, linking to the respective release via annotation. + +import {input, templateCompositeFrom} from '#composite'; +import {stitchArrays} from '#sugar'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertiesFromList, withPropertyFromList} from '#composite/data'; + +import withOtherReleases from './withOtherReleases.js'; + +export default templateCompositeFrom({ + annotation: `withInferredAdditionalNames`, + + outputs: ['#inferredAdditionalNames'], + + steps: () => [ + withOtherReleases(), + + raiseOutputWithoutDependency({ + dependency: '#otherReleases', + mode: input.value('empty'), + output: input.value({'#inferredAdditionalNames': []}), + }), + + { + dependencies: ['#otherReleases', 'name'], + compute: (continuation, { + ['#otherReleases']: otherReleases, + ['name']: name, + }) => continuation({ + ['#differentlyNamedReleases']: + otherReleases.filter(release => release.name !== name), + }), + }, + + withPropertiesFromList({ + list: '#differentlyNamedReleases', + properties: input.value(['name', 'directory', 'album']), + }), + + withPropertyFromList({ + list: '#differentlyNamedReleases.album', + property: input.value('name'), + }), + + { + dependencies: [ + '#differentlyNamedReleases.directory', + '#differentlyNamedReleases.album.name', + ], + + compute: (continuation, { + ['#differentlyNamedReleases.directory']: trackDirectories, + ['#differentlyNamedReleases.album.name']: albumNames, + }) => continuation({ + ['#annotations']: + stitchArrays({ + trackDirectory: trackDirectories, + albumName: albumNames, + }).map(({trackDirectory, albumName}) => + `[[track:${trackDirectory}|on ${albumName}]]`) + }) + }, + + { + dependencies: ['#differentlyNamedReleases.name', '#annotations'], + compute: (continuation, { + ['#differentlyNamedReleases.name']: names, + ['#annotations']: annotations, + }) => continuation({ + ['#inferredAdditionalNames']: + stitchArrays({ + name: names, + annotation: annotations, + }), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withSharedAdditionalNames.js b/src/data/composite/things/track/withSharedAdditionalNames.js new file mode 100644 index 00000000..d205dc89 --- /dev/null +++ b/src/data/composite/things/track/withSharedAdditionalNames.js @@ -0,0 +1,46 @@ +// Compiles additional names directly provided on other releases. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFlattenedList} from '#composite/data'; + +import CacheableObject from '#cacheable-object'; + +import withOtherReleases from './withOtherReleases.js'; + +export default templateCompositeFrom({ + annotation: `withSharedAdditionalNames`, + + outputs: ['#sharedAdditionalNames'], + + steps: () => [ + withOtherReleases(), + + raiseOutputWithoutDependency({ + dependency: '#otherReleases', + mode: input.value('empty'), + output: input.value({'#inferredAdditionalNames': []}), + }), + + // TODO: Using getUpdateValue is always a bit janky. + + { + dependencies: ['#otherReleases'], + compute: (continuation, { + ['#otherReleases']: otherReleases, + }) => continuation({ + ['#otherReleases.additionalNames']: + otherReleases.map(release => + CacheableObject.getUpdateValue(release, 'additionalNames') + ?? []), + }), + }, + + withFlattenedList({ + list: '#otherReleases.additionalNames', + }).outputs({ + '#flattenedList': '#sharedAdditionalNames', + }), + ], +}); diff --git a/src/data/things/track.js b/src/data/things/track.js index f6320677..1f99ef53 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -24,7 +24,6 @@ import { import { additionalFiles, - additionalNameList, commentary, commentatorArtists, contributionList, @@ -44,6 +43,7 @@ import { import { exitWithoutUniqueCoverArt, inheritFromOriginalRelease, + trackAdditionalNameList, trackReverseReferenceList, withAlbum, withAlwaysReferenceByDirectory, @@ -64,7 +64,7 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), - additionalNames: additionalNameList(), + additionalNames: trackAdditionalNameList(), duration: duration(), urls: urls(), -- cgit 1.3.0-6-gf8a5 From 8238f11469c64e6a2a735ca43a70e2a665ef63f1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 17:32:54 -0400 Subject: data: minor fixes caught by eslint --- src/data/composite/wiki-properties/commentatorArtists.js | 1 - src/data/things/language.js | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index f400bbfc..c5c14769 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -2,7 +2,6 @@ // This is mostly useful for credits and listings on artist pages. import {input, templateCompositeFrom} from '#composite'; -import {unique} from '#sugar'; import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; diff --git a/src/data/things/language.js b/src/data/things/language.js index 70481299..d8af9620 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -331,6 +331,8 @@ export class Language extends Thing { }); } + isExternalLinkStyle(style); + return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { language: this, context, -- cgit 1.3.0-6-gf8a5 From f03ea65a10124d8962609f03d4df84be1531db17 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 18:16:09 -0400 Subject: css: adjust padding box around tooltip --- src/static/site6.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/static/site6.css b/src/static/site6.css index 4c083527..884cfca6 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -490,9 +490,9 @@ a:not([href]):hover { .icons-tooltip { position: absolute; z-index: 3; - left: -36px; - top: calc(1em - 2px); - padding: 4px 12px 6px 8px; + left: -34px; + top: calc(1em + 1px); + padding: 3px 6px 6px 6px; } .icons-tooltip:not(.visible) { -- cgit 1.3.0-6-gf8a5 From 15bc6d580ec2b3a754ff3dc17e9eb24bc90e052a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 18:16:34 -0400 Subject: client, css: style hovered/active tooltip links wavy --- src/static/client3.js | 4 ++++ src/static/site6.css | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/static/client3.js b/src/static/client3.js index 866b9ba2..390d020e 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -754,6 +754,8 @@ function hideCurrentlyShownTooltip() { // Never hide the tooltip if it's focused. if (currentlyShownTooltipHasFocus()) return false; + state.currentlyActiveHoverable.classList.remove('has-visible-tooltip'); + state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -774,6 +776,8 @@ function showTooltipFromHoverable(hoverable) { if (!hideCurrentlyShownTooltip()) return false; + hoverable.classList.add('has-visible-tooltip'); + state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; diff --git a/src/static/site6.css b/src/static/site6.css index 884cfca6..830e32f2 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -482,6 +482,11 @@ a:not([href]):hover { text-decoration-style: dotted; } +.contribution.has-tooltip > a:hover, +.contribution.has-tooltip > a.has-visible-tooltip { + text-decoration-style: wavy !important; +} + .icons { font-style: normal; white-space: nowrap; -- cgit 1.3.0-6-gf8a5 From ea3c4655c3023dee609865a0928ce52303a8e363 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:06:34 -0400 Subject: client, css: transition tooltips hidden --- src/static/client3.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++----- src/static/site6.css | 8 +++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 390d020e..285a5ef6 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -383,6 +383,10 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { focusInfoDelay: 750, hideTooltipDelay: 500, + + // If a tooltip that's transitioning to hidden is hovered, it'll cancel + // out of this animation immediately. + transitionHiddenDuration: 300, }, state: { @@ -399,8 +403,10 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { focusTimeout: null, touchTimeout: null, hideTimeout: null, + transitionHiddenTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, + currentlyTransitioningHiddenTooltip: null, tooltipWasJustHidden: false, hoverableWasRecentlyTouched: false, @@ -548,21 +554,36 @@ function handleTooltipReceivedFocus(tooltip) { function handleTooltipLostFocus(tooltip) { const {settings, state} = hoverableTooltipInfo; - // Hide the current tooltip right away when it loses focus. - hideCurrentlyShownTooltip(); + // Hide the current tooltip right away when it loses focus. Specify intent + // to replace - while we don't strictly know if another tooltip is going to + // immediately replace it, the mode of navigating with tab focus (once one + // tooltip has been activated) is a "switch focus immediately" kind of + // interaction in its nature. + hideCurrentlyShownTooltip(true); } function handleTooltipHoverableMouseEntered(hoverable) { const {event, settings, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // If this tooltip was transitioning to hidden, hovering should cancel that + // animation and show it immediately. + + if (tooltip === state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + showTooltipFromHoverable(hoverable); + return; + } + + // Start a timer to show the corresponding tooltip, with the delay depending + // on whether fast hovering or not. This could be canceled by mousing out of + // the hoverable. const hoverTimeoutDelay = (state.fastHovering ? settings.fastHoveringInfoDelay : settings.normalHoverInfoDelay); - // Start a timer to show the corresponding tooltip, with the delay depending - // on whether fast hovering or not. This could be canceled by mousing out of - // the hoverable. state.hoverTimeout = setTimeout(() => { state.hoverTimeout = null; @@ -650,9 +671,10 @@ function handleTooltipHoverableLostFocus(hoverable, domEvent) { // Unless focus is entering the tooltip itself, hide the tooltip immediately. // This will set the tooltipWasJustHidden flag, which is detected by a newly - // focused hoverable, if applicable. + // focused hoverable, if applicable. Always specify intent to replace when + // navigating via tab focus. (Check `handleTooltipLostFocus` for details.) if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { - hideCurrentlyShownTooltip(); + hideCurrentlyShownTooltip(true); } } @@ -743,7 +765,48 @@ function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { return false; } -function hideCurrentlyShownTooltip() { +function beginTransitioningTooltipHidden(tooltip) { + const {settings, state} = hoverableTooltipInfo; + + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + } + + tooltip.classList.add('transition-tooltip-hidden'); + tooltip.style.transitionDuration = + `${settings.transitionHiddenDuration / 1000}s`; + + state.currentlyTransitioningHiddenTooltip = tooltip; + state.transitionHiddenTimeout = + setTimeout(() => { + endTransitioningTooltipHidden(); + }, settings.transitionHiddenDuration); +} + +function cancelTransitioningTooltipHidden() { + const {state} = hoverableTooltipInfo; + + endTransitioningTooltipHidden(); + + if (state.transitionHiddenTimeout) { + clearTimeout(state.transitionHiddenTimeout); + state.transitionHiddenTimeout = null; + } +} + +function endTransitioningTooltipHidden() { + const {state} = hoverableTooltipInfo; + const {currentlyTransitioningHiddenTooltip: tooltip} = state; + + if (!tooltip) return; + + tooltip.classList.remove('transition-tooltip-hidden'); + tooltip.style.removeProperty('transition-duration'); + + state.currentlyTransitioningHiddenTooltip = null; +} + +function hideCurrentlyShownTooltip(intendingToReplace = false) { const {event, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; @@ -756,6 +819,14 @@ function hideCurrentlyShownTooltip() { state.currentlyActiveHoverable.classList.remove('has-visible-tooltip'); + // If there's no intent to replace this tooltip, it's the last one currently + // apparent in the interaction, and should be hidden with a transition. + if (intendingToReplace) { + cancelTransitioningTooltipHidden(); + } else { + beginTransitioningTooltipHidden(state.currentlyShownTooltip); + } + state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -774,7 +845,13 @@ function showTooltipFromHoverable(hoverable) { const {event, state} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); - if (!hideCurrentlyShownTooltip()) return false; + if (!hideCurrentlyShownTooltip(true)) return false; + + // Cancel out another tooltip that's transitioning hidden, if that's going + // on - it's a distraction that this tooltip is now replacing. + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + } hoverable.classList.add('has-visible-tooltip'); diff --git a/src/static/site6.css b/src/static/site6.css index 830e32f2..b06417db 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -504,6 +504,14 @@ a:not([href]):hover { display: none; } +.icons-tooltip:not(.visible).transition-tooltip-hidden { + display: block !important; + opacity: 0; + + transition-property: opacity; + transition-timing-function: linear; +} + .icons-tooltip-content { display: block; padding: 6px 2px 2px 2px; -- cgit 1.3.0-6-gf8a5 From db786d00e450396e680686e95db97ec353fe32f8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:07:26 -0400 Subject: client, css: define tooltip transitions 100% in JS --- src/static/client3.js | 40 +++++++++++++++++++++++++++++++++------- src/static/site6.css | 8 -------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 285a5ef6..59e889a8 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -63,8 +63,25 @@ function pick(array) { return array[Math.floor(Math.random() * array.length)]; } -function cssProp(el, key) { - return getComputedStyle(el).getPropertyValue(key).trim(); +function cssProp(el, ...args) { + if (typeof args[0] === 'string' && args.length === 1) { + return getComputedStyle(el).getPropertyValue(args[0]).trim(); + } + + if (typeof args[0] === 'string' && args.length === 2) { + if (args[1] === null) { + el.style.removeProperty(args[0]); + } else { + el.style.setProperty(args[0], args[1]); + } + return; + } + + if (typeof args[0] === 'object') { + for (const [property, value] of Object.entries(args[0])) { + cssProp(el, property, value); + } + } } // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to @@ -772,9 +789,13 @@ function beginTransitioningTooltipHidden(tooltip) { cancelTransitioningTooltipHidden(); } - tooltip.classList.add('transition-tooltip-hidden'); - tooltip.style.transitionDuration = - `${settings.transitionHiddenDuration / 1000}s`; + cssProp(tooltip, { + 'display': 'block', + 'opacity': '0', + 'transition-property': 'opacity', + 'transition-timing-function': 'linear', + 'transition-duration': `${settings.transitionHiddenDuration / 1000}s`, + }); state.currentlyTransitioningHiddenTooltip = tooltip; state.transitionHiddenTimeout = @@ -800,8 +821,13 @@ function endTransitioningTooltipHidden() { if (!tooltip) return; - tooltip.classList.remove('transition-tooltip-hidden'); - tooltip.style.removeProperty('transition-duration'); + cssProp(tooltip, { + 'display': null, + 'opacity': null, + 'transition-property': null, + 'transition-timing-function': null, + 'transition-duration': null, + }); state.currentlyTransitioningHiddenTooltip = null; } diff --git a/src/static/site6.css b/src/static/site6.css index b06417db..830e32f2 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -504,14 +504,6 @@ a:not([href]):hover { display: none; } -.icons-tooltip:not(.visible).transition-tooltip-hidden { - display: block !important; - opacity: 0; - - transition-property: opacity; - transition-timing-function: linear; -} - .icons-tooltip-content { display: block; padding: 6px 2px 2px 2px; -- cgit 1.3.0-6-gf8a5 From 0581dd1fbb7cc36cf86d28c1bb0c53264f78b213 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:10:14 -0400 Subject: client: transition tooltip hidden in steps --- src/static/client3.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 59e889a8..a0b0ed2c 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -792,9 +792,12 @@ function beginTransitioningTooltipHidden(tooltip) { cssProp(tooltip, { 'display': 'block', 'opacity': '0', + 'transition-property': 'opacity', - 'transition-timing-function': 'linear', - 'transition-duration': `${settings.transitionHiddenDuration / 1000}s`, + 'transition-timing-function': + `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`, + 'transition-duration': + `${settings.transitionHiddenDuration / 1000}s`, }); state.currentlyTransitioningHiddenTooltip = tooltip; -- cgit 1.3.0-6-gf8a5 From 511a26ddff981b866e7ccb6ecb0724c0a67d097e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:48:53 -0400 Subject: client: handle showing/hiding tooltips without internal listeners --- src/static/client3.js | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index a0b0ed2c..58687ecc 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -443,11 +443,6 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { currentTouchIdentifiers: new Set(), touchIdentifiersBanishedByScrolling: new Set(), }, - - event: { - whenTooltipShouldBeShown: [], - whenTooltipShouldBeHidden: [], - }, }; // Adds DOM event listeners, so must be called during addPageListeners step. @@ -856,6 +851,9 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { beginTransitioningTooltipHidden(state.currentlyShownTooltip); } + tooltip.classList.remove('visible'); + tooltip.inert = true; + state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -865,8 +863,6 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { state.tooltipWasJustHidden = false; }); - dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); - return true; } @@ -883,14 +879,14 @@ function showTooltipFromHoverable(hoverable) { } hoverable.classList.add('has-visible-tooltip'); + tooltip.classList.add('visible'); + tooltip.inert = false; state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; state.tooltipWasJustHidden = false; - dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); - return true; } @@ -1979,30 +1975,6 @@ function getExternalIconTooltipReferences() { .map(span => span.querySelector('span.icons-tooltip')); } -function addExternalIconTooltipInternalListeners() { - const info = externalIconTooltipInfo; - - hoverableTooltipInfo.event.whenTooltipShouldBeShown.push(({tooltip}) => { - if (!info.iconContainers.includes(tooltip)) return; - showExternalIconTooltip(tooltip); - }); - - hoverableTooltipInfo.event.whenTooltipShouldBeHidden.push(({tooltip}) => { - if (!info.iconContainers.includes(tooltip)) return; - hideExternalIconTooltip(tooltip); - }); -} - -function showExternalIconTooltip(iconContainer) { - iconContainer.classList.add('visible'); - iconContainer.inert = false; -} - -function hideExternalIconTooltip(iconContainer) { - iconContainer.classList.remove('visible'); - iconContainer.inert = true; -} - function addExternalIconTooltipPageListeners() { const info = externalIconTooltipInfo; @@ -2016,7 +1988,6 @@ function addExternalIconTooltipPageListeners() { } clientSteps.getPageReferences.push(getExternalIconTooltipReferences); -clientSteps.addInternalListeners.push(addExternalIconTooltipInternalListeners); clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); /* -- cgit 1.3.0-6-gf8a5 From 57c06ea665cf2c2ee4536cab70b2459457d05e15 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:53:45 -0400 Subject: static, css: define tooltip show/hide 100% in JS --- src/static/client3.js | 5 +++-- src/static/site6.css | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 58687ecc..a55361ce 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -847,11 +847,11 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { // apparent in the interaction, and should be hidden with a transition. if (intendingToReplace) { cancelTransitioningTooltipHidden(); + cssProp(tooltip, 'display', 'none'); } else { beginTransitioningTooltipHidden(state.currentlyShownTooltip); } - tooltip.classList.remove('visible'); tooltip.inert = true; state.currentlyShownTooltip = null; @@ -879,7 +879,8 @@ function showTooltipFromHoverable(hoverable) { } hoverable.classList.add('has-visible-tooltip'); - tooltip.classList.add('visible'); + + cssProp(tooltip, 'display', 'block'); tooltip.inert = false; state.currentlyShownTooltip = tooltip; diff --git a/src/static/site6.css b/src/static/site6.css index 830e32f2..76b58f32 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -498,9 +498,6 @@ a:not([href]):hover { left: -34px; top: calc(1em + 1px); padding: 3px 6px 6px 6px; -} - -.icons-tooltip:not(.visible) { display: none; } -- cgit 1.3.0-6-gf8a5 From 930bb9e0f1fc7167dbf53636246e3cd2de773774 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:55:04 -0400 Subject: client: grace period during transition hidden --- src/static/client3.js | 52 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index a55361ce..86b5f985 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -401,9 +401,11 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { hideTooltipDelay: 500, - // If a tooltip that's transitioning to hidden is hovered, it'll cancel - // out of this animation immediately. + // If a tooltip that's transitioning to hidden is hovered during the grace + // period (or the corresponding hoverable is hovered at any point in the + // transition), it'll cancel out of this animation immediately. transitionHiddenDuration: 300, + inertGracePeriod: 100, }, state: { @@ -421,9 +423,11 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { touchTimeout: null, hideTimeout: null, transitionHiddenTimeout: null, + inertGracePeriodTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, currentlyTransitioningHiddenTooltip: null, + previouslyActiveHoverable: null, tooltipWasJustHidden: false, hoverableWasRecentlyTouched: false, @@ -526,9 +530,15 @@ function registerTooltipHoverableElement(hoverable, tooltip) { function handleTooltipMouseEntered(tooltip) { const {state} = hoverableTooltipInfo; + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(true); + return; + } + if (state.currentlyShownTooltip !== tooltip) return; // Don't time out the current tooltip while hovering it. + if (state.hideTimeout) { clearTimeout(state.hideTimeout); state.hideTimeout = null; @@ -582,8 +592,7 @@ function handleTooltipHoverableMouseEntered(hoverable) { // animation and show it immediately. if (tooltip === state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(); - showTooltipFromHoverable(hoverable); + cancelTransitioningTooltipHidden(true); return; } @@ -802,14 +811,13 @@ function beginTransitioningTooltipHidden(tooltip) { }, settings.transitionHiddenDuration); } -function cancelTransitioningTooltipHidden() { +function cancelTransitioningTooltipHidden(andShow = false) { const {state} = hoverableTooltipInfo; endTransitioningTooltipHidden(); - if (state.transitionHiddenTimeout) { - clearTimeout(state.transitionHiddenTimeout); - state.transitionHiddenTimeout = null; + if (andShow) { + showTooltipFromHoverable(state.previouslyActiveHoverable); } } @@ -828,10 +836,20 @@ function endTransitioningTooltipHidden() { }); state.currentlyTransitioningHiddenTooltip = null; + + if (state.inertGracePeriodTimeout) { + clearTimeout(state.inertGracePeriodTimeout); + state.inertGracePeriodTimeout = null; + } + + if (state.transitionHiddenTimeout) { + clearTimeout(state.transitionHiddenTimeout); + state.transitionHiddenTimeout = null; + } } function hideCurrentlyShownTooltip(intendingToReplace = false) { - const {event, state} = hoverableTooltipInfo; + const {event, settings, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; // If there was no tooltip to begin with, we're functionally in the desired @@ -846,13 +864,21 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { // If there's no intent to replace this tooltip, it's the last one currently // apparent in the interaction, and should be hidden with a transition. if (intendingToReplace) { - cancelTransitioningTooltipHidden(); cssProp(tooltip, 'display', 'none'); } else { beginTransitioningTooltipHidden(state.currentlyShownTooltip); } - tooltip.inert = true; + // Wait just a moment before making the tooltip inert. You might react + // (to the ghosting, or just to time passing) and realize you wanted + // to look at the tooltip after all - this delay gives a little buffer + // to second guess letting it disappear. + state.inertGracePeriodTimeout = + setTimeout(() => { + tooltip.inert = true; + }, settings.inertGracePeriod); + + state.previouslyActiveHoverable = state.currentlyActiveHoverable; state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -874,9 +900,7 @@ function showTooltipFromHoverable(hoverable) { // Cancel out another tooltip that's transitioning hidden, if that's going // on - it's a distraction that this tooltip is now replacing. - if (state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(); - } + cancelTransitioningTooltipHidden(); hoverable.classList.add('has-visible-tooltip'); -- cgit 1.3.0-6-gf8a5 From 810acb84c379b76d3b8290bfd7d5971438999939 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 20:11:16 -0400 Subject: data: withSharedAdditionalNames: fix bad output when no other releases --- src/data/composite/things/track/withSharedAdditionalNames.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/composite/things/track/withSharedAdditionalNames.js b/src/data/composite/things/track/withSharedAdditionalNames.js index d205dc89..bba675c9 100644 --- a/src/data/composite/things/track/withSharedAdditionalNames.js +++ b/src/data/composite/things/track/withSharedAdditionalNames.js @@ -20,7 +20,7 @@ export default templateCompositeFrom({ raiseOutputWithoutDependency({ dependency: '#otherReleases', mode: input.value('empty'), - output: input.value({'#inferredAdditionalNames': []}), + output: input.value({'#sharedAdditionalNames': []}), }), // TODO: Using getUpdateValue is always a bit janky. -- cgit 1.3.0-6-gf8a5 From a65693efe23b97da173463f207979f81767d791c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 27 Nov 2023 21:13:39 -0400 Subject: data, content: embed scripts on static pages --- src/content/dependencies/generateStaticPage.js | 11 +++++++++-- src/data/things/static-page.js | 1 + src/data/yaml.js | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js index 3e27fd43..226152c7 100644 --- a/src/content/dependencies/generateStaticPage.js +++ b/src/content/dependencies/generateStaticPage.js @@ -1,5 +1,6 @@ export default { contentDependencies: ['generatePageLayout', 'transformContent'], + extraDependencies: ['html'], relations(relation, staticPage) { return { @@ -12,10 +13,11 @@ export default { return { name: staticPage.name, stylesheet: staticPage.stylesheet, + script: staticPage.script, }; }, - generate(data, relations) { + generate(data, relations, {html}) { return relations.layout .slots({ title: data.name, @@ -27,7 +29,12 @@ export default { : []), mainClasses: ['long-content'], - mainContent: relations.content, + mainContent: [ + relations.content, + + data.script && + html.tag('script', data.script), + ], navLinkStyle: 'hierarchical', navLinks: [ diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index ab9c5f98..8a3fd10e 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -30,5 +30,6 @@ export class StaticPage extends Thing { directory: directory(), content: simpleString(), stylesheet: simpleString(), + script: simpleString(), }); } diff --git a/src/data/yaml.js b/src/data/yaml.js index 2c600341..dda06949 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -646,6 +646,7 @@ export const processStaticPageDocument = makeProcessDocument(T.StaticPage, { directory: 'Directory', stylesheet: 'Style', + script: 'Script', content: 'Content', }, }); -- cgit 1.3.0-6-gf8a5 From 6844d1275f0b0025b09ca909a99d705447792e1f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 27 Nov 2023 21:45:38 -0400 Subject: content, test: transformContent: handle indentation more carefully --- src/content/dependencies/transformContent.js | 11 +++--- .../test/snapshot/transformContent.js.test.cjs | 39 ++++++++++++++++++++++ test/snapshot/transformContent.js | 39 ++++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index b0a7796c..2002ebee 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -562,11 +562,14 @@ export default { const transformMultiline = () => { const markedInput = extractNonTextNodes() - // Compress multiple line breaks into single line breaks. - .replace(/\n{2,}/g, '\n') + // Compress multiple line breaks into single line breaks, + // except when they're preceding or following indented + // text (by at least two spaces). + .replace(/(? / " ". - .replace(/(?.*| $|
                      $)\n+/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ + // or
                      / " ", and which don't precede or follow + // indented text (by at least two spaces). + .replace(/(?.*|^ .*\n*| $|
                      $)\n+(?! |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */ // Expand line breaks which are at the end of a list. .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n') // Expand line breaks which are at the end of a quote. diff --git a/tap-snapshots/test/snapshot/transformContent.js.test.cjs b/tap-snapshots/test/snapshot/transformContent.js.test.cjs index 85ee740f..9ab299e6 100644 --- a/tap-snapshots/test/snapshot/transformContent.js.test.cjs +++ b/tap-snapshots/test/snapshot/transformContent.js.test.cjs @@ -10,6 +10,45 @@ exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) >

                      Very nice:

                      ` +exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > hanging indent list 1`] = ` +

                      Hello!

                      +
                        +
                      • I am a list item and I +go on and on and on +and on and on and on.

                        +
                      • +
                      • I am another list item. +Yeah.

                        +
                      • +
                      +

                      In-between!

                      +
                        +
                      • Spooky, +spooky, I say!
                      • +
                      • Following list item. +No empty line around me.
                      • +
                      • Very cool. +So, so cool.
                      • +
                      +

                      Goodbye!

                      +` + +exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > indent on a directly following line 1`] = ` +
                      + Wow! +
                      +` + +exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > indent on an indierctly following line 1`] = ` +

                      Some text.

                      +

                      Yes, some more text.

                      +
                      I am hax0rz!!
                      +All yor base r blong 2 us.
                      +
                      +

                      Aye.

                      +

                      Aye aye aye.

                      +` + exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > inline images 1`] = `

                      as USUAL...

                      What do you know?

                      diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js index b05beac1..740d94df 100644 --- a/test/snapshot/transformContent.js +++ b/test/snapshot/transformContent.js @@ -36,6 +36,45 @@ testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => { `This is [[album:cool-album|my favorite album]].\n` + `That's right, [[album:cool-album]]!`); + quickSnapshot( + 'indent on a directly following line', + `
                      \n` + + ` Wow!\n` + + `
                      `); + + quickSnapshot( + 'indent on an indierctly following line', + `Some text.\n` + + `Yes, some more text.\n` + + `\n` + + ` I am hax0rz!!\n` + + ` All yor base r blong 2 us.\n` + + `\n` + + `Aye.\n` + + `Aye aye aye.`); + + quickSnapshot( + 'hanging indent list', + `Hello!\n` + + `\n` + + `* I am a list item and I\n` + + ` go on and on and on\n` + + ` and on and on and on.\n` + + `\n` + + `* I am another list item.\n` + + ` Yeah.\n` + + `\n` + + `In-between!\n` + + `\n` + + `* Spooky,\n` + + ` spooky, I say!\n` + + `* Following list item.\n` + + ` No empty line around me.\n` + + `* Very cool.\n` + + ` So, so cool.\n` + + `\n` + + `Goodbye!`); + quickSnapshot( 'inline images', ` as USUAL...\n` + -- cgit 1.3.0-6-gf8a5 From e3358d7c6993aa454019ebf58a53198b9e28087f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 28 Nov 2023 12:30:42 -0400 Subject: sugar: extract "getUsefulStackLine" logic & internals --- src/util/sugar.js | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/util/sugar.js b/src/util/sugar.js index eab44b75..cee3df12 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -589,6 +589,32 @@ export function _withAggregate(mode, aggregateOpts, fn) { } } +export const unhelpfulStackLines = [ + /sugar/, + /node:/, + //, +]; + +export function getUsefulStackLine(stack) { + if (!stack) return ''; + + function isUseful(stackLine) { + const trimmed = stackLine.trim(); + + if (!trimmed.startsWith('at')) + return false; + + if (unhelpfulStackLines.some(regex => regex.test(trimmed))) + return false; + + return true; + } + + const stackLines = stack.split('\n'); + const usefulStackLine = stackLines.find(isUseful); + return usefulStackLine ?? ''; +} + export function showAggregate(topError, { pathToFileURL = f => f, showTraces = true, @@ -670,15 +696,8 @@ export function showAggregate(topError, { : messagePart); if (showTraces) { - const stackLines = - stack?.split('\n'); - const stackLine = - stackLines?.find(line => - line.trim().startsWith('at') && - !line.includes('sugar') && - !line.includes('node:') && - !line.includes('')); + getUsefulStackLine(stack); const tracePart = (stackLine -- cgit 1.3.0-6-gf8a5 From 084b5423d2a4fc60a91dd4aeb24ff0cd4d870fbc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 28 Nov 2023 13:04:32 -0400 Subject: data: tweak track album messaging in errors/inspect --- src/data/things/track.js | 13 +++++++++++-- src/data/yaml.js | 3 +-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index f6320677..d25213c2 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -331,12 +331,21 @@ export class Track extends Thing { } let album; - if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + + if (depth >= 0) { + try { + album = this.album; + } catch (_error) {} + + album ??= this.dataSourceAlbum; + } + + if (album) { const albumName = album.name; const albumIndex = album.tracks.indexOf(this); const trackNum = (albumIndex === -1 - ? '#?' + ? 'indeterminate position' : `#${albumIndex + 1}`); parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); } diff --git a/src/data/yaml.js b/src/data/yaml.js index dda06949..27d8721f 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -1625,8 +1625,7 @@ export function filterDuplicateDirectories(wikiData) { call(() => { throw new Error( `Duplicate directory ${colors.green(directory)}:\n` + - places.map((thing) => ` - ` + inspect(thing)).join('\n') - ); + places.map(thing => ` - ` + inspect(thing)).join('\n')); }); } -- cgit 1.3.0-6-gf8a5 From 7215aef076f9734f35dc4f25e54fbe2371630c5f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 28 Nov 2023 13:23:17 -0400 Subject: data, test: album.trackData -> album.ownTrackData --- .../composite/things/album/withTrackSections.js | 4 +-- src/data/composite/things/album/withTracks.js | 4 +-- src/data/things/album.js | 5 +++- src/data/yaml.js | 8 +++-- test/lib/wiki-data.js | 35 +++++++++++++++++++--- test/unit/data/things/album.js | 20 ++++++++----- test/unit/data/things/track.js | 16 +++++----- 7 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js index baa3cb4a..679a09fd 100644 --- a/src/data/composite/things/album/withTrackSections.js +++ b/src/data/composite/things/album/withTrackSections.js @@ -22,7 +22,7 @@ export default templateCompositeFrom({ steps: () => [ exitWithoutDependency({ - dependency: 'trackData', + dependency: 'ownTrackData', value: input.value([]), }), @@ -75,7 +75,7 @@ export default templateCompositeFrom({ withResolvedReferenceList({ list: '#trackRefs', - data: 'trackData', + data: 'ownTrackData', notFoundMode: input.value('null'), find: input.value(find.track), }).outputs({ diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js index dcea6593..fff3d5ae 100644 --- a/src/data/composite/things/album/withTracks.js +++ b/src/data/composite/things/album/withTracks.js @@ -12,7 +12,7 @@ export default templateCompositeFrom({ steps: () => [ exitWithoutDependency({ - dependency: 'trackData', + dependency: 'ownTrackData', value: input.value([]), }), @@ -35,7 +35,7 @@ export default templateCompositeFrom({ withResolvedReferenceList({ list: '#trackRefs', - data: 'trackData', + data: 'ownTrackData', find: input.value(find.track), }), diff --git a/src/data/things/album.js b/src/data/things/album.js index 63ec1140..a95ba354 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -133,7 +133,10 @@ export class Album extends Thing { class: input.value(Group), }), - trackData: wikiData({ + // Only the tracks which belong to this album. + // Necessary for computing the track list, so provide this statically + // or keep it updated. + ownTrackData: wikiData({ class: input.value(Track), }), diff --git a/src/data/yaml.js b/src/data/yaml.js index 27d8721f..82b7faf2 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -936,6 +936,7 @@ export const dataSteps = [ // an individual section before applying it, since those are just // generic objects; they aren't Things in and of themselves.) const trackSections = []; + const ownTrackData = []; let currentTrackSection = { name: `Default Track Section`, @@ -970,13 +971,16 @@ export const dataSteps = [ entry.dataSourceAlbum = albumRef; + ownTrackData.push(entry); currentTrackSection.tracks.push(Thing.getReference(entry)); } closeCurrentTrackSection(); - album.trackSections = trackSections; albumData.push(album); + + album.trackSections = trackSections; + album.ownTrackData = ownTrackData; } return {albumData, trackData}; @@ -1551,7 +1555,7 @@ export function linkWikiDataArrays(wikiData, { assignWikiData([WD.wikiInfo], 'groupData'); - assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData'); + assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData'); assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData'); assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData'); assignWikiData(WD.groupData, 'albumData', 'groupCategoryData'); diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js index c4083a56..5433de29 100644 --- a/test/lib/wiki-data.js +++ b/test/lib/wiki-data.js @@ -1,7 +1,32 @@ +import CacheableObject from '#cacheable-object'; +import find from '#find'; import {linkWikiDataArrays} from '#yaml'; -export function linkAndBindWikiData(wikiData) { - linkWikiDataArrays(wikiData); +export function linkAndBindWikiData(wikiData, { + inferAlbumsOwnTrackData = true, +} = {}) { + function customLinkWikiDataArrays(...args) { + linkWikiDataArrays(...args); + + // If albumData is present, automatically set albums' ownTrackData values + // by resolving track sections' references against the full array. This is + // just a nicety for working with albums throughout tests. + if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) { + for (const album of wikiData.albumData) { + const trackSections = + CacheableObject.getUpdateValue(album, 'trackSections'); + + const trackRefs = + trackSections.flatMap(section => section.tracks); + + album.ownTrackData = + trackRefs.map(ref => + find.track(ref, wikiData.trackData, {mode: 'error'})); + } + } + } + + customLinkWikiDataArrays(wikiData); return { // Mutate to make the below functions aware of new data objects, or of @@ -13,12 +38,14 @@ export function linkAndBindWikiData(wikiData) { // It'll automatically relink everything on wikiData so all the objects // are caught up to date. linkWikiDataArrays: - linkWikiDataArrays.bind(null, wikiData), + customLinkWikiDataArrays + .bind(null, wikiData), // Use this if you HAVEN'T mutated wikiData and just need to decache // indirect dependencies on exposed properties of other data objects. // See documentation on linkWikiDataArarys (in yaml.js) for more info. XXX_decacheWikiData: - linkWikiDataArrays.bind(null, wikiData, {XXX_decacheWikiData: true}), + customLinkWikiDataArrays + .bind(null, wikiData, {XXX_decacheWikiData: true}), }; } diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js index 76a2b90f..5a1261a4 100644 --- a/test/unit/data/things/album.js +++ b/test/unit/data/things/album.js @@ -204,11 +204,13 @@ t.test(`Album.tracks`, t => { const track1 = stubTrack('track1'); const track2 = stubTrack('track2'); const track3 = stubTrack('track3'); + const tracks = [track1, track2, track3]; - linkAndBindWikiData({ - albumData: [album], - trackData: [track1, track2, track3], - }); + album.ownTrackData = tracks; + + for (const track of tracks) { + track.albumData = [album]; + } t.same(album.tracks, [], `Album.tracks #1: defaults to empty array`); @@ -259,11 +261,13 @@ t.test(`Album.trackSections`, t => { const track2 = stubTrack('track2'); const track3 = stubTrack('track3'); const track4 = stubTrack('track4'); + const tracks = [track1, track2, track3, track4]; - linkAndBindWikiData({ - albumData: [album], - trackData: [track1, track2, track3, track4], - }); + album.ownTrackData = tracks; + + for (const track of tracks) { + track.albumData = [album]; + } album.trackSections = [ {tracks: ['track:track1', 'track:track2']}, diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index f84ba1cb..57bd4253 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -80,8 +80,8 @@ t.test(`Track.album`, t => { track1.albumData = [album1, album2]; track2.albumData = [album1, album2]; - album1.trackData = [track1, track2]; - album2.trackData = [track1, track2]; + album1.ownTrackData = [track1, track2]; + album2.ownTrackData = [track1, track2]; album1.trackSections = [{tracks: ['track:track1']}]; album2.trackSections = [{tracks: ['track:track2']}]; @@ -98,13 +98,13 @@ t.test(`Track.album`, t => { t.equal(track1.album, null, `album #4: is null when track missing albumData`); - album1.trackData = []; + album1.ownTrackData = []; track1.albumData = [album1, album2]; t.equal(track1.album, null, - `album #5: is null when album missing trackData`); + `album #5: is null when album missing ownTrackData`); - album1.trackData = [track1, track2]; + album1.ownTrackData = [track1, track2]; album1.trackSections = [{tracks: ['track:track2']}]; // XXX_decacheWikiData @@ -165,7 +165,7 @@ t.test(`Track.color`, t => { const {track, album} = stubTrackAndAlbum(); - const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + const {XXX_decacheWikiData} = linkAndBindWikiData({ albumData: [album], trackData: [track], }); @@ -188,7 +188,7 @@ t.test(`Track.color`, t => { // track's album will always have a corresponding track section. But if that // connection breaks for some future reason (with the album still present), // Track.color should still inherit directly from the album. - wikiData.albumData = [ + track.albumData = [ { constructor: {[Thing.referenceType]: 'album'}, color: '#abcdef', @@ -199,8 +199,6 @@ t.test(`Track.color`, t => { }, ]; - linkWikiDataArrays(); - t.equal(track.color, '#abcdef', `color #3: inherits from album without matching track section`); -- cgit 1.3.0-6-gf8a5 From 7ad62ef4a6908a550d5b48ae93877446088d4d82 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 17:32:08 -0400 Subject: client: remove dead artist tooltip reference code --- src/static/client3.js | 92 --------------------------------------------------- 1 file changed, 92 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 86b5f985..1e64ebe1 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -2015,98 +2015,6 @@ function addExternalIconTooltipPageListeners() { clientSteps.getPageReferences.push(getExternalIconTooltipReferences); clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); -/* -const linkIconTooltipInfo = - Array.from(document.querySelectorAll('span.contribution.has-tooltip')) - .map(span => ({ - mainLink: span.querySelector('a'), - iconsContainer: span.querySelector('span.icons-tooltip'), - iconLinks: span.querySelectorAll('span.icons-tooltip a'), - })); - -for (const info of linkIconTooltipInfo) { - const focusElements = - [info.mainLink, ...info.iconLinks]; - - const hoverElements = - [info.mainLink, info.iconsContainer]; - - let hidden = true; - - const show = () => { - info.iconsContainer.classList.add('visible'); - info.iconsContainer.inert = false; - hidden = false; - }; - - const hide = () => { - info.iconsContainer.classList.remove('visible'); - info.iconsContainer.inert = true; - hidden = true; - }; - - const considerHiding = () => { - if (hoverElements.some(el => el.matches(':hover'))) { - return; - } - - if () { - return; - } - - if (justTouched) { - return; - } - - hide(); - }; - - // Hover (pointer) - - let hoverTimeout; - - info.mainLink.addEventListener('mouseenter', () => { - if (hidden) { - hoverTimeout = setTimeout(show, 250); - } - }); - - info.mainLink.addEventListener('mouseout', () => { - if (hidden) { - clearTimeout(hoverTimeout); - } else { - considerHiding(); - } - }); - - info.iconsContainer.addEventListener('mouseout', () => { - if (!hidden) { - considerHiding(); - } - }); - - // Focus (keyboard) - - let focusTimeout; - - info.mainLink.addEventListener('focus', () => { - focusTimeout = setTimeout(show, 750); - }); - - info.mainLink.addEventListener('blur', () => { - clearTimeout(focusTimeout); - }); - - info.iconsContainer.addEventListener('focusout', () => { - requestAnimationFrame(considerHiding); - }); - - info.mainLink.addEventListener('blur', () => { - requestAnimationFrame(considerHiding); - }); -} -*/ - // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { -- cgit 1.3.0-6-gf8a5 From e156e337c29558b44e75e2d63494221823c5a9f9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 16:53:31 -0400 Subject: content: generateGroupInfoPage: tidy album list implementation --- src/content/dependencies/generateGroupInfoPage.js | 89 +++++++++++------------ src/strings-default.yaml | 18 ++++- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 05df33fb..04886fe2 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -1,4 +1,4 @@ -import {empty} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ @@ -62,18 +62,17 @@ export default { sec.albums.galleryLink = relation('linkGroupGallery', group); - sec.albums.entries = - group.albums.map(album => { - const links = {}; - links.albumLink = relation('linkAlbum', album); - - const otherGroup = album.groups.find(g => g !== group); - if (otherGroup) { - links.groupLink = relation('linkGroup', otherGroup); - } + sec.albums.albumLinks = + group.albums + .map(album => relation('linkAlbum', album)); - return links; - }); + sec.albums.groupLinks = + group.albums + .map(album => album.groups.find(g => g !== group)) + .map(group => + (group + ? relation('linkGroup', group) + : null)); } return relations; @@ -85,11 +84,9 @@ export default { data.name = group.name; data.color = group.color; - if (!empty(group.albums)) { - data.albumYears = - group.albums - .map(album => album.date?.getFullYear()); - } + data.albumYears = + group.albums + .map(album => album.date?.getFullYear()); return data; }, @@ -133,34 +130,36 @@ export default { })), html.tag('ul', - sec.albums.entries.map(({albumLink, groupLink}, index) => { - // All these strings are really jank, and should probably - // be implemented with the same 'const parts = [], opts = {}' - // form used elsewhere... - const year = data.albumYears[index]; - const item = - (year - ? language.$('groupInfoPage.albumList.item', { - year, - album: albumLink, - }) - : language.$('groupInfoPage.albumList.item.withoutYear', { - album: albumLink, - })); - - return html.tag('li', - (groupLink - ? language.$('groupInfoPage.albumList.item.withAccent', { - item, - accent: - html.tag('span', {class: 'other-group-accent'}, - language.$('groupInfoPage.albumList.item.otherGroupAccent', { - group: - groupLink.slot('color', false), - })), - }) - : item)); - })), + stitchArrays({ + albumLink: sec.albums.albumLinks, + groupLink: sec.albums.groupLinks, + albumYear: data.albumYears, + }).map(({albumLink, groupLink, albumYear}) => { + const prefix = 'groupInfoPage.albumList.item'; + const parts = [prefix]; + const options = {album: albumLink}; + + if (albumYear) { + parts.push('withYear'); + options.yearAccent = + language.$(prefix, 'yearAccent', { + year: albumYear, + }); + } + + if (groupLink) { + parts.push('withOtherGroup'); + options.otherGroupAccent = + html.tag('span', {class: 'other-group-accent'}, + language.$(prefix, 'otherGroupAccent', { + group: + groupLink.slot('color', false), + })); + } + + return language.$(...parts, options); + }) + .map(content => html.tag('li', content))), ], ], diff --git a/src/strings-default.yaml b/src/strings-default.yaml index f02a10a0..af2ddc42 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -1023,10 +1023,20 @@ groupInfoPage: title: "Albums" item: - _: "({YEAR}) {ALBUM}" - withoutYear: "{ALBUM}" - withAccent: "{ITEM} {ACCENT}" - otherGroupAccent: "(from {GROUP})" + _: >- + {ALBUM} + + withYear: >- + {YEAR_ACCENT} {ALBUM} + + withOtherGroup: >- + {ALBUM} {OTHER_GROUP_ACCENT} + + withYear.withOtherGroup: >- + {YEAR_ACCENT} {ALBUM} {OTHER_GROUP_ACCENT} + + yearAccent: "({YEAR})" + otherGroupAccent: "(from {GROUP})" # # groupGalleryPage: -- cgit 1.3.0-6-gf8a5 From a0fa6520c77e46b7a2e55b87e9994df3af74f149 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 17:57:28 -0400 Subject: content, client, css: basic (absolute) datetimestamp tooltips --- .../dependencies/generateAbsoluteDatetimestamp.js | 41 +++++++++++++++ .../dependencies/generateDatetimestampTemplate.js | 28 ++++++++++ src/static/client3.js | 53 +++++++++++++++---- src/static/site6.css | 59 ++++++++++++++++------ 4 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 src/content/dependencies/generateAbsoluteDatetimestamp.js create mode 100644 src/content/dependencies/generateDatetimestampTemplate.js diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js new file mode 100644 index 00000000..63acecf2 --- /dev/null +++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js @@ -0,0 +1,41 @@ +export default { + contentDependencies: ['generateDatetimestampTemplate'], + extraDependencies: ['html', 'language'], + + data: (date) => + ({date}), + + relations: (relation) => + ({template: relation('generateDatetimestampTemplate')}), + + slots: { + style: { + validate: v => v.is('full', 'year'), + default: 'full', + }, + + // Only has an effect for 'year' style. + tooltip: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {language}) => + relations.template.slots({ + mainContent: + (slots.style === 'full' + ? language.formatDate(data.date) + : slots.style === 'year' + ? data.date.getFullYear().toString() + : null), + + tooltipContent: + slots.tooltip && + slots.style === 'year' && + language.formatDate(data.date), + + datetime: + data.date.toISOString(), + }), +}; diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js new file mode 100644 index 00000000..bfba647f --- /dev/null +++ b/src/content/dependencies/generateDatetimestampTemplate.js @@ -0,0 +1,28 @@ +export default { + extraDependencies: ['html'], + + slots: { + mainContent: {type: 'html'}, + tooltipContent: {type: 'html'}, + datetime: {type: 'string'}, + }, + + generate: (slots, {html}) => + html.tag('span', { + [html.joinChildren]: '', + + class: [ + 'datetimestamp', + slots.tooltipContent && 'has-tooltip', + ], + }, [ + html.tag('time', + {datetime: slots.datetime}, + slots.mainContent), + + slots.tooltipContent && + html.tag('span', {class: 'datetimestamp-tooltip'}, + html.tag('span', {class: 'datetimestamp-tooltip-content'}, + slots.tooltipContent)), + ]), +}; diff --git a/src/static/client3.js b/src/static/client3.js index 1e64ebe1..ce057712 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1981,8 +1981,8 @@ for (const info of groupContributionsTableInfo) { // Artist link icon tooltips ------------------------------ const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = { - hoverableLinks: null, - iconContainers: null, + hoverables: null, + tooltips: null, }; function getExternalIconTooltipReferences() { @@ -1991,21 +1991,19 @@ function getExternalIconTooltipReferences() { const spans = Array.from(document.querySelectorAll('span.contribution.has-tooltip')); - info.hoverableLinks = - spans - .map(span => span.querySelector('a')); + info.hoverables = + spans.map(span => span.querySelector('a')); - info.iconContainers = - spans - .map(span => span.querySelector('span.icons-tooltip')); + info.tooltips = + spans.map(span => span.querySelector('span.icons-tooltip')); } function addExternalIconTooltipPageListeners() { const info = externalIconTooltipInfo; for (const {hoverable, tooltip} of stitchArrays({ - hoverable: info.hoverableLinks, - tooltip: info.iconContainers, + hoverable: info.hoverables, + tooltip: info.tooltips, })) { registerTooltipElement(tooltip); registerTooltipHoverableElement(hoverable, tooltip); @@ -2015,6 +2013,41 @@ function addExternalIconTooltipPageListeners() { clientSteps.getPageReferences.push(getExternalIconTooltipReferences); clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); +// Datetimestamp tooltips --------------------------------- + +const datetimestampTooltipInfo = clientInfo.datetimestampTooltipInfo = { + hoverables: null, + tooltips: null, +}; + +function getDatestampTooltipReferences() { + const info = datetimestampTooltipInfo; + + const spans = + Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip')); + + info.hoverables = + spans.map(span => span.querySelector('time')); + + info.tooltips = + spans.map(span => span.querySelector('span.datetimestamp-tooltip')); +} + +function addDatestampTooltipPageListeners() { + const info = datetimestampTooltipInfo; + + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverables, + tooltip: info.tooltips, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} + +clientSteps.getPageReferences.push(getDatestampTooltipReferences); +clientSteps.addPageListeners.push(addDatestampTooltipPageListeners); + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { diff --git a/src/static/site6.css b/src/static/site6.css index 76b58f32..b7d5ce04 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -473,37 +473,51 @@ a:not([href]):hover { white-space: nowrap; } -.contribution { +.contribution.has-tooltip, +.datetimestamp.has-tooltip { position: relative; } -.contribution.has-tooltip > a { +.contribution.has-tooltip > a, +.datetimestamp.has-tooltip > time { text-decoration: underline; text-decoration-style: dotted; } -.contribution.has-tooltip > a:hover, -.contribution.has-tooltip > a.has-visible-tooltip { - text-decoration-style: wavy !important; +.datetimestamp.has-tooltip > time { + cursor: default; } -.icons { - font-style: normal; - white-space: nowrap; +.contribution.has-tooltip > a:hover, +.contribution.has-tooltip > a.has-visible-tooltip, +.datetimestamp.has-tooltip > time:hover, +.datetimestamp.has-tooltip > time.has-visible-tooltip { + text-decoration-style: wavy !important; } -.icons-tooltip { +.icons-tooltip, +.datetimestamp-tooltip { position: absolute; z-index: 3; left: -34px; top: calc(1em + 1px); - padding: 3px 6px 6px 6px; display: none; } -.icons-tooltip-content { +.icons-tooltip { + padding: 3px 6px 6px 6px; + left: -34px; +} + +.datetimestamp-tooltip { + padding: 3px 4px 2px 2px; + left: 14px; +} + +.icons-tooltip-content, +.datetimestamp-tooltip-content { display: block; - padding: 6px 2px 2px 2px; + background: var(--bg-black-color); border: 1px dotted var(--primary-color); border-radius: 6px; @@ -514,16 +528,31 @@ a:not([href]):hover { backdrop-filter: brightness(1.5) saturate(1.4) blur(4px); - -webkit-user-select: none; - user-select: none; - box-shadow: 0 3px 4px 4px #000000aa, 0 -2px 4px -2px var(--primary-color) inset; +} + +.icons-tooltip-content { + padding: 6px 2px 2px 2px; + + -webkit-user-select: none; + user-select: none; cursor: default; } +.datetimestamp-tooltip-content { + padding: 5px 6px; + white-space: nowrap; + font-size: 0.9em; +} + +.icons { + font-style: normal; + white-space: nowrap; +} + .icons a:hover { filter: brightness(1.4); } -- cgit 1.3.0-6-gf8a5 From 301ae482ee60897db13d5fd76b9ce7c9df5790f2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 17:58:07 -0400 Subject: content: generateGroupInfoPage: use datetimestamps --- src/content/dependencies/generateGroupInfoPage.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 04886fe2..5bf2b1bf 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -2,6 +2,7 @@ import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ + 'generateAbsoluteDatetimestamp', 'generateContentHeading', 'generateGroupNavLinks', 'generateGroupSecondaryNav', @@ -73,6 +74,12 @@ export default { (group ? relation('linkGroup', group) : null)); + + sec.albums.datetimestamps = + group.albums.map(album => + (album.date + ? relation('generateAbsoluteDatetimestamp', album.date) + : null)); } return relations; @@ -84,10 +91,6 @@ export default { data.name = group.name; data.color = group.color; - data.albumYears = - group.albums - .map(album => album.date?.getFullYear()); - return data; }, @@ -133,17 +136,18 @@ export default { stitchArrays({ albumLink: sec.albums.albumLinks, groupLink: sec.albums.groupLinks, - albumYear: data.albumYears, - }).map(({albumLink, groupLink, albumYear}) => { + datetimestamp: sec.albums.datetimestamps, + }).map(({albumLink, groupLink, datetimestamp}) => { const prefix = 'groupInfoPage.albumList.item'; const parts = [prefix]; const options = {album: albumLink}; - if (albumYear) { + if (datetimestamp) { parts.push('withYear'); options.yearAccent = language.$(prefix, 'yearAccent', { - year: albumYear, + year: + datetimestamp.slots({style: 'year', tooltip: true}), }); } -- cgit 1.3.0-6-gf8a5 From 253ee49496ff785eb0d9ca909ce13d7b2fd2a2e6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 18:21:12 -0400 Subject: content: generateColorStyleVariables: misc dynamics + extensibility --- .../dependencies/generateColorStyleRules.js | 7 ++- .../dependencies/generateColorStyleVariables.js | 51 ++++++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js index 1b316a3c..3f1d0130 100644 --- a/src/content/dependencies/generateColorStyleRules.js +++ b/src/content/dependencies/generateColorStyleRules.js @@ -18,9 +18,12 @@ export default { `:root {`, ...( relations.variables - .slot('color', slots.color) + .slots({ + color: slots.color, + context: 'page-root', + mode: 'property-list', + }) .content - .split(';') .map(line => line + ';')), `}`, ].join('\n'); diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js index f30d786b..7cd04bd1 100644 --- a/src/content/dependencies/generateColorStyleVariables.js +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -2,7 +2,23 @@ export default { extraDependencies: ['html', 'getColors'], slots: { - color: {validate: v => v.isColor}, + color: { + validate: v => v.isColor, + }, + + context: { + validate: v => v.is( + 'any-content', + 'page-root', + 'primary-only'), + + default: 'any-content', + }, + + mode: { + validate: v => v.is('style', 'property-list'), + default: 'style', + }, }, generate(slots, {getColors}) { @@ -18,7 +34,7 @@ export default { shadow, } = getColors(slots.color); - return [ + let anyContent = [ `--primary-color: ${primary}`, `--dark-color: ${dark}`, `--dim-color: ${dim}`, @@ -26,6 +42,35 @@ export default { `--bg-color: ${bg}`, `--bg-black-color: ${bgBlack}`, `--shadow-color: ${shadow}`, - ].join('; '); + ]; + + let selectedProperties; + + switch (slots.context) { + case 'any-content': + selectedProperties = anyContent; + break; + + case 'page-root': + selectedProperties = [ + ...anyContent, + `--page-primary-color: ${primary}`, + ]; + break; + + case 'primary-only': + selectedProperties = [ + `--primary-color: ${primary}`, + ]; + break; + } + + switch (slots.mode) { + case 'style': + return selectedProperties.join('; '); + + case 'property-list': + return selectedProperties; + } }, }; -- cgit 1.3.0-6-gf8a5 From ad823614a22807321d28ad25fa5440d439d84975 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 18:22:05 -0400 Subject: content: generateGroupInfoPage: colorize tooltips --- src/content/dependencies/generateGroupInfoPage.js | 31 +++++++++++++++++++---- src/static/site6.css | 4 +++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 5bf2b1bf..0e5d645b 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -3,6 +3,7 @@ import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ 'generateAbsoluteDatetimestamp', + 'generateColorStyleVariables', 'generateContentHeading', 'generateGroupNavLinks', 'generateGroupSecondaryNav', @@ -63,6 +64,10 @@ export default { sec.albums.galleryLink = relation('linkGroupGallery', group); + sec.albums.colorVariables = + group.albums + .map(() => relation('generateColorStyleVariables')); + sec.albums.albumLinks = group.albums .map(album => relation('linkAlbum', album)); @@ -91,6 +96,9 @@ export default { data.name = group.name; data.color = group.color; + data.albumColors = + group.albums.map(album => album.color); + return data; }, @@ -137,10 +145,21 @@ export default { albumLink: sec.albums.albumLinks, groupLink: sec.albums.groupLinks, datetimestamp: sec.albums.datetimestamps, - }).map(({albumLink, groupLink, datetimestamp}) => { + colorVariables: sec.albums.colorVariables, + albumColor: data.albumColors, + }).map(({ + albumLink, + groupLink, + datetimestamp, + colorVariables, + albumColor, + }) => { const prefix = 'groupInfoPage.albumList.item'; const parts = [prefix]; - const options = {album: albumLink}; + const options = {}; + + options.album = + albumLink.slot('color', false); if (datetimestamp) { parts.push('withYear'); @@ -161,9 +180,11 @@ export default { })); } - return language.$(...parts, options); - }) - .map(content => html.tag('li', content))), + return ( + html.tag('li', + {style: colorVariables.slot('color', albumColor).content}, + language.$(...parts, options))); + })), ], ], diff --git a/src/static/site6.css b/src/static/site6.css index b7d5ce04..113633ed 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -603,6 +603,10 @@ a:not([href]):hover { white-space: nowrap; } +.other-group-accent a { + color: var(--page-primary-color); +} + .content-columns { columns: 2; } -- cgit 1.3.0-6-gf8a5 From 03739d3af94702839bcb3691bf8edd4ff659b9ac Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 18:22:24 -0400 Subject: css: only add tooltip margin in context of list items --- src/static/site6.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/static/site6.css b/src/static/site6.css index 113633ed..9d3da7c4 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -511,6 +511,10 @@ a:not([href]):hover { .datetimestamp-tooltip { padding: 3px 4px 2px 2px; + left: -10px; +} + +li .datetimestamp-tooltip { left: 14px; } -- cgit 1.3.0-6-gf8a5 From 017be69511d1d3638fa834ffb1300fbbb9a187b7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 21:41:16 -0400 Subject: data: language: formatDateDuration, formatRelativeDate Also related counting functions. --- package-lock.json | 38 ++++++++++++++-- package.json | 1 + src/data/things/language.js | 108 ++++++++++++++++++++++++++++++++++++++++++++ src/strings-default.yaml | 48 ++++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index bca46bd5..1da6e9ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "GPL-3.0", "dependencies": { + "@js-temporal/polyfill": "^0.4.4", "chroma-js": "^2.4.2", "command-exists": "^1.2.9", "eslint": "^8.37.0", @@ -241,6 +242,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz", + "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==", + "dependencies": { + "jsbi": "^4.3.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3077,6 +3090,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz", + "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==" + }, "node_modules/json-parse-even-better-errors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", @@ -5231,8 +5249,7 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tuf-js": { "version": "2.1.0", @@ -5878,6 +5895,15 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@js-temporal/polyfill": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz", + "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==", + "requires": { + "jsbi": "^4.3.0", + "tslib": "^2.4.1" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7928,6 +7954,11 @@ "argparse": "^2.0.1" } }, + "jsbi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz", + "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==" + }, "json-parse-even-better-errors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", @@ -9473,8 +9504,7 @@ "tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "tuf-js": { "version": "2.1.0", diff --git a/package.json b/package.json index 7cd9e0ef..a1c165bc 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "node": ">= 20.9.0" }, "dependencies": { + "@js-temporal/polyfill": "^0.4.4", "chroma-js": "^2.4.2", "command-exists": "^1.2.9", "eslint": "^8.37.0", diff --git a/src/data/things/language.js b/src/data/things/language.js index d8af9620..fa529a8e 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,3 +1,5 @@ +import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; + import {isLanguageCode} from '#validators'; import {Tag} from '#html'; @@ -284,6 +286,108 @@ export class Language extends Thing { return this.intl_date.formatRange(startDate, endDate); } + formatDateDuration({ + years: numYears = 0, + months: numMonths = 0, + days: numDays = 0, + approximate = false, + }) { + let basis; + + const years = this.countYears(numYears, {unit: true}); + const months = this.countMonths(numMonths, {unit: true}); + const days = this.countDays(numDays, {unit: true}); + + if (numYears && numMonths && numDays) + basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days}); + else if (numYears && numMonths) + basis = this.formatString('count.dateDuration.yearsMonths', {years, months}); + else if (numYears && numDays) + basis = this.formatString('count.dateDuration.yearsDays', {years, days}); + else if (numYears) + basis = this.formatString('count.dateDuration.years', {years}); + else if (numMonths && numDays) + basis = this.formatString('count.dateDuration.monthsDays', {months, days}); + else if (numMonths) + basis = this.formatzString('count.dateDuration.months', {months}); + else if (numDays) + basis = this.formatString('count.dateDuration.days', {days}); + else + return this.formatString('count.dateDuration.zero'); + + if (approximate) { + return this.formatString('count.dateDuration.approximate', { + duration: basis, + }); + } else { + return basis; + } + } + + formatRelativeDate(currentDate, referenceDate, { + considerRoundingDays = false, + approximate = true, + absolute = true, + } = {}) { + const currentInstant = toTemporalInstant.apply(currentDate); + const referenceInstant = toTemporalInstant.apply(referenceDate); + + const comparison = + Temporal.Instant.compare(currentInstant, referenceInstant); + + if (comparison === 0) { + return this.formatString('count.dateDuration.same'); + } + + const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC'); + const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC'); + + const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ); + const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ); + + const {years, months, days} = + laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: + (considerRoundingDays + ? (laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: 'day', + }).years + ? 'month' + : 'day') + : 'day'), + roundingMode: 'halfCeil', + }); + + const duration = + this.formatDateDuration({ + years, months, days, + approximate: false, + }); + + const relative = + this.formatString( + 'count.dateDuration', + (approximate + ? (comparison === -1 + ? 'approximateEarlier' + : 'approximateLater') + : (comparison === -1 + ? 'earlier' + : 'later')), + {duration}); + + if (absolute) { + return this.formatString('count.dateDuration.relativeAbsolute', { + relative, + absolute: this.formatDate(currentDate), + }); + } else { + return relative; + } + } + formatDuration(secTotal, {approximate = false, unit = false} = {}) { if (secTotal === 0) { return this.formatString('count.duration.missing'); @@ -442,7 +546,11 @@ Object.assign(Language.prototype, { countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), countCoverArts: countHelper('coverArts'), + countDays: countHelper('days'), + countMonths: countHelper('months'), countTimesReferenced: countHelper('timesReferenced'), countTimesUsed: countHelper('timesUsed'), countTracks: countHelper('tracks'), + countWeeks: countHelper('weeks'), + countYears: countHelper('years'), }); diff --git a/src/strings-default.yaml b/src/strings-default.yaml index af2ddc42..30b04141 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -129,6 +129,16 @@ count: many: "" other: "{DAYS} days" + months: + _: "{MONTHS}" + withUnit: + zero: "" + one: "{MONTHS} month" + two: "" + few: "" + many: "" + other: "{MONTHS} months" + timesReferenced: _: "{TIMES_REFERENCED}" withUnit: @@ -149,6 +159,16 @@ count: many: "" other: "used {TIMES_USED} times" + weeks: + _: "{WEEKS}" + withUnit: + zero: "" + one: "{WEEKS} week" + two: "" + few: "" + many: "" + other: "{WEEKS} weeks" + words: _: "{WORDS}" thousand: "{WORDS}k" @@ -160,6 +180,16 @@ count: many: "" other: "{WORDS} words" + years: + _: "{YEARS}" + withUnit: + zero: "" + one: "{YEARS} year" + two: "" + few: "" + many: "" + other: "{YEARS} years" + # Numerical things that aren't exactly counting, per se duration: @@ -172,6 +202,24 @@ count: _: "{MINUTES}:{SECONDS}" withUnit: "{MINUTES}:{SECONDS} minutes" + dateDuration: + earlier: "{DURATION} earlier" + later: "{DURATION} later" + same: "on the same date" + zero: "no days apart" + approximate: "about {DURATION}" + approximateEarlier: "about {DURATION} earlier" + approximateLater: "about {DURATION} later" + relativeAbsolute: "{ABSOLUTE}; {RELATIVE}" + + years: "{YEARS}" + months: "{MONTHS}" + days: "{DAYS}" + yearsMonthsDays: "{YEARS}, {MONTHS}, {DAYS}" + yearsMonths: "{YEARS}, {MONTHS}" + yearsDays: "{YEARS}, {DAYS}" + monthsDays: "{MONTHS}, {DAYS}" + fileSize: terabytes: "{TERABYTES} TB" gigabytes: "{GIGABYTES} GB" -- cgit 1.3.0-6-gf8a5 From 2c6d88908591a0f9d734d634f3f2ab4aca575be5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 21:46:46 -0400 Subject: content: generateRelativeDatetimestamp --- .../dependencies/generateRelativeDatetimestamp.js | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/content/dependencies/generateRelativeDatetimestamp.js diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js new file mode 100644 index 00000000..f0417594 --- /dev/null +++ b/src/content/dependencies/generateRelativeDatetimestamp.js @@ -0,0 +1,58 @@ +export default { + contentDependencies: [ + 'generateAbsoluteDatetimestamp', + 'generateDatetimestampTemplate', + ], + + extraDependencies: ['html', 'language'], + + data: (currentDate, referenceDate) => + (currentDate.getTime() === referenceDate.getTime() + ? {equal: true, date: currentDate} + : {equal: false, currentDate, referenceDate}), + + relations: (relation, currentDate) => + ({template: relation('generateDatetimestampTemplate'), + fallback: relation('generateAbsoluteDatetimestamp', currentDate)}), + + slots: { + style: { + validate: v => v.is('full', 'year'), + default: 'full', + }, + + tooltip: { + type: 'boolean', + default: false, + }, + }, + + generate(data, relations, slots, {language}) { + if (data.comparison === 'equal') { + return relations.fallback.slots({ + style: slots.style, + tooltip: slots.tooltip, + }); + } + + return relations.template.slots({ + mainContent: + (slots.style === 'full' + ? language.formatDate(data.currentDate) + : slots.style === 'year' + ? data.currentDate.getFullYear().toString() + : null), + + tooltipContent: + slots.tooltip && + language.formatRelativeDate(data.currentDate, data.referenceDate, { + considerRoundingDays: true, + approximate: true, + absolute: true, + }), + + datetime: + data.currentDate.toISOString(), + }); + }, +}; -- cgit 1.3.0-6-gf8a5 From 628a608833f938d79d10c250fb1bf780ec133276 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 21:48:05 -0400 Subject: content: generateTrackInfoPage: embed year next to other releases --- src/content/dependencies/generateTrackInfoPage.js | 50 +++++++++++++++++++---- src/strings-default.yaml | 5 ++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 2848b15c..099b889b 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -1,10 +1,11 @@ -import {empty} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; import {sortAlbumsTracksChronologically, sortFlashesChronologically} from '#wiki-data'; import getChronologyRelations from '../util/getChronologyRelations.js'; export default { contentDependencies: [ + 'generateAbsoluteDatetimestamp', 'generateAdditionalFilesShortcut', 'generateAdditionalNamesBox', 'generateAlbumAdditionalFilesList', @@ -16,6 +17,7 @@ export default { 'generateContentHeading', 'generateContributionList', 'generatePageLayout', + 'generateRelativeDatetimestamp', 'generateTrackCoverArtwork', 'generateTrackList', 'generateTrackListDividedByGroups', @@ -140,6 +142,25 @@ export default { otherReleases.heading = relation('generateContentHeading'); + otherReleases.trackLinks = + track.otherReleases + .map(track => relation('linkTrack', track)); + + otherReleases.albumLinks = + track.otherReleases + .map(track => relation('linkAlbum', track.album)); + + otherReleases.datetimestamps = + track.otherReleases.map(track2 => + (track2.date + ? (track.date + ? relation('generateRelativeDatetimestamp', + track2.date, + track.date) + : relation('generateAbsoluteDatetimestamp', + track2.date)) + : null)); + otherReleases.items = track.otherReleases.map(track => ({ trackLink: relation('linkTrack', track), @@ -356,12 +377,27 @@ export default { }), html.tag('ul', - sec.otherReleases.items.map(({trackLink, albumLink}) => - html.tag('li', - language.$('releaseInfo.alsoReleasedAs.item', { - track: trackLink, - album: albumLink, - })))), + stitchArrays({ + trackLink: sec.otherReleases.trackLinks, + albumLink: sec.otherReleases.albumLinks, + datetimestamp: sec.otherReleases.datetimestamps, + }).map(({trackLink, albumLink, datetimestamp}) => { + const parts = ['releaseInfo.alsoReleasedAs.item']; + const options = {track: trackLink, album: albumLink}; + + if (datetimestamp) { + parts.push('withYear'); + options.year = + datetimestamp.slots({ + style: 'year', + tooltip: true, + }); + } + + return ( + html.tag('li', + language.$(...parts, options))); + })), ], sec.contributors && [ diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 30b04141..44f17036 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -268,7 +268,10 @@ releaseInfo: alsoReleasedAs: _: "Also released as:" - item: "{TRACK} (on {ALBUM})" + + item: + _: "{TRACK} ({ALBUM})" + withYear: "({YEAR}) {TRACK} ({ALBUM})" tracksReferenced: "Tracks that {TRACK} references:" tracksThatReference: "Tracks that reference {TRACK}:" -- cgit 1.3.0-6-gf8a5 From 84503bc64dca3e6bf6c135feb53338de5815ad3c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 21:48:27 -0400 Subject: css: don't indent tooltip for single-item list --- src/static/site6.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/static/site6.css b/src/static/site6.css index 9d3da7c4..e30992f5 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -514,7 +514,7 @@ a:not([href]):hover { left: -10px; } -li .datetimestamp-tooltip { +li:not(:first-child:last-child) .datetimestamp-tooltip { left: 14px; } -- cgit 1.3.0-6-gf8a5 From 7d2f0dab8a21a4282f1ee5bcbe418765782eb9b6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 22:02:41 -0400 Subject: content: generateTrackInfoPage: colorize tooltips --- src/content/dependencies/generateTrackInfoPage.js | 24 +++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 099b889b..11fb2b9c 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -13,6 +13,7 @@ export default { 'generateAlbumSidebar', 'generateAlbumStyleRules', 'generateChronologyLinks', + 'generateColorStyleVariables', 'generateCommentarySection', 'generateContentHeading', 'generateContributionList', @@ -142,6 +143,10 @@ export default { otherReleases.heading = relation('generateContentHeading'); + otherReleases.colorVariables = + track.otherReleases + .map(() => relation('generateColorStyleVariables')); + otherReleases.trackLinks = track.otherReleases .map(track => relation('linkTrack', track)); @@ -311,6 +316,9 @@ export default { hasTrackNumbers: track.album.hasTrackNumbers, trackNumber: track.album.tracks.indexOf(track) + 1, + otherReleaseColors: + track.otherReleases.map(track => track.color), + numAdditionalFiles: track.additionalFiles.length, }; }, @@ -381,9 +389,20 @@ export default { trackLink: sec.otherReleases.trackLinks, albumLink: sec.otherReleases.albumLinks, datetimestamp: sec.otherReleases.datetimestamps, - }).map(({trackLink, albumLink, datetimestamp}) => { + colorVariables: sec.otherReleases.colorVariables, + color: data.otherReleaseColors, + }).map(({ + trackLink, + albumLink, + datetimestamp, + colorVariables, + color, + }) => { const parts = ['releaseInfo.alsoReleasedAs.item']; - const options = {track: trackLink, album: albumLink}; + const options = {}; + + options.track = trackLink.slot('color', false); + options.album = albumLink; if (datetimestamp) { parts.push('withYear'); @@ -396,6 +415,7 @@ export default { return ( html.tag('li', + {style: colorVariables.slot('color', color).content}, language.$(...parts, options))); })), ], -- cgit 1.3.0-6-gf8a5 From 67b5ad5f64b5a75a727decd732119343b34c7cdc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 22:11:02 -0400 Subject: content: generateRelativeDatetimestamp: don't double full date in tooltip --- src/content/dependencies/generateRelativeDatetimestamp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js index f0417594..bbe33188 100644 --- a/src/content/dependencies/generateRelativeDatetimestamp.js +++ b/src/content/dependencies/generateRelativeDatetimestamp.js @@ -48,7 +48,7 @@ export default { language.formatRelativeDate(data.currentDate, data.referenceDate, { considerRoundingDays: true, approximate: true, - absolute: true, + absolute: slots.style === 'year', }), datetime: -- cgit 1.3.0-6-gf8a5 From 26fb06c9238a44df75a079fb9495aaff65ebada6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 22:27:45 -0400 Subject: data: language: tweak formatDateDuration zero string --- src/strings-default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 44f17036..61b376c0 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -206,7 +206,7 @@ count: earlier: "{DURATION} earlier" later: "{DURATION} later" same: "on the same date" - zero: "no days apart" + zero: "at most one day" approximate: "about {DURATION}" approximateEarlier: "about {DURATION} earlier" approximateLater: "about {DURATION} later" -- cgit 1.3.0-6-gf8a5 From 168efd0f2685fa00259fffc9f26c7f6a30a61991 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 22:29:26 -0400 Subject: data: language: don't approximate same date in formatRelativeDate --- src/data/things/language.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/things/language.js b/src/data/things/language.js index fa529a8e..80a34575 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -369,7 +369,7 @@ export class Language extends Thing { const relative = this.formatString( 'count.dateDuration', - (approximate + (approximate && (years || months || days) ? (comparison === -1 ? 'approximateEarlier' : 'approximateLater') -- cgit 1.3.0-6-gf8a5 From db4698f9ce602227dc2c9aa79c409dd4ce508e7b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 13:23:40 -0400 Subject: data: withFilteredList, withMappedList, withSortedList God bless thine soul, these are not unit tested. --- src/data/composite/data/excludeFromList.js | 7 +- src/data/composite/data/fillMissingListItems.js | 7 +- src/data/composite/data/index.js | 3 + src/data/composite/data/withFilteredList.js | 50 +++++++++ src/data/composite/data/withFlattenedList.js | 6 +- src/data/composite/data/withMappedList.js | 39 +++++++ src/data/composite/data/withPropertiesFromList.js | 4 +- src/data/composite/data/withPropertyFromList.js | 4 +- src/data/composite/data/withSortedList.js | 126 ++++++++++++++++++++++ src/data/composite/data/withUnflattenedList.js | 10 ++ 10 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 src/data/composite/data/withFilteredList.js create mode 100644 src/data/composite/data/withMappedList.js create mode 100644 src/data/composite/data/withSortedList.js diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js index 718f2294..d798dcdc 100644 --- a/src/data/composite/data/excludeFromList.js +++ b/src/data/composite/data/excludeFromList.js @@ -6,10 +6,9 @@ // - fillMissingListItems // // More list utilities: -// - withFlattenedList -// - withPropertyFromList -// - withPropertiesFromList -// - withUnflattenedList +// - withFilteredList, withMappedList, withSortedList +// - withFlattenedList, withUnflattenedList +// - withPropertyFromList, withPropertiesFromList // import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js index c06eceda..4f818a79 100644 --- a/src/data/composite/data/fillMissingListItems.js +++ b/src/data/composite/data/fillMissingListItems.js @@ -5,10 +5,9 @@ // - excludeFromList // // More list utilities: -// - withFlattenedList -// - withPropertyFromList -// - withPropertiesFromList -// - withUnflattenedList +// - withFilteredList, withMappedList, withSortedList +// - withFlattenedList, withUnflattenedList +// - withPropertyFromList, withPropertiesFromList // import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index e2927afd..256c0490 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -5,10 +5,13 @@ export {default as excludeFromList} from './excludeFromList.js'; export {default as fillMissingListItems} from './fillMissingListItems.js'; +export {default as withFilteredList} from './withFilteredList.js'; export {default as withFlattenedList} from './withFlattenedList.js'; +export {default as withMappedList} from './withMappedList.js'; export {default as withPropertiesFromList} from './withPropertiesFromList.js'; export {default as withPropertiesFromObject} from './withPropertiesFromObject.js'; export {default as withPropertyFromList} from './withPropertyFromList.js'; export {default as withPropertyFromObject} from './withPropertyFromObject.js'; +export {default as withSortedList} from './withSortedList.js'; export {default as withUnflattenedList} from './withUnflattenedList.js'; export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js'; diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js new file mode 100644 index 00000000..82e56903 --- /dev/null +++ b/src/data/composite/data/withFilteredList.js @@ -0,0 +1,50 @@ +// Applies a filter - an array of truthy and falsy values - to the index- +// corresponding items in a list. Items which correspond to a truthy value +// are kept, and the rest are excluded from the output list. +// +// TODO: It would be neat to apply an availability check here, e.g. to allow +// not providing a filter at all and performing the check on the contents of +// the list (though on the filter, if present, is fine too). But that's best +// done by some shmancy-fancy mapping support in composite.js, so a bit out +// of reach for now (apart from proving uses built on top of a more boring +// implementation). +// +// TODO: There should be two outputs - one for the items included according to +// the filter, and one for the items excluded. +// +// See also: +// - withMappedList +// - withSortedList +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withFlattenedList, withUnflattenedList +// - withPropertyFromList, withPropertiesFromList +// + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `withFilteredList`, + + inputs: { + list: input({type: 'array'}), + filter: input({type: 'array'}), + }, + + outputs: ['#filteredList'], + + steps: () => [ + { + dependencies: [input('list'), input('filter')], + compute: (continuation, { + [input('list')]: list, + [input('filter')]: filter, + }) => continuation({ + '#filteredList': + list.filter((item, index) => filter[index]), + }), + }, + ], +}); diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js index b08edb4e..edfa3403 100644 --- a/src/data/composite/data/withFlattenedList.js +++ b/src/data/composite/data/withFlattenedList.js @@ -3,13 +3,13 @@ // successive source array. // // See also: -// - withFlattenedList +// - withUnflattenedList // // More list utilities: // - excludeFromList // - fillMissingListItems -// - withPropertyFromList -// - withPropertiesFromList +// - withFilteredList, withMappedList, withSortedList +// - withPropertyFromList, withPropertiesFromList // import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js new file mode 100644 index 00000000..e0a700b2 --- /dev/null +++ b/src/data/composite/data/withMappedList.js @@ -0,0 +1,39 @@ +// Applies a map function to each item in a list, just like a normal JavaScript +// map. +// +// See also: +// - withFilteredList +// - withSortedList +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withFlattenedList, withUnflattenedList +// - withPropertyFromList, withPropertiesFromList +// + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `withMappedList`, + + inputs: { + list: input({type: 'array'}), + map: input({type: 'function'}), + }, + + outputs: ['#mappedList'], + + steps: () => [ + { + dependencies: [input('list'), input('map')], + compute: (continuation, { + [input('list')]: list, + [input('map')]: mapFn, + }) => continuation({ + ['#mappedList']: + list.map(mapFn), + }), + }, + ], +}); diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js index 76ba696c..08907bab 100644 --- a/src/data/composite/data/withPropertiesFromList.js +++ b/src/data/composite/data/withPropertiesFromList.js @@ -11,8 +11,8 @@ // More list utilities: // - excludeFromList // - fillMissingListItems -// - withFlattenedList -// - withUnflattenedList +// - withFilteredList, withMappedList, withSortedList +// - withFlattenedList, withUnflattenedList // import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js index 1983ebbc..a2c66d77 100644 --- a/src/data/composite/data/withPropertyFromList.js +++ b/src/data/composite/data/withPropertyFromList.js @@ -12,8 +12,8 @@ // More list utilities: // - excludeFromList // - fillMissingListItems -// - withFlattenedList -// - withUnflattenedList +// - withFilteredList, withMappedList, withSortedList +// - withFlattenedList, withUnflattenedList // import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js new file mode 100644 index 00000000..882907f5 --- /dev/null +++ b/src/data/composite/data/withSortedList.js @@ -0,0 +1,126 @@ +// Applies a sort function across pairs of items in a list, just like a normal +// JavaScript sort. Alongside the sorted results, so are outputted the indices +// which each item in the unsorted list corresponds to in the sorted one, +// allowing for the results of this sort to be composed in some more involved +// operation. For example, using an alphabetical sort, the list ['banana', +// 'apple', 'pterodactyl'] will output the expected alphabetical items, as well +// as the indices list [1, 0, 2]. +// +// If two items are equal (in the eyes of the sort operation), their placement +// in the sorted list is arbitrary, though every input index will be present in +// '#sortIndices' exactly once (and equal items will be bunched together). +// +// The '#sortIndices' output refers to the "true" index which each source item +// occupies in the sorted list. This sacrifices information about equal items, +// which can be obtained through '#unstableSortIndices' instead: each mapped +// index may appear more than once, and rather than represent exact positions +// in the sorted list, they represent relational values: if items A and B are +// mapped to indices 3 and 5, then A certainly is positioned before B (and vice +// versa); but there may be more than one item in-between. If items C and D are +// both mapped to index 4, then their position relative to each other is +// arbitrary - they are equal - but they both certainly appear after item A and +// before item B. +// +// This implementation is based on the one used for sortMultipleArrays. +// +// See also: +// - withFilteredList +// - withMappedList +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withFlattenedList, withUnflattenedList +// - withPropertyFromList, withPropertiesFromList +// + +import {input, templateCompositeFrom} from '#composite'; +import {empty} from '#sugar'; + +export default templateCompositeFrom({ + annotation: `withSortedList`, + + inputs: { + list: input({type: 'array'}), + sort: input({type: 'function'}), + }, + + outputs: ['#sortedList', '#sortIndices', '#unstableSortIndices'], + + steps: () => [ + { + dependencies: [input('list'), input('sort')], + compute(continuation, { + [input('list')]: list, + [input('sort')]: sortFn, + }) { + const symbols = + Array.from({length: list.length}, () => Symbol()); + + const equalSymbols = + new Map(); + + const indexMap = + new Map(Array.from(symbols, + (symbol, index) => [symbol, index])); + + symbols.sort((symbol1, symbol2) => { + const comparison = + sortFn( + list[indexMap.get(symbol1)], + list[indexMap.get(symbol2)]); + + if (comparison === 0) { + if (equalSymbols.has(symbol1)) { + equalSymbols.get(symbol1).add(symbol2); + } else { + equalSymbols.set(symbol1, new Set([symbol2])); + } + + if (equalSymbols.has(symbol2)) { + equalSymbols.get(symbol2).add(symbol1); + } else { + equalSymbols.set(symbol2, new Set([symbol1])); + } + } + + return comparison; + }); + + const sortIndices = + symbols.map(symbol => indexMap.get(symbol)); + + const sortedList = + sortIndices.map(index => list[index]); + + const stableToUnstable = + symbols + .map((symbol, index) => + index > 0 && + equalSymbols.get(symbols[index - 1])?.has(symbol)) + .reduce((accumulator, collapseEqual) => { + if (empty(accumulator)) { + accumulator.push(0); + } else { + const last = accumulator[accumulator.length - 1]; + if (collapseEqual) { + accumulator.push(last); + } else { + accumulator.push(last + 1); + } + } + return accumulator; + }, []); + + const unstableSortIndices = + sortIndices.map(stable => stableToUnstable[stable]); + + return continuation({ + ['#sortedList']: sortedList, + ['#sortIndices']: sortIndices, + ['#unstableSortIndices']: unstableSortIndices, + }); + }, + }, + ], +}); diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js index 3cfc247b..39a666dc 100644 --- a/src/data/composite/data/withUnflattenedList.js +++ b/src/data/composite/data/withUnflattenedList.js @@ -3,6 +3,16 @@ // of filtering them out), this function allows for recombining them. It will // filter out null and undefined items by default (pass {filter: false} to // disable this). +// +// See also: +// - withFlattenedList +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withFilteredList, withMappedList, withSortedList +// - withPropertyFromList, withPropertiesFromList +// import {input, templateCompositeFrom} from '#composite'; import {isWholeNumber, validateArrayItems} from '#validators'; -- cgit 1.3.0-6-gf8a5 From 5c1d9fb97b8ecc61c0343d6eee63a735c34e53c9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 13:25:05 -0400 Subject: data: withThingsSortedAlphabetically --- src/data/composite/wiki-data/index.js | 1 + .../wiki-data/withThingsSortedAlphabetically.js | 122 +++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/data/composite/wiki-data/withThingsSortedAlphabetically.js diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index df50a2db..a2ff09d8 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -12,3 +12,4 @@ export {default as withResolvedContribs} from './withResolvedContribs.js'; export {default as withResolvedReference} from './withResolvedReference.js'; export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; export {default as withReverseReferenceList} from './withReverseReferenceList.js'; +export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js'; diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js new file mode 100644 index 00000000..d2487e42 --- /dev/null +++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js @@ -0,0 +1,122 @@ +// Sorts a list of live, generic wiki data objects alphabetically. +// Note that this uses localeCompare but isn't specialized to a particular +// language; where localization is concerned (in content), a follow-up, locale- +// specific sort should be performed. But this function does serve to organize +// a list so same-name entries are beside each other. + +import {input, templateCompositeFrom} from '#composite'; +import {validateWikiData} from '#validators'; +import {compareCaseLessSensitive, normalizeName} from '#wiki-data'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withMappedList, withSortedList, withPropertiesFromList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withThingsSortedAlphabetically`, + + inputs: { + things: input({validate: validateWikiData}), + }, + + outputs: ['#sortedThings'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('things'), + mode: input.value('empty'), + output: input.value({'#sortedThings': []}), + }), + + withPropertiesFromList({ + list: input('things'), + properties: input.value(['name', 'directory']), + }).outputs({ + '#list.name': '#names', + '#list.directory': '#directories', + }), + + withMappedList({ + list: '#names', + map: input.value(normalizeName), + }).outputs({ + '#mappedList': '#normalizedNames', + }), + + withSortedList({ + list: '#normalizedNames', + sort: input.value(compareCaseLessSensitive), + }).outputs({ + '#unstableSortIndices': '#normalizedNameSortIndices', + }), + + withSortedList({ + list: '#names', + sort: input.value(compareCaseLessSensitive), + }).outputs({ + '#unstableSortIndices': '#nonNormalizedNameSortIndices', + }), + + withSortedList({ + list: '#directories', + sort: input.value(compareCaseLessSensitive), + }).outputs({ + '#unstableSortIndices': '#directorySortIndices', + }), + + // TODO: No primitive for the next two-three steps, yet... + + { + dependencies: [input('things')], + compute: (continuation, { + [input('things')]: things, + }) => continuation({ + ['#combinedSortIndices']: + Array.from( + {length: things.length}, + (_item, index) => index), + }), + }, + + { + dependencies: [ + '#combinedSortIndices', + '#normalizedNameSortIndices', + '#nonNormalizedNameSortIndices', + '#directorySortIndices', + ], + + compute: (continuation, { + ['#combinedSortIndices']: combined, + ['#normalizedNameSortIndices']: normalized, + ['#nonNormalizedNameSortIndices']: nonNormalized, + ['#directorySortIndices']: directory, + }) => continuation({ + ['#combinedSortIndices']: + combined.sort((index1, index2) => { + if (normalized[index1] !== normalized[index2]) + return normalized[index1] - normalized[index2]; + + if (nonNormalized[index1] !== nonNormalized[index2]) + return nonNormalized[index1] - nonNormalized[index2]; + + if (directory[index1] !== directory[index2]) + return directory[index1] - directory[index2]; + + return 0; + }), + }), + }, + + { + dependencies: [input('things'), '#combinedSortIndices'], + compute: (continuation, { + [input('things')]: things, + ['#combinedSortIndices']: combined, + }) => continuation({ + ['#sortedThings']: + combined.map(index => things[index]), + }), + }, + ], +}); -- cgit 1.3.0-6-gf8a5 From 4ba84964a90ec93d6d30d577e9e00c3e5b4fca83 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 13:26:52 -0400 Subject: data: individual custom additional name list props --- src/data/composite/things/track/index.js | 5 +- .../things/track/inferredAdditionalNameList.js | 67 ++++++++++++++++++ .../things/track/sharedAdditionalNameList.js | 38 ++++++++++ .../things/track/withInferredAdditionalNames.js | 80 ---------------------- .../things/track/withSharedAdditionalNames.js | 46 ------------- .../wiki-properties/additionalNameList.js | 1 + src/data/things/track.js | 9 ++- src/data/things/validators.js | 25 +++++-- 8 files changed, 133 insertions(+), 138 deletions(-) create mode 100644 src/data/composite/things/track/inferredAdditionalNameList.js create mode 100644 src/data/composite/things/track/sharedAdditionalNameList.js delete mode 100644 src/data/composite/things/track/withInferredAdditionalNames.js delete mode 100644 src/data/composite/things/track/withSharedAdditionalNames.js diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index b5f1e3e2..cc723a24 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,12 +1,11 @@ export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; +export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js'; export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js'; -export {default as trackAdditionalNameList} from './trackAdditionalNameList.js'; +export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.js'; export {default as trackReverseReferenceList} from './trackReverseReferenceList.js'; export {default as withAlbum} from './withAlbum.js'; export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js'; export {default as withContainingTrackSection} from './withContainingTrackSection.js'; export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; -export {default as withInferredAdditionalNames} from './withInferredAdditionalNames.js'; export {default as withOtherReleases} from './withOtherReleases.js'; export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; -export {default as withSharedAdditionalNames} from './withSharedAdditionalNames.js'; diff --git a/src/data/composite/things/track/inferredAdditionalNameList.js b/src/data/composite/things/track/inferredAdditionalNameList.js new file mode 100644 index 00000000..9cf158c6 --- /dev/null +++ b/src/data/composite/things/track/inferredAdditionalNameList.js @@ -0,0 +1,67 @@ +// Infers additional name entries from other releases that were titled +// differently; the corresponding releases are stored in eacn entry's "from" +// array, which will include multiple items, if more than one other release +// shares the same name differing from this one's. + +import {input, templateCompositeFrom} from '#composite'; +import {chunkByProperties} from '#wiki-data'; + +import {exitWithoutDependency} from '#composite/control-flow'; +import {withFilteredList, withPropertyFromList} from '#composite/data'; +import {withThingsSortedAlphabetically} from '#composite/wiki-data'; + +import withOtherReleases from './withOtherReleases.js'; + +export default templateCompositeFrom({ + annotation: `inferredAdditionalNameList`, + + compose: false, + + steps: () => [ + withOtherReleases(), + + exitWithoutDependency({ + dependency: '#otherReleases', + mode: input.value('empty'), + value: input.value([]), + }), + + withPropertyFromList({ + list: '#otherReleases', + property: input.value('name'), + }), + + { + dependencies: ['#otherReleases.name', 'name'], + compute: (continuation, { + ['#otherReleases.name']: releaseNames, + ['name']: ownName, + }) => continuation({ + ['#nameFilter']: + releaseNames.map(name => name !== ownName), + }), + }, + + withFilteredList({ + list: '#otherReleases', + filter: '#nameFilter', + }).outputs({ + '#filteredList': '#differentlyNamedReleases', + }), + + withThingsSortedAlphabetically({ + things: '#differentlyNamedReleases', + }).outputs({ + '#sortedThings': '#differentlyNamedReleases', + }), + + { + dependencies: ['#differentlyNamedReleases'], + compute: ({ + ['#differentlyNamedReleases']: releases, + }) => + chunkByProperties(releases, ['name']) + .map(({name, chunk}) => ({name, from: chunk})), + }, + ], +}); diff --git a/src/data/composite/things/track/sharedAdditionalNameList.js b/src/data/composite/things/track/sharedAdditionalNameList.js new file mode 100644 index 00000000..1806ec80 --- /dev/null +++ b/src/data/composite/things/track/sharedAdditionalNameList.js @@ -0,0 +1,38 @@ +// Compiles additional names directly provided by other releases. + +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withFlattenedList, withPropertyFromList} from '#composite/data'; + +import withOtherReleases from './withOtherReleases.js'; + +export default templateCompositeFrom({ + annotation: `sharedAdditionalNameList`, + + compose: false, + + steps: () => [ + withOtherReleases(), + + exitWithoutDependency({ + dependency: '#otherReleases', + mode: input.value('empty'), + value: input.value([]), + }), + + withPropertyFromList({ + list: '#otherReleases', + property: input.value('additionalNames'), + }), + + withFlattenedList({ + list: '#otherReleases.additionalNames', + }), + + exposeDependency({ + dependency: '#flattenedList', + }), + ], +}); diff --git a/src/data/composite/things/track/withInferredAdditionalNames.js b/src/data/composite/things/track/withInferredAdditionalNames.js deleted file mode 100644 index 659d6b8f..00000000 --- a/src/data/composite/things/track/withInferredAdditionalNames.js +++ /dev/null @@ -1,80 +0,0 @@ -// Infers additional name entries from other releases that were titled -// differently, linking to the respective release via annotation. - -import {input, templateCompositeFrom} from '#composite'; -import {stitchArrays} from '#sugar'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withPropertiesFromList, withPropertyFromList} from '#composite/data'; - -import withOtherReleases from './withOtherReleases.js'; - -export default templateCompositeFrom({ - annotation: `withInferredAdditionalNames`, - - outputs: ['#inferredAdditionalNames'], - - steps: () => [ - withOtherReleases(), - - raiseOutputWithoutDependency({ - dependency: '#otherReleases', - mode: input.value('empty'), - output: input.value({'#inferredAdditionalNames': []}), - }), - - { - dependencies: ['#otherReleases', 'name'], - compute: (continuation, { - ['#otherReleases']: otherReleases, - ['name']: name, - }) => continuation({ - ['#differentlyNamedReleases']: - otherReleases.filter(release => release.name !== name), - }), - }, - - withPropertiesFromList({ - list: '#differentlyNamedReleases', - properties: input.value(['name', 'directory', 'album']), - }), - - withPropertyFromList({ - list: '#differentlyNamedReleases.album', - property: input.value('name'), - }), - - { - dependencies: [ - '#differentlyNamedReleases.directory', - '#differentlyNamedReleases.album.name', - ], - - compute: (continuation, { - ['#differentlyNamedReleases.directory']: trackDirectories, - ['#differentlyNamedReleases.album.name']: albumNames, - }) => continuation({ - ['#annotations']: - stitchArrays({ - trackDirectory: trackDirectories, - albumName: albumNames, - }).map(({trackDirectory, albumName}) => - `[[track:${trackDirectory}|on ${albumName}]]`) - }) - }, - - { - dependencies: ['#differentlyNamedReleases.name', '#annotations'], - compute: (continuation, { - ['#differentlyNamedReleases.name']: names, - ['#annotations']: annotations, - }) => continuation({ - ['#inferredAdditionalNames']: - stitchArrays({ - name: names, - annotation: annotations, - }), - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withSharedAdditionalNames.js b/src/data/composite/things/track/withSharedAdditionalNames.js deleted file mode 100644 index bba675c9..00000000 --- a/src/data/composite/things/track/withSharedAdditionalNames.js +++ /dev/null @@ -1,46 +0,0 @@ -// Compiles additional names directly provided on other releases. - -import {input, templateCompositeFrom} from '#composite'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withFlattenedList} from '#composite/data'; - -import CacheableObject from '#cacheable-object'; - -import withOtherReleases from './withOtherReleases.js'; - -export default templateCompositeFrom({ - annotation: `withSharedAdditionalNames`, - - outputs: ['#sharedAdditionalNames'], - - steps: () => [ - withOtherReleases(), - - raiseOutputWithoutDependency({ - dependency: '#otherReleases', - mode: input.value('empty'), - output: input.value({'#sharedAdditionalNames': []}), - }), - - // TODO: Using getUpdateValue is always a bit janky. - - { - dependencies: ['#otherReleases'], - compute: (continuation, { - ['#otherReleases']: otherReleases, - }) => continuation({ - ['#otherReleases.additionalNames']: - otherReleases.map(release => - CacheableObject.getUpdateValue(release, 'additionalNames') - ?? []), - }), - }, - - withFlattenedList({ - list: '#otherReleases.additionalNames', - }).outputs({ - '#flattenedList': '#sharedAdditionalNames', - }), - ], -}); diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js index d1302224..c5971d4a 100644 --- a/src/data/composite/wiki-properties/additionalNameList.js +++ b/src/data/composite/wiki-properties/additionalNameList.js @@ -9,5 +9,6 @@ export default function() { return { flags: {update: true, expose: true}, update: {validate: isAdditionalNameList}, + expose: {transform: value => value ?? []}, }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 1f99ef53..08891719 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -24,6 +24,7 @@ import { import { additionalFiles, + additionalNameList, commentary, commentatorArtists, contributionList, @@ -42,8 +43,9 @@ import { import { exitWithoutUniqueCoverArt, + inferredAdditionalNameList, inheritFromOriginalRelease, - trackAdditionalNameList, + sharedAdditionalNameList, trackReverseReferenceList, withAlbum, withAlwaysReferenceByDirectory, @@ -64,7 +66,10 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), - additionalNames: trackAdditionalNameList(), + + additionalNames: additionalNameList(), + sharedAdditionalNames: sharedAdditionalNameList(), + inferredAdditionalNames: inferredAdditionalNameList(), duration: duration(), urls: urls(), diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 55eedbcf..ac91b456 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -429,13 +429,6 @@ export function isURL(string) { return true; } -export const isAdditionalName = validateProperties({ - name: isName, - annotation: optional(isStringNonEmpty), -}); - -export const isAdditionalNameList = validateArrayItems(isAdditionalName); - export function validateReference(type = 'track') { return (ref) => { isStringNonEmpty(ref); @@ -557,6 +550,24 @@ export function validateWikiData({ }; } +export const isAdditionalName = validateProperties({ + name: isName, + annotation: optional(isStringNonEmpty), + + // TODO: This only allows indicating sourcing from a track. + // That's okay for the current limited use of "from", but + // could be expanded later. + from: + // Double TODO: Explicitly allowing both references and + // live objects to co-exist is definitely weird, and + // altogether questions the way we define validators... + optional(oneOf( + validateReferenceList('track'), + validateWikiData({referenceType: 'track'}))), +}); + +export const isAdditionalNameList = validateArrayItems(isAdditionalName); + // Compositional utilities export function oneOf(...checks) { -- cgit 1.3.0-6-gf8a5 From 38e048838eae945a1b0ed8cffd747c0534e46af2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 13:28:26 -0400 Subject: content: generateAdditionalNamesBoxItem + "from" support --- .../dependencies/generateAdditionalNamesBox.js | 46 +++------------ .../dependencies/generateAdditionalNamesBoxItem.js | 68 ++++++++++++++++++++++ src/static/site6.css | 2 +- src/strings-default.yaml | 8 ++- 4 files changed, 84 insertions(+), 40 deletions(-) create mode 100644 src/content/dependencies/generateAdditionalNamesBoxItem.js diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js index f7fa3b00..63427c58 100644 --- a/src/content/dependencies/generateAdditionalNamesBox.js +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -1,48 +1,20 @@ -import {stitchArrays} from '#sugar'; - export default { - contentDependencies: ['transformContent'], + contentDependencies: ['generateAdditionalNamesBoxItem'], extraDependencies: ['html', 'language'], relations: (relation, additionalNames) => ({ - names: - additionalNames.map(({name}) => - relation('transformContent', name)), - - annotations: - additionalNames.map(({annotation}) => - (annotation - ? relation('transformContent', annotation) - : null)), + items: + additionalNames + .map(entry => relation('generateAdditionalNamesBoxItem', entry)), }), - generate: (relations, {html, language}) => { - const names = - relations.names.map(name => - html.tag('span', {class: 'additional-name'}, - name.slot('mode', 'inline'))); - - const annotations = - relations.annotations.map(annotation => - (annotation - ? html.tag('span', {class: 'annotation'}, - language.$('misc.additionalNames.item.annotation', { - annotation: - annotation.slot('mode', 'inline'), - })) - : null)); - - return html.tag('div', {id: 'additional-names-box'}, [ + generate: (relations, {html, language}) => + html.tag('div', {id: 'additional-names-box'}, [ html.tag('p', language.$('misc.additionalNames.title')), html.tag('ul', - stitchArrays({name: names, annotation: annotations}) - .map(({name, annotation}) => - html.tag('li', - (annotation - ? language.$('misc.additionalNames.item.withAnnotation', {name, annotation}) - : language.$('misc.additionalNames.item', {name}))))), - ]); - }, + relations.items + .map(item => html.tag('li', item))), + ]), }; diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js new file mode 100644 index 00000000..bb4c8477 --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js @@ -0,0 +1,68 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['linkTrack', 'transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + nameContent: + relation('transformContent', entry.name), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + + trackLinks: + (entry.from + ? entry.from.map(track => relation('linkTrack', track)) + : null), + }), + + data: (entry) => ({ + albumNames: + (entry.from + ? entry.from.map(track => track.album.name) + : null), + }), + + generate: (data, relations, {html, language}) => { + const prefix = 'misc.additionalNames.item'; + + const itemParts = [prefix]; + const itemOptions = {}; + + itemOptions.name = + html.tag('span', {class: 'additional-name'}, + relations.nameContent.slot('mode', 'inline')); + + const accentParts = [prefix, 'accent']; + const accentOptions = {}; + + if (relations.annotationContent) { + accentParts.push('withAnnotation'); + accentOptions.annotation = + relations.annotationContent.slot('mode', 'inline'); + } + + if (relations.trackLinks) { + accentParts.push('withAlbums'); + accentOptions.albums = + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + }).map(({trackLink, albumName}) => + trackLink.slot('content', albumName))); + } + + if (accentParts.length > 2) { + itemParts.push('withAccent'); + itemOptions.accent = + html.tag('span', {class: 'accent'}, + language.$(...accentParts, accentOptions)); + } + + return language.$(...itemParts, itemOptions); + }, +}; diff --git a/src/static/site6.css b/src/static/site6.css index 76b58f32..892458d6 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -955,7 +955,7 @@ h1 a[href="#additional-names-box"]:hover { margin-top: 0.5em; } -#additional-names-box li .annotation { +#additional-names-box li .accent { opacity: 0.8; display: inline-block; } diff --git a/src/strings-default.yaml b/src/strings-default.yaml index f02a10a0..53763dcc 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -346,8 +346,12 @@ misc: item: _: "{NAME}" - withAnnotation: "{NAME} {ANNOTATION}" - annotation: "({ANNOTATION})" + withAccent: "{NAME} {ACCENT}" + + accent: + withAnnotation: "({ANNOTATION})" + withAlbums: "(on {ALBUMS})" + withAnnotations.withAlbums: "({ANNOTATION}; on {ALBUMS})" # alt: # Fallback text for the alt text of images and artworks - these -- cgit 1.3.0-6-gf8a5 From 2f0ff9ced6c8a29b098a51f751522ebe11704063 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 13:28:59 -0400 Subject: content: generateTrackAdditionalNamesBox --- .../generateTrackAdditionalNamesBox.js | 53 ++++++++++++++++++++++ src/content/dependencies/generateTrackInfoPage.js | 11 ++--- 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/content/dependencies/generateTrackAdditionalNamesBox.js diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js new file mode 100644 index 00000000..bad04b74 --- /dev/null +++ b/src/content/dependencies/generateTrackAdditionalNamesBox.js @@ -0,0 +1,53 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['generateAdditionalNamesBox'], + extraDependencies: ['html'], + + query: (track) => { + const { + additionalNames: own, + sharedAdditionalNames: shared, + inferredAdditionalNames: inferred, + } = track; + + if (empty(own) && empty(shared) && empty(inferred)) { + return {combinedList: []}; + } + + const firstFilter = + (empty(own) + ? new Set() + : new Set(own.map(({name}) => name))); + + const sharedFiltered = + shared.filter(({name}) => !firstFilter.has(name)) + + const secondFilter = + new Set([ + ...firstFilter, + ...sharedFiltered.map(({name}) => name), + ]); + + const inferredFiltered = + inferred.filter(({name}) => !secondFilter.has(name)); + + return { + combinedList: [ + ...own, + ...sharedFiltered, + ...inferredFiltered, + ], + }; + }, + + relations: (relation, query) => ({ + box: + (empty(query.combinedList) + ? null + : relation('generateAdditionalNamesBox', query.combinedList)), + }), + + generate: (relations, {html}) => + relations.box ?? html.blank(), +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 2848b15c..d8908ade 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -6,7 +6,6 @@ import getChronologyRelations from '../util/getChronologyRelations.js'; export default { contentDependencies: [ 'generateAdditionalFilesShortcut', - 'generateAdditionalNamesBox', 'generateAlbumAdditionalFilesList', 'generateAlbumNavAccent', 'generateAlbumSidebar', @@ -16,6 +15,7 @@ export default { 'generateContentHeading', 'generateContributionList', 'generatePageLayout', + 'generateTrackAdditionalNamesBox', 'generateTrackCoverArtwork', 'generateTrackList', 'generateTrackListDividedByGroups', @@ -108,10 +108,9 @@ export default { list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), }); - if (!empty(track.additionalNames)) { - relations.additionalNamesBox = - relation('generateAdditionalNamesBox', track.additionalNames); - } + // This'll take care of itself being blank if there's nothing to show here. + relations.additionalNamesBox = + relation('generateTrackAdditionalNamesBox', track); if (track.hasUniqueCoverArt || album.hasCoverArt) { relations.cover = @@ -302,7 +301,7 @@ export default { title: language.$('trackPage.title', {track: data.name}), headingMode: 'sticky', - additionalNames: relations.additionalNamesBox ?? null, + additionalNames: relations.additionalNamesBox, color: data.color, styleRules: [relations.albumStyleRules], -- cgit 1.3.0-6-gf8a5 From c336352a915245e28e08498de61808c96daa3dcf Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 17:49:20 -0400 Subject: test: generateTrackAdditionalNamesBox (snapshot) --- .../generateTrackAdditionalNamesBox.js.test.cjs | 99 +++++++++++++++++++ test/snapshot/generateTrackAdditionalNamesBox.js | 107 +++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs create mode 100644 test/snapshot/generateTrackAdditionalNamesBox.js diff --git a/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs new file mode 100644 index 00000000..3a22266e --- /dev/null +++ b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs @@ -0,0 +1,99 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > inferred additional names only 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ + [ + { name: 'Baz Baz', from: [ { directory: 'the-pyrenees' } ] } + ] + ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > multiple own 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ + [ + { name: 'Apple Time!' }, + { name: 'Pterodactyl Time!' }, + { name: 'Banana Time!' } + ] + ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > no additional names 1`] = ` + +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own additional names only 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ [ { name: 'Foo Bar', annotation: 'the Alps' } ] ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and inferred, some overlap 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ + [ + { name: 'Ke$halo Strike Back', annotation: 'own annotation' }, + { name: 'Ironic Mania', annotation: 'own annotation' }, + { + name: 'ANARCHY::MEGASTRIFE', + from: [ { directory: 'inferred-from' } ] + } + ] + ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared and inferred, various overlap 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ + [ + { name: 'Own!', annotation: 'own annotation' }, + { name: 'Own! Shared!', annotation: 'own annotation' }, + { name: 'Own! Inferred!', annotation: 'own annotation' }, + { name: 'Own! Shared! Inferred!', annotation: 'own annotation' }, + { name: 'Shared!', annotation: 'shared annotation' }, + { name: 'Shared! Inferred!', annotation: 'shared annotation' }, + { name: 'Inferred!', from: [ { directory: 'inferred-from' } ] } + ] + ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared, some overlap 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ + [ + { name: 'weed dreams..', annotation: 'own annotation' }, + { name: '夜間のMOON汗', annotation: 'own annotation' }, + { name: 'GAMINGブラザー96', annotation: 'shared annotation' } + ] + ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared additional names only 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ [ { name: 'Bar Foo', annotation: 'the Rockies' } ] ] + slots: {}] +` + +exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared and inferred, some overlap 1`] = ` +[mocked: generateAdditionalNamesBox + args: [ + [ + { name: 'Coruscate', annotation: 'shared annotation' }, + { name: 'Arbroath', annotation: 'shared annotation' }, + { name: 'Prana Ferox', from: [ { directory: 'inferred-from' } ] } + ] + ] + slots: {}] +` diff --git a/test/snapshot/generateTrackAdditionalNamesBox.js b/test/snapshot/generateTrackAdditionalNamesBox.js new file mode 100644 index 00000000..9c1e3598 --- /dev/null +++ b/test/snapshot/generateTrackAdditionalNamesBox.js @@ -0,0 +1,107 @@ +import t from 'tap'; + +import contentFunction from '#content-function'; +import {testContentFunctions} from '#test-lib'; + +testContentFunctions(t, 'generateTrackAdditionalNamesBox (snapshot)', async (t, evaluate) => { + await evaluate.load({ + mock: { + generateAdditionalNamesBox: + evaluate.stubContentFunction('generateAdditionalNamesBox'), + }, + }); + + const stubTrack = { + additionalNames: [], + sharedAdditionalNames: [], + inferredAdditionalNames: [], + }; + + const quickSnapshot = (message, trackProperties) => + evaluate.snapshot(message, { + name: 'generateTrackAdditionalNamesBox', + args: [{...stubTrack, ...trackProperties}], + }); + + quickSnapshot(`no additional names`, {}); + + quickSnapshot(`own additional names only`, { + additionalNames: [ + {name: `Foo Bar`, annotation: `the Alps`}, + ], + }); + + quickSnapshot(`shared additional names only`, { + sharedAdditionalNames: [ + {name: `Bar Foo`, annotation: `the Rockies`}, + ], + }); + + quickSnapshot(`inferred additional names only`, { + inferredAdditionalNames: [ + {name: `Baz Baz`, from: [{directory: `the-pyrenees`}]}, + ], + }); + + quickSnapshot(`multiple own`, { + additionalNames: [ + {name: `Apple Time!`}, + {name: `Pterodactyl Time!`}, + {name: `Banana Time!`}, + ], + }); + + quickSnapshot(`own and shared, some overlap`, { + additionalNames: [ + {name: `weed dreams..`, annotation: `own annotation`}, + {name: `夜間のMOON汗`, annotation: `own annotation`}, + ], + sharedAdditionalNames: [ + {name: `weed dreams..`, annotation: `shared annotation`}, + {name: `GAMINGブラザー96`, annotation: `shared annotation`}, + ], + }); + + quickSnapshot(`shared and inferred, some overlap`, { + sharedAdditionalNames: [ + {name: `Coruscate`, annotation: `shared annotation`}, + {name: `Arbroath`, annotation: `shared annotation`}, + ], + inferredAdditionalNames: [ + {name: `Arbroath`, from: [{directory: `inferred-from`}]}, + {name: `Prana Ferox`, from: [{directory: `inferred-from`}]}, + ], + }); + + quickSnapshot(`own and inferred, some overlap`, { + additionalNames: [ + {name: `Ke$halo Strike Back`, annotation: `own annotation`}, + {name: `Ironic Mania`, annotation: `own annotation`}, + ], + inferredAdditionalNames: [ + {name: `Ironic Mania`, from: [{directory: `inferred-from`}]}, + {name: `ANARCHY::MEGASTRIFE`, from: [{directory: `inferred-from`}]}, + ], + }); + + quickSnapshot(`own and shared and inferred, various overlap`, { + additionalNames: [ + {name: `Own!`, annotation: `own annotation`}, + {name: `Own! Shared!`, annotation: `own annotation`}, + {name: `Own! Inferred!`, annotation: `own annotation`}, + {name: `Own! Shared! Inferred!`, annotation: `own annotation`}, + ], + sharedAdditionalNames: [ + {name: `Shared!`, annotation: `shared annotation`}, + {name: `Own! Shared!`, annotation: `shared annotation`}, + {name: `Shared! Inferred!`, annotation: `shared annotation`}, + {name: `Own! Shared! Inferred!`, annotation: `shared annotation`}, + ], + inferredAdditionalNames: [ + {name: `Inferred!`, from: [{directory: `inferred-from`}]}, + {name: `Own! Inferred!`, from: [{directory: `inferred-from`}]}, + {name: `Shared! Inferred!`, from: [{directory: `inferred-from`}]}, + {name: `Own! Shared! Inferred!`, from: [{directory: `inferred-from`}]}, + ], + }); +}); -- cgit 1.3.0-6-gf8a5