diff options
29 files changed, 3191 insertions, 1258 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/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)); 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, 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', { 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/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js deleted file mode 100644 index 2a684b19..00000000 --- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js +++ /dev/null @@ -1,81 +0,0 @@ -import {stitchArrays} from '#sugar'; -import {sortChronologically} from '#wiki-data'; - -export default { - contentDependencies: ['generateColorStyleVariables', '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), - - albumColorVariables: - query.albums - .map(() => relation('generateColorStyleVariables')), - }), - - data: (query, sprawl, group) => ({ - groupDirectory: - group.directory, - - 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}) => - html.tags([ - html.tag('dt', - language.$('listingPage.other.randomPages.group', { - group: relations.groupLink, - - randomAlbum: - html.tag('a', - {href: '#', 'data-random': 'album-in-' + data.groupDirectory}, - language.$('listingPage.other.randomPages.group.randomAlbum')), - - randomTrack: - html.tag('a', - {href: '#', 'data-random': 'track-in-' + data.groupDirectory}, - language.$('listingPage.other.randomPages.group.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}) => - 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), - }))))), - ]), -}; diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 08eb40c6..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: [ @@ -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 @@ -58,12 +62,42 @@ 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), + }, - rows: {validate: v => v.strictArrayOf(v.isObject)}, + rowAttributes: { + validate: v => v.strictArrayOf(v.optional(v.isObject)) + }, + + chunkTitles: { + validate: v => v.strictArrayOf(v.isObject), + }, - chunkTitles: {validate: v => v.strictArrayOf(v.isObject)}, - chunkRows: {validate: v => v.strictArrayOf(v.isObject)}, + chunkTitleAccents: { + validate: v => v.strictArrayOf(v.optional(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.optional(v.isString)), + }, listStyle: { validate: v => v.is('ordered', 'unordered'), @@ -74,26 +108,59 @@ export default { }, generate(data, relations, slots, {html, language}) { - const listTag = - (slots.listStyle === 'ordered' - ? 'ol' - : 'ul'); + function formatListingString({ + context, + provided = {}, + }) { + const parts = ['listingPage', data.stringsKey]; + + if (Array.isArray(context)) { + parts.push(...context); + } else { + parts.push(context); + } - const formatListingString = (contextStringsKey, options = {}) => { - const baseStringsKey = `listingPage.${data.stringsKey}`; + if (provided.stringsKey) { + parts.push(provided.stringsKey); + } - const parts = [baseStringsKey, contextStringsKey]; + const options = {...provided}; + delete options.stringsKey; - if (options.stringsKey) { - parts.push(options.stringsKey); - delete options.stringsKey; - } + return language.formatString(...parts, options); + } - return language.formatString(parts.join('.'), options); - }; + const formatRow = ({context, row, attributes}) => + (attributes?.href + ? html.tag('li', + html.tag('a', + attributes, + formatListingString({ + context, + provided: row, + }))) + : html.tag('li', + attributes, + formatListingString({ + context, + provided: row, + }))); + + const formatRowList = ({context, rows, rowAttributes}) => + html.tag( + (slots.listStyle === 'ordered' ? 'ol' : 'ul'), + stitchArrays({ + row: rows, + attributes: rowAttributes ?? rows.map(() => null), + }).map( + bindOpts(formatRow, { + [bindOpts.bindIndex]: 0, + context, + }))); return relations.layout.slots({ - title: formatListingString('title'), + title: formatListingString({context: 'title'}), + headingMode: 'sticky', mainContent: [ @@ -121,35 +188,78 @@ export default { listings: language.formatUnitList(relations.seeAlsoLinks), })), + slots.content, + slots.type === 'rows' && - html.tag(listTag, - slots.rows.map(row => - html.tag('li', - formatListingString('item', row)))), + formatRowList({ + context: 'item', + rows: slots.rows, + rowAttributes: slots.rowAttributes, + }), 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: + html.normalize( + formatListingString({ + context: 'chunk.title', + provided: title, + }).toString() + .replace(/:$/, '')), + }))))), + ], + stitchArrays({ title: slots.chunkTitles, + titleAccent: slots.chunkTitleAccents, + id: slots.chunkIDs, rows: slots.chunkRows, - }).map(({title, rows}) => [ + rowAttributes: slots.chunkRowAttributes, + }).map(({title, titleAccent, id, rows, rowAttributes}) => [ relations.chunkHeading .clone() .slots({ tag: 'dt', - title: formatListingString('chunk.title', title), + id, + + title: + formatListingString({ + context: 'chunk.title', + provided: title, + }), + + accent: + titleAccent && + formatListingString({ + context: ['chunk.title', title.stringsKey, 'accent'], + provided: titleAccent, + }), }), html.tag('dd', - html.tag(listTag, - rows.map(row => - html.tag('li', - {class: row.stringsKey === 'rerelease' && 'rerelease'}, - formatListingString('chunk.item', row))))), - ])), - - slots.type === 'custom' && - slots.content, + formatRowList({ + context: 'chunk.item', + rows, + rowAttributes, + })), + ]), + ]), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index cd831ba7..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'); } @@ -449,7 +451,8 @@ export default { {[html.onlyIfContent]: true, class: 'skipper-list'}, processSkippers([ {id: 'tracks', string: 'tracks'}, - {id: 'art', string: 'flashes'}, + {id: 'art', string: 'artworks'}, + {id: 'flashes', string: 'flashes'}, {id: 'contributors', string: 'contributors'}, {id: 'references', string: 'references'}, {id: 'referenced-by', string: 'referencedBy'}, diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js index cb0860f5..a19f104c 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,16 @@ export default { stitchArrays({ image: relations.images, path: data.paths, - }).map(({image, path}) => - image.slot('path', path)); + name: data.names ?? data.paths.slice().fill(null), + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + name && + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), + })); commonSlots.actionLinks = (relations.actionLinks diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 73c656e3..5de612e2 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -3,10 +3,20 @@ const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; const MASTODON_DOMAINS = ['types.pl']; export default { - extraDependencies: ['html', 'language'], + extraDependencies: ['html', 'language', 'wikiData'], - data(url) { - return {url}; + 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; }, slots: { @@ -20,6 +30,7 @@ export default { let isLocal; let domain; let pathname; + try { const url = new URL(data.url); domain = url.hostname; @@ -28,6 +39,14 @@ export default { // 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; } const link = html.tag('a', 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; } 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/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/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js index 3870afde..45f8390f 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,299 @@ 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 ?? track.date), - ...artist.albumsAsCoverArtist.map(album => album.coverArtDate ?? album.date), - ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate ?? album.date), - ...artist.albumsAsBannerArtist.map(album => album.coverArtDate ?? album.date), - ], - ]); - - 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.date, 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)); - - relations.artistLinksByArtworkContributions = - query.artistsByArtworkContributions - .map(artists => - artists.map(artist => relation('linkArtist', artist))); + 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.date, track.album, 'artwork'); + } - relations.datelessArtistLinksByArtworkContributions = - query.datelessArtistsByArtworkContributions - .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'); + } + } - // Flash contributors + 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'); + } + } - if (query.enableFlashesAndGames) { - relations.flashLinksByFlashContributions = - query.flashesByFlashContributions - .map(flash => relation('linkFlash', flash)); + // + // 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; + } + }); - 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/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js index 6c0ad836..554b4587 100644 --- a/src/content/dependencies/listArtistsByName.js +++ b/src/content/dependencies/listArtistsByName.js @@ -2,38 +2,33 @@ import {stitchArrays} 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}) => + ({artistData, wikiInfo}), - query({artistData}, spec) { - return { - spec, + query: (sprawl, spec) => ({ + spec, - artists: sortAlphabetically(artistData.slice()), - }; - }, + artists: + sortAlphabetically(sprawl.artistData.slice()), + }), - relations(relation, query) { - return { - page: relation('generateListingPage', query.spec), + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), - artistLinks: - query.artists - .map(artist => relation('linkArtist', artist)), - }; - }, + artistLinks: + query.artists + .map(artist => relation('linkArtist', artist)), + }), - data(query) { - return { - counts: - query.artists - .map(artist => getArtistNumContributions(artist)), - }; - }, + data: (query) => ({ + counts: + query.artists + .map(artist => getArtistNumContributions(artist)), + }), generate(data, relations, {language}) { return relations.page.slots({ diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 43bf7dd5..0b904019 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -1,48 +1,118 @@ +import {empty} from '#sugar'; +import {sortChronologically} from '#wiki-data'; + export default { contentDependencies: [ 'generateListingPage', - 'generateListRandomPageLinksGroupSection', + 'generateListRandomPageLinksAlbumLink', + 'linkGroup', ], extraDependencies: ['html', 'language', 'wikiData'], - sprawl({groupData}) { - return {groupData}; - }, + sprawl: ({albumData, wikiInfo}) => ({albumData, 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'), - }; + const query = {spec}; + + const groups = sprawl.wikiInfo.divideTrackListsByGroups; + + query.divideByGroups = !empty(groups); + + 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) { - return { - page: relation('generateListingPage', query.spec), + 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; + }, - officialSection: - relation('generateListRandomPageLinksGroupSection', query.officialGroup), + data(query) { + const data = {}; - fandomSection: - relation('generateListRandomPageLinksGroupSection', query.fandomGroup), + if (query.divideByGroups) { + data.groupDirectories = + query.groups + .map(group => group.directory); + } - beyondSection: - relation('generateListRandomPageLinksGroupSection', query.beyondGroup), - }; + return data; }, - generate(relations, {html, language}) { + generate(data, 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')), + language.$('listingPage.other.randomPages.chooseLinkLine', { + fromPart: + (relations.groupLinks + ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups') + : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')), + + browserSupportPart: + language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'), + })), html.tag('p', {class: 'js-hide-once-data'}, @@ -51,40 +121,71 @@ export default { html.tag('p', {class: 'js-show-once-data'}, language.$('listingPage.other.randomPages.dataLoadedLine')), + ], + + showSkipToSection: true, + + chunkIDs: + (data.groupDirectories + ? [null, ...data.groupDirectories] + : null), - html.tag('dl', [ - html.tag('dt', - language.$('listingPage.other.randomPages.misc')), - - 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')) + - ')', + chunkTitles: [ + {stringsKey: 'misc'}, + + ... + (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.accent.randomAlbum')), + + randomTrack: + html.tag('a', + {href: '#', 'data-random': 'track-in-group-dl'}, + language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')), + })) + : [null]), + ], + + chunkRows: [ + miscellaneousChunkRows, + + ... + (relations.groupAlbumLinks + ? relations.groupAlbumLinks.map(albumLinks => + albumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + }))) + : [ + relations.undividedAlbumLinks.map(albumLink => ({ + stringsKey: 'album', + album: albumLink, + })), ]), + ], - 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.officialSection, - relations.fandomSection, - relations.beyondSection, - ]), + chunkRowAttributes: [ + miscellaneousChunkRowAttributes, + ... + (relations.groupAlbumLinks + ? relations.groupAlbumLinks.map(albumLinks => + albumLinks.map(() => null)) + : [relations.undividedAlbumLinks.map(() => null)]), ], }); }, 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))), }); }, }; diff --git a/src/data/language.js b/src/data/language.js index 09466907..3fc14da7 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -1,39 +1,199 @@ +import EventEmitter from 'node:events'; import {readFile} from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; -// It stands for "HTML Entities", apparently. Cursed. -import he from 'he'; +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 {colors, logWarn} from '#cli'; -export async function processLanguageFile(file) { - const contents = await readFile(file, 'utf-8'); - const json = JSON.parse(contents); +import { + annotateError, + annotateErrorWithFile, + showAggregate, + withAggregate, +} from '#sugar'; + +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, + 'meta.languageName': name, + + 'meta.languageIntlCode': intlCode = null, + 'meta.hidden': hidden = false, + + ...strings + } = spec; + + withAggregate({message: `Errors validating language spec`}, ({push}) => { + if (!code) { + push(new Error(`Missing language code`)); + } + + if (!name) { + push(new Error(`Missing language name`)); + } + + if (code && existingCode && code !== existingCode) { + push(new Error(`Language code (${code}) doesn't match previous value\n(You'll have to reload hsmusic to load this)`)); + } + }); + + return {code, intlCode, name, hidden, strings}; +} - const code = json['meta.languageCode']; - if (!code) { - throw new Error(`Missing language code (file: ${file})`); +function flattenLanguageSpec(spec) { + const recursive = (keyPath, value) => + (typeof value === 'object' + ? Object.assign({}, ... + Object.entries(value) + .map(([key, value]) => + (key === '_' + ? {[keyPath]: value} + : recursive( + (keyPath ? `${keyPath}.${key}` : key), + value)))) + : {[keyPath]: value}); + + return recursive('', spec); +} + +async function processLanguageSpecFromFile(file, processLanguageSpecOpts) { + let contents; + + try { + contents = await readFile(file, 'utf-8'); + } catch (caughtError) { + throw annotateError( + new Error(`Failed to read language file`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); } - delete json['meta.languageCode']; - const intlCode = json['meta.languageIntlCode'] ?? null; - delete json['meta.languageIntlCode']; + let rawSpec; + let parseLanguage; - const name = json['meta.languageName']; - if (!name) { - throw new Error(`Missing language name (${code})`); + try { + if (path.extname(file) === '.yaml') { + parseLanguage = 'YAML'; + rawSpec = yaml.load(contents); + } else { + parseLanguage = 'JSON'; + rawSpec = JSON.parse(contents); + } + } catch (caughtError) { + throw annotateError( + new Error(`Failed to parse language file as valid ${parseLanguage}`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); } - delete json['meta.languageName']; - const hidden = json['meta.hidden'] ?? false; - delete json['meta.hidden']; + const flattenedSpec = flattenLanguageSpec(rawSpec); - const language = new T.Language(); - language.code = code; - language.intlCode = intlCode; - language.name = name; - language.hidden = hidden; - language.escapeHTML = (string) => + try { + return processLanguageSpec(flattenedSpec, processLanguageSpecOpts); + } catch (caughtError) { + throw annotateErrorWithFile(caughtError, file); + } +} + +export function initializeLanguageObject() { + const language = new Language(); + + language.escapeHTML = string => he.encode(string, {useNamedReferences: true}); - language.strings = json; + return language; } + +export async function processLanguageFile(file) { + const language = initializeLanguageObject(); + const properties = await processLanguageSpecFromFile(file); + return Object.assign(language, properties); +} + +export function watchLanguageFile(file, { + logging = true, +} = {}) { + const basename = path.basename(file); + + const events = new EventEmitter(); + const language = initializeLanguageObject(); + + let emittedReady = false; + let successfullyAppliedLanguage = false; + + Object.assign(events, {language, close}); + + const watcher = chokidar.watch(file); + watcher.on('change', () => handleFileUpdated()); + + setImmediate(handleFileUpdated); + + return events; + + async function close() { + return watcher.close(); + } + + function checkReadyConditions() { + if (emittedReady) return; + if (!successfullyAppliedLanguage) return; + + events.emit('ready'); + emittedReady = true; + } + + async function handleFileUpdated() { + let properties; + + try { + properties = await processLanguageSpecFromFile(file, { + existingCode: + (successfullyAppliedLanguage + ? language.code + : null), + }); + } catch (error) { + events.emit('error', error); + + if (logging) { + const label = + (successfullyAppliedLanguage + ? `${language.name} (${language.code})` + : basename); + + if (successfullyAppliedLanguage) { + logWarn`Failed to load language ${label} - using existing version`; + } else { + logWarn`Failed to load language ${label} - no prior version loaded`; + } + showAggregate(error, {showTraces: false}); + } + + return; + } + + Object.assign(language, properties); + successfullyAppliedLanguage = true; + + if (logging && emittedReady) { + const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); + console.log(colors.green(`[${timestamp}] Updated language ${language.name} (${language.code})`)); + } + + events.emit('update'); + checkReadyConditions(); + } +} diff --git a/src/data/things/artist.js b/src/data/things/artist.js index e0350b86..a51723c4 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -107,6 +107,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/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 <meta rel="description"> tag. description: simpleString(), diff --git a/src/listing-spec.js b/src/listing-spec.js index 2b33744a..9433ee68 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -44,12 +44,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({ @@ -64,6 +66,15 @@ listingSpec.push({ contentFunction: 'listArtistsByDuration', }); +// TODO: hide if no groups... +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/repl.js b/src/repl.js index 26879be4..dd61133c 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'; @@ -44,10 +44,7 @@ export async function getContextAssignments({ let language; try { - language = await processLanguageFile( - path.join( - path.dirname(fileURLToPath(import.meta.url)), - 'strings-default.json')); + language = await processLanguageFile(internalDefaultStringsFile); } catch (error) { console.error(error); logWarn`Failed to create Language object`; diff --git a/src/static/client2.js b/src/static/client2.js index 523b48d8..0ec052bd 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))); @@ -840,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..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; @@ -1275,6 +1269,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; } diff --git a/src/strings-default.json b/src/strings-default.json deleted file mode 100644 index b6471bdf..00000000 --- a/src/strings-default.json +++ /dev/null @@ -1,513 +0,0 @@ -{ - "meta.languageCode": "en", - "meta.languageName": "English", - "count.tracks": "{TRACKS}", - "count.tracks.withUnit.zero": "", - "count.tracks.withUnit.one": "{TRACKS} track", - "count.tracks.withUnit.two": "", - "count.tracks.withUnit.few": "", - "count.tracks.withUnit.many": "", - "count.tracks.withUnit.other": "{TRACKS} tracks", - "count.additionalFiles": "{FILES}", - "count.additionalFiles.withUnit.zero": "", - "count.additionalFiles.withUnit.one": "{FILES} file", - "count.additionalFiles.withUnit.two": "", - "count.additionalFiles.withUnit.few": "", - "count.additionalFiles.withUnit.many": "", - "count.additionalFiles.withUnit.other": "{FILES} files", - "count.albums": "{ALBUMS}", - "count.albums.withUnit.zero": "", - "count.albums.withUnit.one": "{ALBUMS} album", - "count.albums.withUnit.two": "", - "count.albums.withUnit.few": "", - "count.albums.withUnit.many": "", - "count.albums.withUnit.other": "{ALBUMS} albums", - "count.artworks": "{ARTWORKS}", - "count.artworks.withUnit.zero": "", - "count.artworks.withUnit.one": "{ARTWORKS} artwork", - "count.artworks.withUnit.two": "", - "count.artworks.withUnit.few": "", - "count.artworks.withUnit.many": "", - "count.artworks.withUnit.other": "{ARTWORKS} artworks", - "count.commentaryEntries": "{ENTRIES}", - "count.commentaryEntries.withUnit.zero": "", - "count.commentaryEntries.withUnit.one": "{ENTRIES} entry", - "count.commentaryEntries.withUnit.two": "", - "count.commentaryEntries.withUnit.few": "", - "count.commentaryEntries.withUnit.many": "", - "count.commentaryEntries.withUnit.other": "{ENTRIES} entries", - "count.contributions": "{CONTRIBUTIONS}", - "count.contributions.withUnit.zero": "", - "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution", - "count.contributions.withUnit.two": "", - "count.contributions.withUnit.few": "", - "count.contributions.withUnit.many": "", - "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions", - "count.coverArts": "{COVER_ARTS}", - "count.coverArts.withUnit.zero": "", - "count.coverArts.withUnit.one": "{COVER_ARTS} cover art", - "count.coverArts.withUnit.two": "", - "count.coverArts.withUnit.few": "", - "count.coverArts.withUnit.many": "", - "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts", - "count.flashes": "{FLASHES}", - "count.flashes.withUnit.zero": "", - "count.flashes.withUnit.one": "{FLASHES} flashes & games", - "count.flashes.withUnit.two": "", - "count.flashes.withUnit.few": "", - "count.flashes.withUnit.many": "", - "count.flashes.withUnit.other": "{FLASHES} flashes & games", - "count.timesReferenced": "{TIMES_REFERENCED}", - "count.timesReferenced.withUnit.zero": "", - "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced", - "count.timesReferenced.withUnit.two": "", - "count.timesReferenced.withUnit.few": "", - "count.timesReferenced.withUnit.many": "", - "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced", - "count.words": "{WORDS}", - "count.words.thousand": "{WORDS}k", - "count.words.withUnit.zero": "", - "count.words.withUnit.one": "{WORDS} word", - "count.words.withUnit.two": "", - "count.words.withUnit.few": "", - "count.words.withUnit.many": "", - "count.words.withUnit.other": "{WORDS} words", - "count.timesUsed": "{TIMES_USED}", - "count.timesUsed.withUnit.zero": "", - "count.timesUsed.withUnit.one": "used {TIMES_USED} time", - "count.timesUsed.withUnit.two": "", - "count.timesUsed.withUnit.few": "", - "count.timesUsed.withUnit.many": "", - "count.timesUsed.withUnit.other": "used {TIMES_USED} times", - "count.index.zero": "", - "count.index.one": "{INDEX}st", - "count.index.two": "{INDEX}nd", - "count.index.few": "{INDEX}rd", - "count.index.many": "", - "count.index.other": "{INDEX}th", - "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}", - "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours", - "count.duration.minutes": "{MINUTES}:{SECONDS}", - "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes", - "count.duration.approximate": "~{DURATION}", - "count.duration.missing": "_:__", - "count.fileSize.terabytes": "{TERABYTES} TB", - "count.fileSize.gigabytes": "{GIGABYTES} GB", - "count.fileSize.megabytes": "{MEGABYTES} MB", - "count.fileSize.kilobytes": "{KILOBYTES} kB", - "count.fileSize.bytes": "{BYTES} bytes", - "releaseInfo.by": "By {ARTISTS}.", - "releaseInfo.from": "From {ALBUM}.", - "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.", - "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.", - "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.", - "releaseInfo.released": "Released {DATE}.", - "releaseInfo.artReleased": "Art released {DATE}.", - "releaseInfo.addedToWiki": "Added to wiki {DATE}.", - "releaseInfo.duration": "Duration: {DURATION}.", - "releaseInfo.viewCommentary": "View {LINK}!", - "releaseInfo.viewCommentary.link": "commentary page", - "releaseInfo.viewGallery": "View {LINK}!", - "releaseInfo.viewGallery.link": "gallery page", - "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!", - "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page", - "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page", - "releaseInfo.viewOriginalFile": "View {LINK}.", - "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).", - "releaseInfo.viewOriginalFile.link": "original file", - "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)", - "releaseInfo.listenOn": "Listen on {LINKS}.", - "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.", - "releaseInfo.visitOn": "Visit on {LINKS}.", - "releaseInfo.playOn": "Play on {LINKS}.", - "releaseInfo.readCommentary": "Read {LINK}.", - "releaseInfo.readCommentary.link": "artist commentary", - "releaseInfo.alsoReleasedAs": "Also released as:", - "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})", - "releaseInfo.contributors": "Contributors:", - "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:", - "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:", - "releaseInfo.tracksSampled": "Tracks that {TRACK} samples:", - "releaseInfo.tracksThatSample": "Tracks that sample {TRACK}:", - "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:", - "releaseInfo.flashesThatFeature.item": "{FLASH}", - "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})", - "releaseInfo.tracksFeatured": "Tracks that {FLASH} features:", - "releaseInfo.lyrics": "Lyrics:", - "releaseInfo.artistCommentary": "Artist commentary:", - "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!", - "releaseInfo.artTags": "Tags:", - "releaseInfo.artTags.inline": "Tags: {TAGS}", - "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}", - "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files", - "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:", - "releaseInfo.additionalFiles.entry": "{TITLE}", - "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}", - "releaseInfo.additionalFiles.file": "{FILE}", - "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})", - "releaseInfo.sheetMusicFiles.shortcut": "Download {LINK}.", - "releaseInfo.sheetMusicFiles.shortcut.link": "sheet music files", - "releaseInfo.sheetMusicFiles.heading": "Print or download sheet music files:", - "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.", - "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files", - "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:", - "releaseInfo.note": "Context notes:", - "trackList.section.withDuration": "{SECTION} ({DURATION}):", - "trackList.group": "From {GROUP}:", - "trackList.group.fromOther": "From somewhere else:", - "trackList.item.withDuration": "({DURATION}) {TRACK}", - "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}", - "trackList.item.withArtists": "{TRACK} {BY}", - "trackList.item.withArtists.by": "by {ARTISTS}", - "trackList.item.rerelease": "{TRACK} (re-release)", - "misc.alt.albumCover": "album cover", - "misc.alt.albumBanner": "album banner", - "misc.alt.trackCover": "track cover", - "misc.alt.artistAvatar": "artist avatar", - "misc.alt.flashArt": "flash art", - "misc.artistLink": "{ARTIST}", - "misc.artistLink.withContribution": "{ARTIST} ({CONTRIB})", - "misc.artistLink.withExternalLinks": "{ARTIST} ({LINKS})", - "misc.artistLink.withContribution.withExternalLinks": "{ARTIST} ({CONTRIB}) ({LINKS})", - "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)", - "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}", - "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}", - "misc.chronology.heading.track": "{INDEX} track by {ARTIST}", - "misc.chronology.withNavigation": "{HEADING} ({NAVIGATION})", - "misc.external.domain": "External ({DOMAIN})", - "misc.external.local": "Wiki Archive (local upload)", - "misc.external.bandcamp": "Bandcamp", - "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})", - "misc.external.deviantart": "DeviantArt", - "misc.external.instagram": "Instagram", - "misc.external.mastodon": "Mastodon", - "misc.external.mastodon.domain": "Mastodon ({DOMAIN})", - "misc.external.newgrounds": "Newgrounds", - "misc.external.patreon": "Patreon", - "misc.external.poetryFoundation": "Poetry Foundation", - "misc.external.soundcloud": "SoundCloud", - "misc.external.spotify": "Spotify", - "misc.external.tumblr": "Tumblr", - "misc.external.twitter": "Twitter", - "misc.external.wikipedia": "Wikipedia", - "misc.external.youtube": "YouTube", - "misc.external.youtube.playlist": "YouTube (playlist)", - "misc.external.youtube.fullAlbum": "YouTube (full album)", - "misc.external.flash.bgreco": "{LINK} (HQ Audio)", - "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})", - "misc.external.flash.homestuck.secret": "{LINK} (secret page)", - "misc.external.flash.youtube": "{LINK} (on any device)", - "misc.missingImage": "(This image file is missing)", - "misc.missingLinkContent": "(Missing link content)", - "misc.nav.previous": "Previous", - "misc.nav.next": "Next", - "misc.nav.info": "Info", - "misc.nav.gallery": "Gallery", - "misc.pageTitle": "{TITLE}", - "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}", - "misc.skippers.skipTo": "Skip to:", - "misc.skippers.content": "Content", - "misc.skippers.sidebar": "Sidebar", - "misc.skippers.sidebar.left": "Sidebar (left)", - "misc.skippers.sidebar.right": "Sidebar (right)", - "misc.skippers.header": "Header", - "misc.skippers.footer": "Footer", - "misc.skippers.tracks": "Tracks", - "misc.skippers.art": "Artworks", - "misc.skippers.flashes": "Flashes & Games", - "misc.skippers.contributors": "Contributors", - "misc.skippers.references": "References...", - "misc.skippers.referencedBy": "Referenced by...", - "misc.skippers.samples": "Samples...", - "misc.skippers.sampledBy": "Sampled by...", - "misc.skippers.features": "Features...", - "misc.skippers.featuredIn": "Featured in...", - "misc.skippers.lyrics": "Lyrics", - "misc.skippers.sheetMusicFiles": "Sheet music files", - "misc.skippers.midiProjectFiles": "MIDI/project files", - "misc.skippers.additionalFiles": "Additional files", - "misc.skippers.commentary": "Commentary", - "misc.skippers.artistCommentary": "Commentary", - "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}", - "misc.jumpTo": "Jump to:", - "misc.jumpTo.withLinks": "Jump to: {LINKS}.", - "misc.contentWarnings": "cw: {WARNINGS}", - "misc.contentWarnings.reveal": "click to show", - "misc.albumGrid.details": "({TRACKS}, {TIME})", - "misc.albumGrid.details.coverArtists": "(Illust. {ARTISTS})", - "misc.albumGrid.details.otherCoverArtists": "(With {ARTISTS})", - "misc.albumGrid.noCoverArt": "{ALBUM}", - "misc.albumGalleryGrid.noCoverArt": "{NAME}", - "misc.uiLanguage": "UI Language: {LANGUAGES}", - "homepage.title": "{TITLE}", - "homepage.news.title": "News", - "homepage.news.entry.viewRest": "(View rest of entry!)", - "albumSidebar.trackList.fallbackSectionName": "Track list", - "albumSidebar.trackList.group": "{GROUP}", - "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})", - "albumSidebar.trackList.item": "{TRACK}", - "albumSidebar.groupBox.title": "{GROUP}", - "albumSidebar.groupBox.next": "Next: {ALBUM}", - "albumSidebar.groupBox.previous": "Previous: {ALBUM}", - "albumPage.title": "{ALBUM}", - "albumPage.nav.album": "{ALBUM}", - "albumPage.nav.randomTrack": "Random Track", - "albumPage.nav.gallery": "Gallery", - "albumPage.nav.commentary": "Commentary", - "albumPage.socialEmbed.heading": "{GROUP}", - "albumPage.socialEmbed.title": "{ALBUM}", - "albumPage.socialEmbed.body.withDuration": "{DURATION}.", - "albumPage.socialEmbed.body.withTracks": "{TRACKS}.", - "albumPage.socialEmbed.body.withReleaseDate": "Released {DATE}.", - "albumPage.socialEmbed.body.withDuration.withTracks": "{DURATION}, {TRACKS}.", - "albumPage.socialEmbed.body.withDuration.withReleaseDate": "{DURATION}. Released {DATE}.", - "albumPage.socialEmbed.body.withTracks.withReleaseDate": "{TRACKS}. Released {DATE}.", - "albumPage.socialEmbed.body.withDuration.withTracks.withReleaseDate": "{DURATION}, {TRACKS}. Released {DATE}.", - "albumGalleryPage.title": "{ALBUM} - Gallery", - "albumGalleryPage.statsLine": "{TRACKS} totaling {DURATION}.", - "albumGalleryPage.statsLine.withDate": "{TRACKS} totaling {DURATION}. Released {DATE}.", - "albumGalleryPage.coverArtistsLine": "All track artwork by {ARTISTS}.", - "albumGalleryPage.noTrackArtworksLine": "This album doesn't have any track artwork.", - "albumCommentaryPage.title": "{ALBUM} - Commentary", - "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.", - "albumCommentaryPage.nav.album": "Album: {ALBUM}", - "albumCommentaryPage.entry.title.albumCommentary": "Album commentary", - "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}", - "artistPage.title": "{ARTIST}", - "artistPage.creditList.album": "{ALBUM}", - "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})", - "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})", - "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})", - "artistPage.creditList.flashAct": "{ACT}", - "artistPage.creditList.flashAct.withDate": "{ACT} ({DATE})", - "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})", - "artistPage.creditList.entry.track": "{TRACK}", - "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}", - "artistPage.creditList.entry.album.coverArt": "(cover art)", - "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)", - "artistPage.creditList.entry.album.bannerArt": "(banner art)", - "artistPage.creditList.entry.album.commentary": "(album commentary)", - "artistPage.creditList.entry.flash": "{FLASH}", - "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)", - "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})", - "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})", - "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})", - "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.", - "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}", - "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}", - "artistPage.groupsLine.item.withCount": "{GROUP} ({COUNT})", - "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})", - "artistPage.groupContributions.title.music": "Contributed music to groups:", - "artistPage.groupContributions.title.artworks": "Contributed artworks to groups:", - "artistPage.groupContributions.title.withSortButton": "{TITLE} ({SORT})", - "artistPage.groupContributions.title.sorting.count": "Sorting by count.", - "artistPage.groupContributions.title.sorting.duration": "Sorting by duration.", - "artistPage.groupContributions.item.countAccent": "({COUNT})", - "artistPage.groupContributions.item.durationAccent": "({DURATION})", - "artistPage.groupContributions.item.countDurationAccent": "({COUNT} — {DURATION})", - "artistPage.groupContributions.item.durationCountAccent": "({DURATION} — {COUNT})", - "artistPage.trackList.title": "Tracks", - "artistPage.artList.title": "Artworks", - "artistPage.flashList.title": "Flashes & Games", - "artistPage.commentaryList.title": "Commentary", - "artistPage.viewArtGallery": "View {LINK}!", - "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:", - "artistPage.viewArtGallery.link": "art gallery", - "artistPage.nav.artist": "Artist: {ARTIST}", - "artistGalleryPage.title": "{ARTIST} - Gallery", - "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.", - "commentaryIndex.title": "Commentary", - "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.", - "commentaryIndex.albumList.title": "Choose an album:", - "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})", - "flashIndex.title": "Flashes & Games", - "flashPage.title": "{FLASH}", - "flashPage.nav.flash": "{FLASH}", - "flashSidebar.flashList.flashesInThisAct": "Flashes in this act", - "flashSidebar.flashList.entriesInThisSection": "Entries in this section", - "groupSidebar.title": "Groups", - "groupSidebar.groupList.category": "{CATEGORY}", - "groupSidebar.groupList.item": "{GROUP}", - "groupPage.nav.group": "Group: {GROUP}", - "groupInfoPage.title": "{GROUP}", - "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:", - "groupInfoPage.viewAlbumGallery.link": "album gallery", - "groupInfoPage.albumList.title": "Albums", - "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}", - "groupInfoPage.albumList.item.withoutYear": "{ALBUM}", - "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}", - "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})", - "groupGalleryPage.title": "{GROUP} - Gallery", - "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.", - "listingIndex.title": "Listings", - "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.", - "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!", - "listingPage.target.album": "Albums", - "listingPage.target.artist": "Artists", - "listingPage.target.group": "Groups", - "listingPage.target.track": "Tracks", - "listingPage.target.tag": "Tags", - "listingPage.target.other": "Other", - "listingPage.listingsFor": "Listings for {TARGET}: {LISTINGS}", - "listingPage.seeAlso": "Also check out: {LISTINGS}", - "listingPage.listAlbums.byName.title": "Albums - by Name", - "listingPage.listAlbums.byName.title.short": "...by Name", - "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})", - "listingPage.listAlbums.byTracks.title": "Albums - by Tracks", - "listingPage.listAlbums.byTracks.title.short": "...by Tracks", - "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})", - "listingPage.listAlbums.byDuration.title": "Albums - by Duration", - "listingPage.listAlbums.byDuration.title.short": "...by Duration", - "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})", - "listingPage.listAlbums.byDate.title": "Albums - by Date", - "listingPage.listAlbums.byDate.title.short": "...by Date", - "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})", - "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.chunk.title": "{DATE}", - "listingPage.listAlbums.byDateAdded.chunk.item": "{ALBUM}", - "listingPage.listArtists.byName.title": "Artists - by Name", - "listingPage.listArtists.byName.title.short": "...by Name", - "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.byCommentary.title": "Artists - by Commentary Entries", - "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries", - "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})", - "listingPage.listArtists.byDuration.title": "Artists - by Duration", - "listingPage.listArtists.byDuration.title.short": "...by Duration", - "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", - "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", - "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.item": "{ARTIST}", - "listingPage.listArtists.byLatest.dateless.title": "These artists' contributions aren't dated:", - "listingPage.listArtists.byLatest.dateless.item": "{ARTIST}", - "listingPage.listGroups.byName.title": "Groups - by Name", - "listingPage.listGroups.byName.title.short": "...by Name", - "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byName.item.gallery": "Gallery", - "listingPage.listGroups.byCategory.title": "Groups - by Category", - "listingPage.listGroups.byCategory.title.short": "...by Category", - "listingPage.listGroups.byCategory.chunk.title": "{CATEGORY}", - "listingPage.listGroups.byCategory.chunk.item": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byCategory.chunk.item.gallery": "Gallery", - "listingPage.listGroups.byAlbums.title": "Groups - by Albums", - "listingPage.listGroups.byAlbums.title.short": "...by Albums", - "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})", - "listingPage.listGroups.byTracks.title": "Groups - by Tracks", - "listingPage.listGroups.byTracks.title.short": "...by Tracks", - "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})", - "listingPage.listGroups.byDuration.title": "Groups - by Duration", - "listingPage.listGroups.byDuration.title.short": "...by Duration", - "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})", - "listingPage.listGroups.byLatest.title": "Groups - by Latest Album", - "listingPage.listGroups.byLatest.title.short": "...by Latest Album", - "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})", - "listingPage.listTracks.byName.title": "Tracks - by Name", - "listingPage.listTracks.byName.title.short": "...by Name", - "listingPage.listTracks.byName.item": "{TRACK}", - "listingPage.listTracks.byAlbum.title": "Tracks - by Album", - "listingPage.listTracks.byAlbum.title.short": "...by Album", - "listingPage.listTracks.byAlbum.chunk.title": "{ALBUM}", - "listingPage.listTracks.byAlbum.chunk.item": "{TRACK}", - "listingPage.listTracks.byDate.title": "Tracks - by Date", - "listingPage.listTracks.byDate.title.short": "...by Date", - "listingPage.listTracks.byDate.chunk.title": "{ALBUM} ({DATE})", - "listingPage.listTracks.byDate.chunk.item": "{TRACK}", - "listingPage.listTracks.byDate.chunk.item.rerelease": "{TRACK} (re-release)", - "listingPage.listTracks.byDuration.title": "Tracks - by Duration", - "listingPage.listTracks.byDuration.title.short": "...by Duration", - "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})", - "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)", - "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)", - "listingPage.listTracks.byDurationInAlbum.chunk.title": "{ALBUM}", - "listingPage.listTracks.byDurationInAlbum.chunk.item": "{TRACK} ({DURATION})", - "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced", - "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced", - "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})", - "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)", - "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)", - "listingPage.listTracks.inFlashes.byAlbum.chunk.title": "{ALBUM}", - "listingPage.listTracks.inFlashes.byAlbum.chunk.item": "{TRACK} (in {FLASHES})", - "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)", - "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)", - "listingPage.listTracks.inFlashes.byFlash.chunk.title": "{FLASH}", - "listingPage.listTracks.inFlashes.byFlash.chunk.item": "{TRACK} (from {ALBUM})", - "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics", - "listingPage.listTracks.withLyrics.title.short": "...with Lyrics", - "listingPage.listTracks.withLyrics.chunk.title": "{ALBUM}", - "listingPage.listTracks.withLyrics.chunk.title.withDate": "{ALBUM} ({DATE})", - "listingPage.listTracks.withLyrics.chunk.item": "{TRACK}", - "listingPage.listTracks.withSheetMusicFiles.title": "Tracks - with Sheet Music Files", - "listingPage.listTracks.withSheetMusicFiles.title.short": "...with Sheet Music Files", - "listingPage.listTracks.withSheetMusicFiles.chunk.title": "{ALBUM}", - "listingPage.listTracks.withSheetMusicFiles.chunk.title.withDate": "{ALBUM} ({DATE})", - "listingPage.listTracks.withSheetMusicFiles.chunk.item": "{TRACK}", - "listingPage.listTracks.withMidiProjectFiles.title": "Tracks - with MIDI & Project Files", - "listingPage.listTracks.withMidiProjectFiles.title.short": "...with MIDI & Project Files", - "listingPage.listTracks.withMidiProjectFiles.chunk.title": "{ALBUM}", - "listingPage.listTracks.withMidiProjectFiles.chunk.title.withDate": "{ALBUM} ({DATE})", - "listingPage.listTracks.withMidiProjectFiles.chunk.item": "{TRACK}", - "listingPage.listTags.byName.title": "Tags - by Name", - "listingPage.listTags.byName.title.short": "...by Name", - "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})", - "listingPage.listTags.byUses.title": "Tags - by Uses", - "listingPage.listTags.byUses.title.short": "...by Uses", - "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})", - "listingPage.other.allSheetMusic.title": "All Sheet Music", - "listingPage.other.allSheetMusic.title.short": "All Sheet Music", - "listingPage.other.allSheetMusic.albumFiles": "Album sheet music:", - "listingPage.other.allSheetMusic.file": "{TITLE}", - "listingPage.other.allSheetMusic.file.withMultipleFiles": "{TITLE} ({FILES})", - "listingPage.other.allMidiProjectFiles.title": "All MIDI/Project Files", - "listingPage.other.allMidiProjectFiles.title.short": "All MIDI/Project Files", - "listingPage.other.allMidiProjectFiles.albumFiles": "Album MIDI/project files:", - "listingPage.other.allMidiProjectFiles.file": "{TITLE}", - "listingPage.other.allMidiProjectFiles.file.withMultipleFiles": "{TITLE} ({FILES})", - "listingPage.other.allAdditionalFiles.title": "All Additional Files", - "listingPage.other.allAdditionalFiles.title.short": "All Additional Files", - "listingPage.other.allAdditionalFiles.albumFiles": "Album additional files:", - "listingPage.other.allAdditionalFiles.file": "{TITLE}", - "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.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:", - "listingPage.other.randomPages.misc.randomArtist": "Random Artist", - "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.album": "{ALBUM}", - "listingPage.misc.trackContributors": "Track Contributors", - "listingPage.misc.artContributors": "Art Contributors", - "listingPage.misc.flashContributors": "Flash & Game Contributors", - "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors", - "newsIndex.title": "News", - "newsIndex.entry.viewRest": "(View rest of entry!)", - "newsEntryPage.title": "{ENTRY}", - "newsEntryPage.published": "(Published {DATE}.)", - "redirectPage.title": "Moved to {TITLE}", - "redirectPage.infoLine": "This page has been moved to {TARGET}.", - "tagPage.title": "{TAG}", - "tagPage.infoLine": "Appears in {COVER_ARTS}.", - "tagPage.nav.tag": "Tag: {TAG}", - "trackPage.title": "{TRACK}", - "trackPage.referenceList.fandom": "Fandom:", - "trackPage.referenceList.official": "Official:", - "trackPage.nav.track": "{TRACK}", - "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}", - "trackPage.nav.random": "Random", - "trackPage.socialEmbed.heading": "{ALBUM}", - "trackPage.socialEmbed.title": "{TRACK}", - "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.", - "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.", - "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}." -} diff --git a/src/strings-default.yaml b/src/strings-default.yaml new file mode 100644 index 00000000..a21758e7 --- /dev/null +++ b/src/strings-default.yaml @@ -0,0 +1,1691 @@ +meta.languageCode: en +meta.languageName: English + +# +# count: +# +# This covers pretty much any time that a specific number of things +# is represented! It's sectioned... like an alignment chart meme... +# +# First counting specific wiki objects, then more abstract stuff, +# and finally numerical representations of kinds of quantities that +# aren't really "counting", per se. +# +# These must be filled out according to the Unicode Common Locale +# Data Repository (Unicode CLDR). Check out info on their site: +# https://cldr.unicode.org +# +# Specifically, you'll want to look into the Plural Rules for your +# language. Here's a summary on what those even are: +# https://cldr.unicode.org/index/cldr-spec/plural-rules +# +# CLDR's charts are available online! This should bring you to the +# most recent table of plural rules: +# https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html +# +# Counting is generally done with the "Type: cardinal" section on +# that chart - for example, if the chart lists "one", "many", and +# "other" under the cardinal plural rules for your language, then +# your job is to fill in the correct pluralizations of the specific +# term for each of those. +# +# If you adore technical details or want to better understand the +# "Rules" column, you'll want to check out the syntax outline here: +# https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules +# +count: + + # Count things and objects + + additionalFiles: + _: "{FILES}" + withUnit: + zero: "" + one: "{FILES} file" + two: "" + few: "" + many: "" + other: "{FILES} files" + + albums: + _: "{ALBUMS}" + withUnit: + zero: "" + one: "{ALBUMS} album" + two: "" + few: "" + many: "" + other: "{ALBUMS} albums" + + artworks: + _: "{ARTWORKS}" + withUnit: + zero: "" + one: "{ARTWORKS} artwork" + two: "" + few: "" + many: "" + other: "{ARTWORKS} artworks" + + commentaryEntries: + _: "{ENTRIES}" + withUnit: + zero: "" + one: "{ENTRIES} entry" + two: "" + few: "" + many: "" + other: "{ENTRIES} entries" + + contributions: + _: "{CONTRIBUTIONS}" + withUnit: + zero: "" + one: "{CONTRIBUTIONS} contribution" + two: "" + few: "" + many: "" + other: "{CONTRIBUTIONS} contributions" + + coverArts: + _: "{COVER_ARTS}" + withUnit: + zero: "" + one: "{COVER_ARTS} cover art" + two: "" + few: "" + many: "" + other: "{COVER_ARTS} cover arts" + + flashes: + _: "{FLASHES}" + withUnit: + zero: "" + one: "{FLASHES} flashes & games" + two: "" + few: "" + many: "" + other: "{FLASHES} flashes & games" + + tracks: + _: "{TRACKS}" + withUnit: + zero: "" + one: "{TRACKS} track" + two: "" + few: "" + many: "" + other: "{TRACKS} tracks" + + # Count more abstract stuff + + days: + _: "{DAYS}" + withUnit: + zero: "" + one: "{DAYS} day" + two: "" + few: "" + many: "" + other: "{DAYS} days" + + timesReferenced: + _: "{TIMES_REFERENCED}" + withUnit: + zero: "" + one: "{TIMES_REFERENCED} time referenced" + two: "" + few: "" + many: "" + other: "{TIMES_REFERENCED} times referenced" + + timesUsed: + _: "{TIMES_USED}" + withUnit: + zero: "" + one: "used {TIMES_USED} time" + two: "" + few: "" + many: "" + other: "used {TIMES_USED} times" + + words: + _: "{WORDS}" + thousand: "{WORDS}k" + withUnit: + zero: "" + one: "{WORDS} word" + two: "" + few: "" + many: "" + other: "{WORDS} words" + + # Numerical things that aren't exactly counting, per se + + duration: + missing: "_:__" + approximate: "~{DURATION}" + hours: + _: "{HOURS}:{MINUTES}:{SECONDS}" + withUnit: "{HOURS}:{MINUTES}:{SECONDS} hours" + minutes: + _: "{MINUTES}:{SECONDS}" + withUnit: "{MINUTES}:{SECONDS} minutes" + + fileSize: + terabytes: "{TERABYTES} TB" + gigabytes: "{GIGABYTES} GB" + megabytes: "{MEGABYTES} MB" + kilobytes: "{KILOBYTES} kB" + bytes: "{BYTES} bytes" + + # Indexes in a list + # These use "Type: ordinal" on CLDR's chart of plural rules. + + index: + zero: "" + one: "{INDEX}st" + two: "{INDEX}nd" + few: "{INDEX}rd" + many: "" + other: "{INDEX}th" + +# +# releaseInfo: +# +# This covers a lot of generic strings - they're used in a variety +# of contexts. They're sorted below with descriptions first, then +# actions further down. +# +releaseInfo: + + # Descriptions + + by: "By {ARTISTS}." + from: "From {ALBUM}." + + coverArtBy: "Cover art by {ARTISTS}." + wallpaperArtBy: "Wallpaper art by {ARTISTS}." + bannerArtBy: "Banner art by {ARTISTS}." + + released: "Released {DATE}." + artReleased: "Art released {DATE}." + addedToWiki: "Added to wiki {DATE}." + + duration: "Duration: {DURATION}." + + contributors: "Contributors:" + lyrics: "Lyrics:" + note: "Context notes:" + + alsoReleasedAs: + _: "Also released as:" + item: "{TRACK} (on {ALBUM})" + + tracksReferenced: "Tracks that {TRACK} references:" + tracksThatReference: "Tracks that reference {TRACK}:" + tracksSampled: "Tracks that {TRACK} samples:" + tracksThatSample: "Tracks that sample {TRACK}:" + + flashesThatFeature: + _: "Flashes & games that feature {TRACK}:" + item: + _: "{FLASH}" + asDifferentRelease: "{FLASH} (as {TRACK})" + + tracksFeatured: "Tracks that {FLASH} features:" + + artTags: + _: "Tags:" + inline: "Tags: {TAGS}" + + # Actions + + viewCommentary: + _: "View {LINK}!" + link: "commentary page" + + viewGallery: + _: "View {LINK}!" + link: "gallery page" + + viewGalleryOrCommentary: + _: "View {GALLERY} or {COMMENTARY}!" + gallery: "gallery page" + commentary: "commentary page" + + viewOriginalFile: + _: "View {LINK}." + withSize: "View {LINK} ({SIZE})." + link: "original file" + sizeWarning: >- + (Heads up! If you're on a mobile plan, this is a large download.) + + listenOn: + _: "Listen on {LINKS}." + noLinks: >- + This wiki doesn't have any listening links for {NAME}. + + visitOn: "Visit on {LINKS}." + playOn: "Play on {LINKS}." + + readCommentary: + _: "Read {LINK}." + link: "artist commentary" + + artistCommentary: + _: "Artist commentary:" + seeOriginalRelease: "See {ORIGINAL}!" + + additionalFiles: + heading: "View or download {ADDITIONAL_FILES}:" + + entry: + _: "{TITLE}" + withDescription: "{TITLE}: {DESCRIPTION}" + + file: + _: "{FILE}" + withSize: "{FILE} ({SIZE})" + + shortcut: + _: "View {ANCHOR_LINK}: {TITLES}" + anchorLink: "additional files" + + sheetMusicFiles: + heading: "Print or download sheet music files:" + + shortcut: + _: "Download {LINK}." + link: "sheet music files" + + midiProjectFiles: + heading: "Download MIDI/project files:" + + shortcut: + _: "Download {LINK}." + link: "MIDI/project files" + +# +# trackList: +# +# A list of tracks! These are used pretty much across the wiki. +# Track lists can be split into sections, groups, or not split at +# all. "Track sections" are divisions in the list which suit the +# album as a whole, like if it has multiple discs or bonus tracks. +# "Groups" are actual group objects (see ex. groupInfoPage). +# +trackList: + section: + withDuration: "{SECTION} ({DURATION}):" + + group: + _: "From {GROUP}:" + fromOther: "From somewhere else:" + + item: + withDuration: "({DURATION}) {TRACK}" + withDuration.withArtists: "({DURATION}) {TRACK} {BY}" + withArtists: "{TRACK} {BY}" + withArtists.by: "by {ARTISTS}" + rerelease: "{TRACK} (re-release)" + +# +# misc: +# +# These cover a whole host of general things across the wiki, and +# aren't specially organized. Sorry! See each entry for details. +# +misc: + + # alt: + # Fallback text for the alt text of images and artworks - these + # are read aloud by screen readers. + + alt: + albumCover: "album cover" + albumBanner: "album banner" + trackCover: "track cover" + artistAvatar: "artist avatar" + flashArt: "flash art" + + # artistLink: + # Artist links have special accents which are made conditionally + # present in a variety of places across the wiki. + + artistLink: + _: "{ARTIST}" + + # Contribution to a track, artwork, or other thing. + withContribution: "{ARTIST} ({CONTRIB})" + + # External links to visit the artist's own websites or profiles. + withExternalLinks: "{ARTIST} ({LINKS})" + + # Combination of above. + withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})" + + # chronology: + # + # "Chronology links" are a section that appear in the nav bar for + # most things with individual contributors across the wiki! These + # allow for quick navigation between older and newer releases of + # a given artist, or seeing at a glance how many contributions an + # artist made before the one you're currently viewing. + # + # Chronology information is described for each artist and shows + # the kind of thing which is being contributed to, since all of + # the entries are displayed together in one list. + # + + chronology: + + # seeArtistPages: + # If the thing you're viewing has a lot of contributors, their + # chronology info will be exempt from the nav bar, which'll + # show this message instead. + + seeArtistPages: "(See artist pages for chronology info!)" + + # withNavigation: + # Navigation refers to previous/next links. + + withNavigation: "{HEADING} ({NAVIGATION})" + + heading: + coverArt: "{INDEX} cover art by {ARTIST}" + flash: "{INDEX} flash/game by {ARTIST}" + track: "{INDEX} track by {ARTIST}" + + # external: + # Links which will generally bring you somewhere off of the wiki. + # The list of sites is hard-coded into the wiki software, so it + # may be out of date or missing ones that are relevant to another + # wiki - sorry! + + external: + + # domain: + # General domain when one the URL doesn't match one of the + # sites below. + + domain: "External ({DOMAIN})" + + # local: + # Files which are locally available on the wiki (under its media + # directory). + + local: "Wiki Archive (local upload)" + + deviantart: "DeviantArt" + instagram: "Instagram" + newgrounds: "Newgrounds" + patreon: "Patreon" + poetryFoundation: "Poetry Foundation" + soundcloud: "SoundCloud" + spotify: "Spotify" + tumblr: "Tumblr" + twitter: "Twitter" + wikipedia: "Wikipedia" + + bandcamp: + _: "Bandcamp" + domain: "Bandcamp ({DOMAIN})" + + mastodon: + _: "Mastodon" + domain: "Mastodon ({DOMAIN})" + + youtube: + _: "YouTube" + 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)" + + # 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 + # shouldn't display on a correct build of the site, it may be + # displayed when working on data locally (for example adding a + # track before you've brought in its cover art). + + missingImage: "(This image file is missing)" + + # misingLinkContent: + # Generic fallback when a link is completely missing its content. + # This is only to make those links visible in the first place - + # it should never appear on the website and is only intended for + # debugging. + + missingLinkContent: "(Missing link content)" + + # nav: + # Generic navigational elements. These usually only appear in the + # wiki's nav bar, at the top of the page. + + nav: + previous: "Previous" + next: "Next" + info: "Info" + gallery: "Gallery" + + # pageTitle: + # Title set under the page's <title> HTML element, which is + # displayed in the browser tab bar, bookmarks list, etc. + + pageTitle: + _: "{TITLE}" + withWikiName: "{TITLE} | {WIKI_NAME}" + + # skippers: + # + # These are navigational links that only show up when you're + # navigating the wiki using the Tab key (or some other method of + # "tabbing" between links and interactive elements). They move + # the browser's nav focus to the selected element when pressed. + # + # There are a lot of definitions here, and they're mostly shown + # conditionally, based on the elements that are actually apparent + # on the current page. + # + + skippers: + skipTo: "Skip to:" + + content: "Content" + header: "Header" + footer: "Footer" + + sidebar: + _: "Sidebar" + left: "Sidebar (left)" + right: "Sidebar (right)" + + # Displayed on artist info page. + + tracks: "Tracks" + artworks: "Artworks" + flashes: "Flashes & Games" + + # Displayed on track and flash info pages. + + contributors: "Contributors" + + # Displayed on track info page. + + references: "References..." + referencedBy: "Referenced by..." + samples: "Samples..." + sampledBy: "Sampled by..." + features: "Features..." + featuredIn: "Featured in..." + + lyrics: "Lyrics" + + sheetMusicFiles: "Sheet music files" + midiProjectFiles: "MIDI/project files" + + # Displayed on track and album info pages. + + commentary: "Commentary" + + artistCommentary: "Commentary" + additionalFiles: "Additional files" + + # socialEmbed: + # Social embeds describe how the page should be represented on + # social platforms, chat messaging apps, and so on. + + socialEmbed: + heading: "{WIKI_NAME} | {HEADING}" + + # jumpTo: + # Generic action displayed at the top of some longer pages, for + # quickly scrolling down to a particular section. + + jumpTo: + _: "Jump to:" + withLinks: "Jump to: {LINKS}." + + # contentWarnings: + # Displayed for some artworks, informing of possibly sensitive + # content and giving the viewer a chance to consider before + # clicking through. + + contentWarnings: + _: "cw: {WARNINGS}" + reveal: "click to show" + + # albumGrid: + # Generic strings for various sorts of gallery grids, displayed + # on the homepage, album galleries, artist artwork galleries, and + # so on. These get the name of the thing being represented and, + # often, a bit of text providing pertinent extra details about + # that thing. + + albumGrid: + noCoverArt: "{ALBUM}" + + details: + _: "({TRACKS}, {TIME})" + coverArtists: "(Illust. {ARTISTS})" + otherCoverArtists: "(With {ARTISTS})" + + albumGalleryGrid: + noCoverArt: "{NAME}" + + # uiLanguage: + # Displayed in the footer, for switching between languages. + + uiLanguage: "UI Language: {LANGUAGES}" + +# +# homepage: +# This is the main index and home for the whole wiki! There isn't +# much for strings here as the layout is very customizable and +# includes mostly wiki-provided content. +# +homepage: + title: "{TITLE}" + + # news: + # If the wiki has news entries enabled, then there's a box in the + # homepage's sidebar (beneath custom sidebar content, if any) + # which displays the bodies the latest few entries up to a split. + + news: + title: "News" + + entry: + viewRest: "(View rest of entry!)" + +# +# albumSidebar: +# This sidebar is displayed on both the album and track info pages! +# It displays the groups that the album is from (each getting its +# own box on the album page, all conjoined in one box on the track +# page) and the list of tracks in the album, which can be sectioned +# similarly to normal track lists, but displays the range of tracks +# in each section rather than the section's duration. +# +albumSidebar: + trackList: + item: "{TRACK}" + + # fallbackSectionName: + # If an album's track list isn't sectioned, the track list here + # will still have all the tracks grouped under a list that can + # be toggled open and closed. This controls how that list gets + # titled. + + fallbackSectionName: "Track list" + + # group: + # "Group" is a misnomer - these are track sections. Some albums + # don't use track numbers at all, and for these, the default + # string will be used instead of group.withRange. + + group: + _: "{GROUP}" + withRange: "{GROUP} ({RANGE})" + + # groupBox: + # This is the box for groups. Apart from the next and previous + # links, it also gets "visit on" and the group's descripton + # (up to a split). + + groupBox: + title: "{GROUP}" + next: "Next: {ALBUM}" + previous: "Previous: {ALBUM}" + +# +# albumPage: +# +# Albums group together tracks and provide quick access to each of +# their pages, have release data (and sometimes credits) that are +# generally inherited by the album's tracks plus commentary and +# other goodies of their own, and are generally the main object on +# the wiki! +# +# Most of the strings on the album info page are tracked under +# releaseInfo, so there isn't a lot here. +# +albumPage: + title: "{ALBUM}" + + nav: + album: "{ALBUM}" + randomTrack: "Random Track" + gallery: "Gallery" + commentary: "Commentary" + + socialEmbed: + heading: "{GROUP}" + title: "{ALBUM}" + + # body: + # These permutations are a bit awkward. "Tracks" is a counted + # string, ex. "63 tracks". + + body: + withDuration: "{DURATION}." + withTracks: "{TRACKS}." + withReleaseDate: Released {DATE}. + withDuration.withTracks: "{DURATION}, {TRACKS}." + withDuration.withReleaseDate: "{DURATION}. Released {DATE}." + withTracks.withReleaseDate: "{TRACKS}. Released {DATE}." + withDuration.withTracks.withReleaseDate: "{DURATION}, {TRACKS}. Released {DATE}." + +# +# albumGalleryPage: +# Album galleries provide an alternative way to navigate the album, +# and put all its artwork - including for each track - into the +# spotlight. Apart from the main gallery grid (which usually lists +# each artwork's illustrators), this page also has a quick stats +# line about the album, and may display a message about all of the +# artworks if one applies. +# +albumGalleryPage: + title: "{ALBUM} - Gallery" + + # statsLine: + # Most albums have release dates, but not all. These strings + # react accordingly. + + statsLine: >- + {TRACKS} totaling {DURATION}. + + statsLine.withDate: >- + {TRACKS} totaling {DURATION}. Released {DATE}. + + # coverArtistsLine: + # This is displayed if every track (which has artwork at all) + # has the same illustration credits. + + coverArtistsLine: >- + All track artwork by {ARTISTS}. + + # noTrackArtworksLine: + # This is displayed if none of the tracks on the album have any + # artwork at all. Generally, this means the album gallery won't + # be linked from the album's other pages, but it is possible to + # end up on "stub galleries" using nav links on another gallery. + + noTrackArtworksLine: >- + This album doesn't have any track artwork. + +# +# albumCommentaryPage: +# The album commentary page is a more minimal layout that brings +# the commentary for the album, and each of its tracks, to the +# front. It's basically inspired by reading in a library, or by +# following along with an album's booklet or liner notes while +# playing it back on a treasured dinky CD player late at night. +# +albumCommentaryPage: + title: "{ALBUM} - Commentary" + + nav: + album: "Album: {ALBUM}" + + infoLine: >- + {WORDS} across {ENTRIES}. + + entry: + title: + albumCommentary: "Album commentary" + trackCommentary: "{TRACK}" + +# +# artistInfoPage: +# The artist info page is an artist's main home on the wiki, and +# automatically includes a full list of all the things they've +# contributed to and been credited on. It's split into a section +# for each of the kinds of things the artist is credited for, +# including tracks, artworks, flashes/games, and commentary. +# +artistPage: + title: "{ARTIST}" + + nav: + artist: "Artist: {ARTIST}" + + creditList: + + # album: + # Tracks are chunked by albums, as long as the tracks are all + # of the same date (if applicable). + + album: + _: "{ALBUM}" + withDate: "{ALBUM} ({DATE})" + withDuration: "{ALBUM} ({DURATION})" + withDate.withDuration: "{ALBUM} ({DATE}; {DURATION})" + + # flashAct: + # Flashes are chunked by flash act, though a single flash act + # might be split into multiple chunks if it spans a long range + # and the artist contributed to a flash from some other act + # between. A date range will be shown if an act has at least + # two differently dated flashes. + + flashAct: + _: "{ACT}" + withDate: "{ACT} ({DATE})" + withDateRange: "{ACT} ({DATE_RANGE})" + + # entry: + # This section covers strings for all kinds of individual + # things which an artist has contributed to, and refers to the + # items in each of the chunks described above. + + entry: + + # withContribution: + # The specific contribution that an artist made to a given + # thing may be described with a word or two, and that's shown + # in the list. + + withContribution: "{ENTRY} ({CONTRIBUTION})" + + # withArtists: + # This lists co-artists or co-contributors, depending on how + # the artist themselves was credited. + + withArtists: "{ENTRY} (with {ARTISTS})" + + withArtists.withContribution: "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})" + + # rerelease: + # Tracks which aren't the original release don't display co- + # artists or contributors, and get dimmed a little compared + # to original release track entries. + + rerelease: "{ENTRY} (re-release)" + + # track: + # The string without duration is used in both the artist's + # track credits list as well as their commentary list. + + track: + _: "{TRACK}" + withDuration: "({DURATION}) {TRACK}" + + # album: + # The artist info page doesn't display if the artist is + # musically credited outright for the album as a whole, + # opting to show each of the tracks from that album instead. + # But other parts belonging specifically to the album have + # credits too, and those entreis get the strings below. + + album: + coverArt: "(cover art)" + wallpaperArt: "(wallpaper art)" + bannerArt: "(banner art)" + commentary: "(album commentary)" + + flash: + _: "{FLASH}" + + # contributedDurationLine: + # This is shown at the top of the artist's track list, provided + # any of their tracks have durations at all. + + contributedDurationLine: >- + {ARTIST} has contributed {DURATION} of music shared on this wiki. + + # groupContributions: + # This is a special "chunk" shown at the top of an artist's + # track and artwork lists. It lists which groups an artist has + # contributed the most (and least) to, and is interactive - + # it can be sorted by count or, for tracks, by duration. + + groupContributions: + title: + music: "Contributed music to groups:" + artworks: "Contributed artworks to groups:" + withSortButton: "{TITLE} ({SORT})" + + sorting: + count: "Sorting by count." + duration: "Sorting by duration." + + item: + countAccent: "({COUNT})" + durationAccent: "({DURATION})" + countDurationAccent: "({COUNT} — {DURATION})" + durationCountAccent: "({DURATION} — {COUNT})" + + trackList: + title: "Tracks" + + artList: + title: "Artworks" + + flashList: + title: "Flashes & Games" + + commentaryList: + title: "Commentary" + + # viewArtGallery: + # This is shown twice on the page - once at almost the very top + # of the page, just beneath visiting links, and once above the + # list of credited artworks, where it gets the longer + # orBrowseList form. + + viewArtGallery: + _: "View {LINK}!" + orBrowseList: "View {LINK}! Or browse the list:" + link: "art gallery" + +# +# artistGalleryPage: +# The artist gallery page shows a neat grid of all of the album and +# track artworks an artist has contributed to! Co-illustrators are +# also displayed when applicable. +# +artistGalleryPage: + title: "{ARTIST} - Gallery" + + infoLine: >- + Contributed to {COVER_ARTS}. + +# +# commentaryIndex: +# The commentary index page shows a summary of all the commentary +# across the entire wiki, with a list linking to each album's +# dedicated commentary page. +# +commentaryIndex: + title: "Commentary" + + infoLine: >- + {WORDS} across {ENTRIES}, in all. + + albumList: + title: "Choose an album:" + item: "{ALBUM} ({WORDS} across {ENTRIES})" + +# +# flashIndex: +# The flash index page shows a very long grid including every flash +# on the wiki, sectioned with big headings for each act. It's also +# got jump links at the top to skip to a specific overarching +# section ("side") of flash acts. +# +flashIndex: + title: "Flashes & Games" + +# +# flashSidebar: +# The flash sidebar is used on both the flash info and flash act +# gallery pages, and has two boxes - one showing all the flashes in +# the current flash act, and one showing all the flash acts on the +# wiki, sectioned by "side". +# +flashSidebar: + flashList: + + # These two strings are the default ones used when a flash act + # doesn't specify a custom phrasing. + flashesInThisAct: "Flashes in this act" + entriesInThisSection: "Entries in this section" + +# +# flashPage: +# The flash info page shows release information, links to check the +# flash out, and lists of contributors and featured tracks. Most of +# those strings are under releaseInfo, so there aren't a lot of +# strings here. +# +flashPage: + title: "{FLASH}" + + nav: + flash: "{FLASH}" + +# +# groupSidebar: +# The group sidebar is used on both the group info and group +# gallery pages, and is formed of just one box, showing all the +# groups on the wiki, sectioned by "category". +# +groupSidebar: + title: "Groups" + + groupList: + category: "{CATEGORY}" + item: "{GROUP}" + +# +# groupPage: +# This section represents strings common to multiple group pages. +# +groupPage: + nav: + group: "Group: {GROUP}" + +# +# groupInfoPage: +# The group info page shows visiting links, the group's full +# description, and a list of albums from the group. +# +groupInfoPage: + title: "{GROUP}" + + viewAlbumGallery: + _: "View {LINK}! Or browse the list:" + link: "album gallery" + + # albumList: + # Many albums are present under multiple groups, and these get an + # accent indicating what other group is highest on the album's + # list of groups. + + albumList: + title: "Albums" + + item: + _: "({YEAR}) {ALBUM}" + withoutYear: "{ALBUM}" + withAccent: "{ITEM} {ACCENT}" + otherGroupAccent: "(from {GROUP})" + +# +# groupGalleryPage: +# The group gallery page shows a grid of all the albums from that +# group, each including the number of tracks and duration, as well +# as a stats line for the group as a whole, and a neat carousel, if +# pre-configured! +# +groupGalleryPage: + title: "{GROUP} - Gallery" + + infoLine: >- + {TRACKS} across {ALBUMS}, totaling {TIME}. + +# +# listingIndex: +# The listing index page shows all available listings on the wiki, +# and a very exciting stats line for the wiki as a whole. +# +listingIndex: + title: "Listings" + + infoLine: >- + {WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}. + + exploreList: >- + Feel free to explore any of the listings linked below and in the sidebar! + +# +# listingPage: +# +# There are a lot of listings! Each is automatically generated and +# sorts or organizes the data on the wiki in some way that provides +# useful or interesting information. Most listings work primarily +# with one kind of data and are sectioned accordingly, for example +# "listAlbums.byDuration" or "listTracks.byDate". +# +# There are also some miscellaneous strings here, most of which are +# common to a variety of listings, and are often navigational in +# nature. +# +listingPage: + + # target: + # Just the names for each of the sections - each chunk on the + # listing index (and in the sidebar) gets is titled with one of + # these. + + target: + album: "Albums" + artist: "Artists" + group: "Groups" + track: "Tracks" + tag: "Tags" + other: "Other" + + # misc: + # Common, generic terminology across multiple listings. + + misc: + trackContributors: "Track Contributors" + artContributors: "Art Contributors" + flashContributors: "Flash & Game Contributors" + artAndFlashContributors: "Art & Flash Contributors" + + # listingFor: + # Displays quick links to navigate to other listings for the + # current target. + + listingsFor: "Listings for {TARGET}: {LISTINGS}" + + # seeAlso: + # Displays directly related listings, which might be from other + # targets besides the current one. + + seeAlso: "Also check out: {LISTINGS}" + + # skipToSection: + # Some listings which use a chunked-list layout also show links + # to scroll down to each of these sections - this is the title + # for the list of those links. + + skipToSection: "Skip to a section:" + + listAlbums: + + # listAlbums.byName: + # Lists albums alphabetically without sorting or chunking by + # any other criteria. Also displays the number of tracks for + # each album. + + byName: + title: "Albums - by Name" + title.short: "...by Name" + item: "{ALBUM} ({TRACKS})" + + # listAlbums.byTracks: + # Lists albums by number of tracks, most to least, or by name + # alphabetically, if two albums have the same track count. + # Albums without any tracks are totally excluded. + + byTracks: + title: "Albums - by Tracks" + title.short: "...by Tracks" + item: "{ALBUM} ({TRACKS})" + + # listAlbums.byDuration: + # Lists albums by total duration of all tracks, longest to + # shortest, falling back to an alphabetical sort if two albums + # are the same duration. Albums with zero duration are totally + # excluded. + + byDuration: + title: "Albums - by Duration" + title.short: "...by Duration" + item: "{ALBUM} ({DURATION})" + + # listAlbums.byDate: + # Lists albums by release date, oldest to newest, falling back + # to an alphabetical sort if two albums were released on the + # same date. Dateless albums are totally excluded. + + byDate: + title: "Albums - by Date" + title.short: "...by Date" + item: "{ALBUM} ({DATE})" + + # listAlbums.byDateAdded: + # Lists albums by the date they were added to the wiki, oldest + # to newest, and chunks these by date, since albums are usually + # added in bunches at a time. The albums in each chunk are + # sorted alphabetically, and albums which are missing the + # "Date Added" field are totally excluded. + + byDateAdded: + title: "Albums - by Date Added to Wiki" + title.short: "...by Date Added to Wiki" + chunk: + title: "{DATE}" + item: "{ALBUM}" + + listArtists: + + # listArtists.byName: + # Lists artists alphabetically without sorting or chunking by + # any other criteria. Also displays the number of contributions + # from each artist. + + byName: + title: "Artists - by Name" + title.short: "...by Name" + item: "{ARTIST} ({CONTRIBUTIONS})" + + # listArtists.byContribs: + # Lists artists by number of contributions, most to least, + # with separate lists for contributions to tracks, artworks, + # and flashes. Falls back alphabetically if two artists have + # the same number of contributions. Artists who aren't credited + # for any contributions to each of these categories are + # excluded from the respective list. + + byContribs: + title: "Artists - by Contributions" + title.short: "...by Contributions" + chunk: + item: "{ARTIST} ({CONTRIBUTIONS})" + title: + trackContributors: "Contributed tracks:" + artContributors: "Contributed artworks:" + flashContributors: "Contributed to flashes & games:" + + # listArtists.byCommentary: + # Lists artists by number of commentary entries, most to least, + # falling back to an alphabetical sort if two artists have the + # same count. Artists who don't have any commentary entries are + # totally excluded. + + byCommentary: + title: "Artists - by Commentary Entries" + title.short: "...by Commentary Entries" + item: "{ARTIST} ({ENTRIES})" + + # listArtists.byDuration: + # Lists artists by total duration of the tracks which they're + # credited on (as either artist or contributor), longest sum to + # shortest, falling back alphabetically if two artists have + # the same duration. Artists who haven't contributed any music, + # or whose tracks all lack durations, are totally excluded. + + byDuration: + title: "Artists - by Duration" + title.short: "...by Duration" + item: "{ARTIST} ({DURATION})" + + # listArtists.byGroup: + # Lists artists who have contributed to each of the main groups + # of a wiki (its "Divide Track Lists By Groups" field), sorted + # alphabetically. Artists who aren't credited for contributions + # under each of the groups are exlcuded from the respective + # list. + + byGroup: + title: "Artists - by Group" + title.short: "...by Group" + item: "{ARTIST} ({CONTRIBUTIONS})" + chunk: + title: "Contributed to {GROUP}:" + item: "{ARTIST} ({CONTRIBUTIONS})" + + # listArtists.byLatest: + # Lists artists by the date of their latest contribution + # overall, and chunks artists together by the album or flash + # which that contribution belongs to. Within albums, each + # artist is accented with the kind of contribution they made - + # tracks, artworks, or both - and sorted so those of the same + # sort of contribution are bunched together, then by name. + # Artists who aren't credited for any dated contributions are + # included at the bottom under a separate chunk. + + byLatest: + title: "Artists - by Latest Contribution" + title.short: "...by Latest Contribution" + chunk: + title: + album: "{ALBUM} ({DATE})" + flash: "{FLASH} ({DATE})" + dateless: "These artists' contributions aren't dated:" + item: + _: "{ARTIST}" + tracks: "{ARTIST} (tracks)" + tracksAndArt: "{ARTIST} (tracks, art)" + art: "{ARTIST} (art)" + + listGroups: + + # listGroups.byName: + # Lists groups alphabetically without sorting or chunking by + # any other criteria. Also displays a link to each group's + # gallery page. + + byName: + title: "Groups - by Name" + title.short: "...by Name" + item: "{GROUP} ({GALLERY})" + item.gallery: "Gallery" + + # listGroups.byCategory: + # Lists groups directly reflecting the way they're sorted in + # the wiki's groups.yaml data file, with no automatic sorting, + # chunked (as sectioned in groups.yaml) by category. Also shows + # a link to each group's gallery page. + + byCategory: + title: "Groups - by Category" + title.short: "...by Category" + + chunk: + title: "{CATEGORY}" + item: "{GROUP} ({GALLERY})" + item.gallery: "Gallery" + + # listGroups.byAlbums: + # Lists groups by number of belonging albums, most to least, + # falling back alphabetically if two groups have the same + # number of albums. Groups without any albums are totally + # excluded. + + byAlbums: + title: "Groups - by Albums" + title.short: "...by Albums" + item: "{GROUP} ({ALBUMS})" + + # listGroups.byTracks: + # Lists groups by number of tracks under each group's albums, + # most to least, falling back to an alphabetical sort if two + # groups have the same track counts. Groups without any tracks + # are totally excluded. + + byTracks: + title: "Groups - by Tracks" + title.short: "...by Tracks" + item: "{GROUP} ({TRACKS})" + + # listGroups.byDuration: + # Lists groups by sum of durations of all the tracks under each + # of the group's albums, longest to shortest, falling back to + # an alphabetical sort if two groups have the same duration. + # Groups whose total duration is zero are totally excluded. + + byDuration: + title: "Groups - by Duration" + title.short: "...by Duration" + item: "{GROUP} ({DURATION})" + + # listGroups.byLatest: + # List groups by release date of each group's most recent + # album, most recent to longest ago, falling back to sorting + # alphabetically if two groups' latest albums were released + # on the same date. Groups which don't have any albums, or + # whose albums are all dateless, are totally excluded. + + byLatest: + title: "Groups - by Latest Album" + title.short: "...by Latest Album" + item: "{GROUP} ({DATE})" + + listTracks: + + # listTracks.byName: + # List tracks alphabetically without sorting or chunking by + # any other criteria. + + byName: + title: "Tracks - by Name" + title.short: "...by Name" + item: "{TRACK}" + + # listTracks.byAlbum: + # List tracks chunked by the album they're from, retaining the + # position each track occupies in its album, and sorting albums + # from oldest to newest (or alphabetically, if two albums were + # released on the same date). Dateless albums are included at + # the bottom of the list. Custom "Date First Released" fields + # on individual tracks are totally ignored. + + byAlbum: + title: "Tracks - by Album" + title.short: "...by Album" + + chunk: + title: "{ALBUM}" + item: "{TRACK}" + + # listTracks.byDate: + # List tracks according to their own release dates, which may + # differ from that of the album via the "Date First Released" + # field, oldest to newest, and chunked by album when multiple + # tracks from one album were released on the same date. Track + # order within a given album is preserved where possible. + # Dateless albums are excluded, except for contained tracks + # which have custom "Date First Released" fields. + + byDate: + title: "Tracks - by Date" + title.short: "...by Date" + + chunk: + title: "{ALBUM} ({DATE})" + item: "{TRACK}" + item.rerelease: "{TRACK} (re-release)" + + # listTracks.byDuration: + # List tracks by duration, longest to shortest, falling back to + # an alphabetical sort if two tracks have the same duration. + # Tracks which don't have any duration are totally excluded. + + byDuration: + title: "Tracks - by Duration" + title.short: "...by Duration" + item: "{TRACK} ({DURATION})" + + # listTracks.byDurationInAlbum: + # List tracks chunked by the album they're from, then sorted + # by duration, longest to shortest; albums are sorted by date, + # oldest to newest, and both sorts fall back alphabetically. + # Dateless albums are included at the bottom of the list. + + byDurationInAlbum: + title: "Tracks - by Duration (in Album)" + title.short: "...by Duration (in Album)" + + chunk: + title: "{ALBUM}" + item: "{TRACK} ({DURATION})" + + # listTracks.byTimesReferenced: + # List tracks by how many other tracks' reference lists each + # appears in, most times referenced to fewest, falling back + # alphabetically if two tracks have been referenced the same + # number of times. Tracks that aren't referenced by any other + # tracks are totally excluded from the list. + + byTimesReferenced: + title: "Tracks - by Times Referenced" + title.short: "...by Times Referenced" + item: "{TRACK} ({TIMES_REFERENCED})" + + # listTracks.inFlashes.byAlbum: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # and display the list of flashes that eack track is featured + # in. Tracks which aren't featured in any flashes are totally + # excluded from the list. + + inFlashes.byAlbum: + title: "Tracks - in Flashes & Games (by Album)" + title.short: "...in Flashes & Games (by Album)" + + chunk: + title: "{ALBUM}" + item: "{TRACK} (in {FLASHES})" + + # listTracks.inFlashes.byFlash: + # List tracks, chunked by flash (which are sorted by date, + # retaining their positions in a common act where applicable, + # or else by the two acts' names) and sorted according to the + # featured list of the flash, and display a link to the album + # each track is contained in. Tracks which aren't featured in + # any flashes are totally excluded from the list. + + inFlashes.byFlash: + title: "Tracks - in Flashes & Games (by Flash)" + title.short: "...in Flashes & Games (by Flash)" + + chunk: + title: "{FLASH}" + item: "{TRACK} (from {ALBUM})" + + # listTracks.withLyrics: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which have lyrics. The chunk titles + # also display the date each album was released, and tracks' + # own custom "Date First Released" fields are totally ignored. + + withLyrics: + title: "Tracks - with Lyrics" + title.short: "...with Lyrics" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + + # listTracks.withSheetMusicFiles: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which have sheet music files. The + # chunk titles also display the date each album was released, + # and tracks' own custom "Date First Released" fields are + # totally ignored. + + withSheetMusicFiles: + title: "Tracks - with Sheet Music Files" + title.short: "...with Sheet Music Files" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + + # listTracks.withMidiProjectFiles: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which have MIDI & project files. The + # chunk titles also display the date each album was released, + # and tracks' own custom "Date First Released" fields are + # totally ignored. + + withMidiProjectFiles: + title: "Tracks - with MIDI & Project Files" + title.short: "...with MIDI & Project Files" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + + listTags: + + # listTags.byName: + # List art tags alphabetically without sorting or chunking by + # any other criteria. Also displays the number of times each + # art tag has been featured. + + byName: + title: "Tags - by Name" + title.short: "...by Name" + item: "{TAG} ({TIMES_USED})" + + # listTags.byUses: + # List art tags by number of times used, falling back to an + # alphabetical sort if two art tags have been featured the same + # number of times. Art tags which haven't haven't been featured + # at all yet are totally excluded from the list. + + byUses: + title: "Tags - by Uses" + title.short: "...by Uses" + item: "{TAG} ({TIMES_USED})" + + other: + + # other.allSheetMusic: + # List all sheet music files, sectioned by album (which are + # sorted by date, falling back alphabetically) and then by + # track (which retain album ordering). If one "file" entry + # contains multiple files, then it's displayed as an expandable + # list, collapsed by default, accented with the number of + # downloadable files. + + allSheetMusic: + title: "All Sheet Music" + title.short: "All Sheet Music" + albumFiles: "Album sheet music:" + + file: + _: "{TITLE}" + withMultipleFiles: "{TITLE} ({FILES})" + + # other.midiProjectFiles: + # Same as other.allSheetMusic, but for MIDI & project files. + + allMidiProjectFiles: + title: "All MIDI/Project Files" + title.short: "All MIDI/Project Files" + albumFiles: "Album MIDI/project files:" + + file: + _: "{TITLE}" + withMultipleFiles: "{TITLE} ({FILES})" + + # other.additionalFiles: + # Same as other.allSheetMusic, but for additional files. + + allAdditionalFiles: + title: "All Additional Files" + title.short: "All Additional Files" + albumFiles: "Album additional files:" + + file: + _: "{TITLE}" + withMultipleFiles: "{TITLE} ({FILES})" + + # other.randomPages: + # Special listing which shows a bunch of buttons that each + # link to a random page on the wiki under a particular scope. + + randomPages: + title: "Random Pages" + title.short: "Random Pages" + + # chooseLinkLine: + # Introductory line explaining the links on this listing. + + chooseLinkLine: + _: "{FROM_PART} {BROWSER_SUPPORT_PART}" + + fromPart: + dividedByGroups: >- + Choose a link to go to a random page in that group or album! + notDividedByGroups: >- + Choose a link to go to a random page in that album! + + browserSupportPart: >- + If your browser doesn't support relatively modern JavaScript + or you've disabled it, these links won't work - sorry. + + # dataLoadingLine, dataLoadedLine: + # 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 + # download and whether or not the links will work yet. + + dataLoadingLine: >- + (Data files are downloading in the background! Please wait for data to load.) + + dataLoadedLine: >- + (Data files have finished being downloaded. The links should work!) + + chunk: + + 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}:" + + accent: + _: "({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" + + randomAlbumWholeSite: "Random Album (whole site)" + randomTrackWholeSite: "Random Track (whole site)" + +# +# newsIndex: +# The news index page shows a list of every news entry on the wiki! +# (If it's got news entries enabled.) Each entry gets a stylized +# heading with its name of and date, sorted newest to oldest, as +# well as its body (up to a split) and a link to view the rest of +# the entry on its dedicated news entry page. +# +newsIndex: + title: "News" + + entry: + viewRest: "(View rest of entry!)" + +# +# newsEntryPage: +# The news entry page displays all the content of a news entry, +# as well as its date published, in one big list, and has nav links +# to go to the previous and next news entry. +# +newsEntryPage: + title: "{ENTRY}" + published: "(Published {DATE}.)" + +# +# redirectPage: +# Static "placeholder" pages when redirecting a visitor from one +# page to another - this generally happens automatically, before +# you have a chance to read the page, so content is concise. +# +redirectPage: + title: "Moved to {TITLE}" + + infoLine: >- + This page has been moved to {TARGET}. + +# +# tagPage: +# The tag gallery page displays all the artworks that a tag has +# been featured in, in one neat grid, with each artwork displaying +# its illustrators, as well as a short info line that indicates +# how many artworks the tag's part of. +# +tagPage: + title: "{TAG}" + + nav: + tag: "Tag: {TAG}" + + infoLine: >- + Appears in {COVER_ARTS}. + +# +# trackPage: +# +# The track info page is pretty much the most discrete and common +# chunk of information across the whole site, displaying info about +# the track like its release date, artists, cover illustrators, +# commentary, and more, as well as relational info, like the tracks +# it references and tracks which reference it, and flashes which +# it's been featured in. Tracks can also have extra related files, +# like sheet music and MIDI/project files. +# +# Most of the details about tracks use strings that are defined +# under releaseInfo, so this section is a little sparse. +# +trackPage: + title: "{TRACK}" + + nav: + random: "Random" + + track: + _: "{TRACK}" + withNumber: "{NUMBER}. {TRACK}" + + socialEmbed: + heading: "{ALBUM}" + title: "{TRACK}" + + body: + withArtists.withCoverArtists: "By {ARTISTS}; art by {COVER_ARTISTS}." + withArtists: "By {ARTISTS}." + withCoverArtists: "Art by {COVER_ARTISTS}." diff --git a/src/upd8.js b/src/upd8.js index 3d7da800..ebb278b2 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -40,7 +40,8 @@ import wrap from 'word-wrap'; import CacheableObject from '#cacheable-object'; import {displayCompositeCacheAnalysis} from '#composite'; -import {processLanguageFile} from '#language'; +import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile} + from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; import {empty, showAggregate, withEntries} from '#sugar'; @@ -56,7 +57,6 @@ import { logError, parseOptions, progressCallAll, - progressPromiseAll, } from '#cli'; import genThumbs, { @@ -94,8 +94,6 @@ try { const BUILD_TIME = new Date(); -const DEFAULT_STRINGS_FILE = 'strings-default.json'; - const STATUS_NOT_STARTED = `not started`; const STATUS_NOT_APPLICABLE = `not applicable`; const STATUS_STARTED_NOT_DONE = `started but not yet done`; @@ -291,6 +289,18 @@ async function main() { type: 'flag', }, + 'no-input': { + help: `Don't wait on input from stdin - assume the device is headless`, + type: 'flag', + }, + + 'no-language-reloading': { + help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`, + type: 'flag', + }, + + 'no-language-reload': {alias: 'no-language-reloading'}, + // Want sweet, sweet trace8ack info in aggreg8te error messages? This // will print all the juicy details (or at least the first relevant // line) right to your output, 8ut also pro8a8ly give you a headache @@ -457,6 +467,8 @@ async function main() { const thumbsOnly = cliOptions['thumbs-only'] ?? false; const skipReferenceValidation = cliOptions['skip-reference-validation'] ?? false; const noBuild = cliOptions['no-build'] ?? false; + const noInput = cliOptions['no-input'] ?? false; + let noLanguageReloading = cliOptions['no-language-reloading'] ?? null; // Will get default later. showStepStatusSummary = cliOptions['show-step-summary'] ?? false; @@ -567,12 +579,24 @@ async function main() { } if (noBuild) { + logInfo`Won't generate any site or page files this run (--no-build passed).`; + Object.assign(stepStatusSummary.performBuild, { status: STATUS_NOT_APPLICABLE, annotation: `--no-build provided`, }); + } else if (usingDefaultBuildMode) { + logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`; + } else { + logInfo`Will use specified build mode: ${selectedBuildModeFlag}`; } + noLanguageReloading ??= + ({ + 'static-build': true, + 'live-dev-server': false, + })[selectedBuildModeFlag]; + if (skipThumbs && thumbsOnly) { logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`; return false; @@ -766,14 +790,6 @@ async function main() { thumbsCache = result.cache; } - if (noBuild) { - logInfo`Not generating any site or page files this run (--no-build passed).`; - } else if (usingDefaultBuildMode) { - logInfo`No build mode specified, using default: ${selectedBuildModeFlag}`; - } else { - logInfo`Using specified build mode: ${selectedBuildModeFlag}`; - } - if (showInvalidPropertyAccesses) { CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; } @@ -1085,18 +1101,52 @@ async function main() { }); let internalDefaultLanguage; + let internalDefaultLanguageWatcher; - try { - internalDefaultLanguage = - await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE)); + let errorLoadingInternalDefaultLanguage = false; - Object.assign(stepStatusSummary.loadInternalDefaultLanguage, { - status: STATUS_DONE_CLEAN, - timeEnd: Date.now(), - }); - } catch (error) { - console.error(error); + if (noLanguageReloading) { + internalDefaultLanguageWatcher = null; + try { + internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile); + } catch (error) { + niceShowAggregate(error); + errorLoadingInternalDefaultLanguage = true; + } + } else { + internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile); + + try { + await new Promise((resolve, reject) => { + const watcher = internalDefaultLanguageWatcher; + + const onReady = () => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + resolve(); + }; + + const onError = error => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + watcher.close(); + reject(error); + }; + + watcher.on('ready', onReady); + watcher.on('error', onError); + }); + + internalDefaultLanguage = internalDefaultLanguageWatcher.language; + } catch (_error) { + // No need to display the error here - it's already printed by + // watchLanguageFile. + errorLoadingInternalDefaultLanguage = true; + } + } + + if (errorLoadingInternalDefaultLanguage) { logError`There was an error reading the internal language file.`; fileIssue(); @@ -1109,6 +1159,17 @@ async function main() { return false; } + if (!noLanguageReloading) { + // Bypass node.js special-case handling for uncaught error events + internalDefaultLanguageWatcher.on('error', () => {}); + } + + Object.assign(stepStatusSummary.loadInternalDefaultLanguage, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + + let customLanguageWatchers; let languages; if (langPath) { @@ -1118,20 +1179,103 @@ 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', }); - let results; + let errorLoadingCustomLanguages = false; - // TODO: Aggregate errors (with Promise.allSettled). - try { - results = - await progressPromiseAll(`Reading & processing language files.`, - languageDataFiles.map((file) => processLanguageFile(file))); - } catch (error) { - console.error(error); + if (noLanguageReloading) { + languages = {}; + + const results = + await Promise.allSettled( + languageDataFiles + .map(file => processLanguageFile(file))); + + for (const {status, value: language, reason: error} of results) { + if (status === 'rejected') { + errorLoadingCustomLanguages = true; + niceShowAggregate(error); + } else { + languages[language.code] = language; + } + } + } else watchCustomLanguages: { + customLanguageWatchers = + languageDataFiles.map(file => { + const watcher = watchLanguageFile(file); + + // Bypass node.js special-case handling for uncaught error events + watcher.on('error', () => {}); + + return watcher; + }); + const waitingOnWatchers = new Set(customLanguageWatchers); + + const initialResults = + await Promise.allSettled( + customLanguageWatchers + .map(watcher => new Promise((resolve, reject) => { + const onReady = () => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + waitingOnWatchers.delete(watcher); + resolve(); + }; + + const onError = error => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + reject(error); + }; + + watcher.on('ready', onReady); + watcher.on('error', onError); + }))); + + if (initialResults.some(({status}) => status === 'rejected')) { + logWarn`There were errors loading custom languages from the language path`; + logWarn`provided: ${langPath}`; + + if (noInput) { + internalDefaultLanguageWatcher.close(); + + for (const watcher of Object.values(customLanguageWatchers)) { + watcher.close(); + } + + errorLoadingCustomLanguages = true; + break watchCustomLanguages; + } + + logWarn`The build should start automatically if you investigate these.`; + logWarn`Or, exit by pressing ^C here (control+C) and run again without`; + logWarn`providing ${'--lang-path'} (or ${'HSMUSIC_LANG'}) to build without custom`; + logWarn`languages.`; + + await new Promise(resolve => { + for (const watcher of waitingOnWatchers) { + watcher.once('ready', () => { + waitingOnWatchers.remove(watcher); + if (empty(waitingOnWatchers)) { + resolve(); + } + }); + } + }); + } + + languages = + Object.fromEntries( + customLanguageWatchers + .map(({language}) => [language.code, language])); + } + + if (errorLoadingCustomLanguages) { logError`Failed to load language files. Please investigate these, or don't provide`; logError`--lang-path (or HSMUSIC_LANG) and build again.`; @@ -1144,13 +1288,15 @@ async function main() { return false; } - languages = - Object.fromEntries( - results.map((language) => [language.code, language])); - Object.assign(stepStatusSummary.loadLanguageFiles, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + annotation: + (noLanguageReloading + ? (selectedBuildModeFlag === 'static-build' + ? `loaded statically, default for --static-build` + : `loaded statically, --no-language-reloading provided`) + : `watching for changes`), }); } else { languages = {}; @@ -1161,57 +1307,107 @@ async function main() { timeStart: Date.now(), }); - const customDefaultLanguage = - languages[wikiData.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code]; let finalDefaultLanguage; + let finalDefaultLanguageWatcher; + let finalDefaultLanguageAnnotation; + + if (wikiData.wikiInfo.defaultLanguage) { + const customDefaultLanguage = languages[wikiData.wikiInfo.defaultLanguage]; + + if (!customDefaultLanguage) { + logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`; + if (langPath) { + logError`Check if an appropriate file exists in ${langPath}?`; + } else { + logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`; + } + + Object.assign(stepStatusSummary.initializeDefaultLanguage, { + status: STATUS_FATAL_ERROR, + annotation: `wiki specifies default language whose file is not available`, + timeEnd: Date.now(), + }); + + return false; + } - if (customDefaultLanguage) { logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`; - customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings; + finalDefaultLanguage = customDefaultLanguage; + finalDefaultLanguageAnnotation = `using wiki-specified custom default language`; - Object.assign(stepStatusSummary.initializeDefaultLanguage, { - status: STATUS_DONE_CLEAN, - annotation: `using wiki-specified custom default language`, - timeEnd: Date.now(), - }); - } else if (wikiData.wikiInfo.defaultLanguage) { - logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`; - if (langPath) { - logError`Check if an appropriate file exists in ${langPath}?`; - } else { - logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`; + if (!noLanguageReloading) { + finalDefaultLanguageWatcher = + customLanguageWatchers + .find(({language}) => language === customDefaultLanguage); } + } else if (languages[internalDefaultLanguage.code]) { + const customDefaultLanguage = languages[internalDefaultLanguage.code]; - Object.assign(stepStatusSummary.initializeDefaultLanguage, { - status: STATUS_FATAL_ERROR, - annotation: `wiki specifies default language whose file is not available`, - timeEnd: Date.now(), - }); + finalDefaultLanguage = customDefaultLanguage; + finalDefaultLanguageAnnotation = `using inferred custom default language`; - return false; + if (!noLanguageReloading) { + finalDefaultLanguageWatcher = + customLanguageWatchers + .find(({language}) => language === customDefaultLanguage); + } } else { languages[internalDefaultLanguage.code] = internalDefaultLanguage; + finalDefaultLanguage = internalDefaultLanguage; - stepStatusSummary.initializeDefaultLanguage.status = STATUS_DONE_CLEAN; + finalDefaultLanguageAnnotation = `no custom default language specified`; - Object.assign(stepStatusSummary.initializeDefaultLanguage, { - status: STATUS_DONE_CLEAN, - annotation: `no custom default language specified`, - timeEnd: Date.now(), - }); + if (!noLanguageReloading) { + finalDefaultLanguageWatcher = internalDefaultLanguageWatcher; + } + } + + const inheritStringsFromInternalLanguage = () => { + // The custom default language, if set, will be the new one providing fallback + // strings for other languages. But on its own, it still might not be a complete + // list of strings - so it falls back to the internal default language, which + // won't otherwise be presented in the build. + if (finalDefaultLanguage === internalDefaultLanguage) return; + const {strings: inheritedStrings} = internalDefaultLanguage; + Object.assign(finalDefaultLanguage, {inheritedStrings}); + }; + + const inheritStringsFromDefaultLanguage = () => { + const {strings: inheritedStrings} = finalDefaultLanguage; + for (const language of Object.values(languages)) { + if (language === finalDefaultLanguage) continue; + Object.assign(language, {inheritedStrings}); + } + }; + + if (finalDefaultLanguage !== internalDefaultLanguage) { + inheritStringsFromInternalLanguage(); } - for (const language of Object.values(languages)) { - if (language === finalDefaultLanguage) { - continue; + inheritStringsFromDefaultLanguage(); + + if (!noLanguageReloading) { + if (finalDefaultLanguage !== internalDefaultLanguage) { + internalDefaultLanguageWatcher.on('update', () => { + inheritStringsFromInternalLanguage(); + inheritStringsFromDefaultLanguage(); + }); } - language.inheritedStrings = finalDefaultLanguage.strings; + finalDefaultLanguageWatcher.on('update', () => { + inheritStringsFromDefaultLanguage(); + }); } logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`; + Object.assign(stepStatusSummary.initializeDefaultLanguage, { + status: STATUS_DONE_CLEAN, + annotation: finalDefaultLanguageAnnotation, + timeEnd: Date.now(), + }); + const urls = generateURLs(urlSpec); Object.assign(stepStatusSummary.verifyImagePaths, { diff --git a/src/util/html.js b/src/util/html.js index 282a52da..5b6743e0 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -181,6 +181,10 @@ export function tags(content, attributes = null) { return new Tag(null, attributes, content); } +export function normalize(content) { + return Tag.normalize(content); +} + export class Tag { #tagName = ''; #content = null; diff --git a/test/lib/content-function.js b/test/lib/content-function.js index 5cb499b1..a4c5dac1 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 = ({ @@ -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, }, }); |