diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/content/dependencies/generateLyricsEntry.js | 25 | ||||
-rw-r--r-- | src/content/dependencies/generateLyricsSection.js | 42 | ||||
-rw-r--r-- | src/content/dependencies/generateLyricsSwitcher.js | 49 | ||||
-rw-r--r-- | src/content/dependencies/generateTrackInfoPage.js | 27 | ||||
-rw-r--r-- | src/content/dependencies/transformContent.js | 35 | ||||
-rw-r--r-- | src/data/composite/wiki-data/index.js | 2 | ||||
-rw-r--r-- | src/data/composite/wiki-data/processContentEntryDates.js | 181 | ||||
-rw-r--r-- | src/data/composite/wiki-data/withParsedCommentaryEntries.js | 77 | ||||
-rw-r--r-- | src/data/composite/wiki-data/withParsedLyricsEntries.js | 130 | ||||
-rw-r--r-- | src/data/composite/wiki-properties/commentary.js | 6 | ||||
-rw-r--r-- | src/data/composite/wiki-properties/index.js | 1 | ||||
-rw-r--r-- | src/data/composite/wiki-properties/lyrics.js | 36 | ||||
-rw-r--r-- | src/data/things/track.js | 3 | ||||
-rw-r--r-- | src/static/css/site.css | 13 | ||||
-rw-r--r-- | src/static/js/client/index.js | 2 | ||||
-rw-r--r-- | src/static/js/client/lyrics-switcher.js | 70 | ||||
-rw-r--r-- | src/strings-default.yaml | 7 | ||||
-rw-r--r-- | src/validators.js | 19 |
18 files changed, 632 insertions, 93 deletions
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js new file mode 100644 index 00000000..4f9c22f1 --- /dev/null +++ b/src/content/dependencies/generateLyricsEntry.js @@ -0,0 +1,25 @@ +export default { + contentDependencies: [ + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + content: + relation('transformContent', entry.body), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (relations, slots, {html}) => + html.tag('div', {class: 'lyrics-entry'}, + slots.attributes, + + relations.content.slot('mode', 'lyrics')), +}; diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js new file mode 100644 index 00000000..7e7718c7 --- /dev/null +++ b/src/content/dependencies/generateLyricsSection.js @@ -0,0 +1,42 @@ +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateLyricsEntry', + 'generateLyricsSwitcher', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + switcher: + relation('generateLyricsSwitcher', entries), + + entries: + entries + .map(entry => relation('generateLyricsEntry', entry)), + }), + + generate: (relations, {html, language}) => + html.tags([ + relations.heading + .slots({ + attributes: {id: 'lyrics'}, + title: language.$('releaseInfo.lyrics'), + }), + + relations.switcher, + + relations.entries + .map((entry, index) => + entry.slots({ + attributes: [ + index >= 1 && + {style: 'display: none'}, + ], + })), + ]), +}; diff --git a/src/content/dependencies/generateLyricsSwitcher.js b/src/content/dependencies/generateLyricsSwitcher.js new file mode 100644 index 00000000..1c9ee6a3 --- /dev/null +++ b/src/content/dependencies/generateLyricsSwitcher.js @@ -0,0 +1,49 @@ +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + annotations: + entries + .map(entry => entry.annotation) + .map(annotation => relation('transformContent', annotation)), + }), + + slots: { + tag: {type: 'string', default: 'p'}, + }, + + generate: (relations, slots, {html, language}) => + html.tag(slots.tag, {class: 'lyrics-switcher'}, + language.$('releaseInfo.lyrics.switcher', { + entries: + language.formatListWithoutSeparator( + relations.annotations + .map((annotation, index) => + html.tag('span', {[html.joinChildren]: ''}, [ + html.tag('a', + {href: '#'}, + + index === 0 && + {style: 'display: none'}, + + annotation + .slots({ + mode: 'inline', + textOnly: true, + })), + + html.tag('a', + {class: 'current'}, + + index >= 1 && + {style: 'display: none'}, + + annotation + .slots({ + mode: 'inline', + textOnly: true, + })), + ]))), + })), +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 7d531124..ca6f82b9 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -9,6 +9,7 @@ export default { 'generateCommentaryEntry', 'generateContentHeading', 'generateContributionList', + 'generateLyricsSection', 'generatePageLayout', 'generateTrackArtistCommentarySection', 'generateTrackArtworkColumn', @@ -90,8 +91,8 @@ export default { flashesThatFeatureList: relation('generateTrackInfoPageFeaturedByFlashesList', track), - lyrics: - relation('transformContent', track.lyrics), + lyricsSection: + relation('generateLyricsSection', track.lyrics), sheetMusicFilesList: relation('generateAlbumAdditionalFilesList', @@ -308,17 +309,19 @@ export default { relations.flashesThatFeatureList, ]), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'lyrics'}, - title: language.$('releaseInfo.lyrics'), - }), + relations.lyricsSection, - html.tag('blockquote', - {[html.onlyIfContent]: true}, - relations.lyrics.slot('mode', 'lyrics')), - ]), + // html.tags([ + // relations.contentHeading.clone() + // .slots({ + // attributes: {id: 'lyrics'}, + // title: language.$('releaseInfo.lyrics'), + // }), + + // html.tag('blockquote', + // {[html.onlyIfContent]: true}, + // relations.lyrics.slot('mode', 'lyrics')), + // ]), html.tags([ relations.contentHeading.clone() diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index f56a1da9..1bbd45e2 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -2,6 +2,7 @@ import {bindFind} from '#find'; import {replacerSpec, parseInput} from '#replacer'; import {Marked} from 'marked'; +import striptags from 'striptags'; const commonMarkedOptions = { headerIds: false, @@ -184,6 +185,8 @@ export default { link: relation(name, arg), label: node.data.label, hash: node.data.hash, + name: arg?.name, + shortName: arg?.shortName ?? arg?.nameShort, } : getPlaceholder(node, content)); @@ -241,6 +244,11 @@ export default { default: true, }, + textOnly: { + type: 'boolean', + default: false, + }, + thumb: { validate: v => v.is('small', 'medium', 'large'), default: 'large', @@ -452,7 +460,17 @@ export default { nodeFromRelations.link, {slots: ['content', 'hash']}); - const {label, hash} = nodeFromRelations; + const {label, hash, shortName, name} = nodeFromRelations; + + if (slots.textOnly) { + if (label) { + return {type: 'text', data: label}; + } else if (slots.preferShortLinkNames) { + return {type: 'text', data: shortName ?? name}; + } else { + return {type: 'text', data: name}; + } + } // These are removed from the typical combined slots({})-style // because we don't want to override slots that were already set @@ -506,6 +524,10 @@ export default { const {label} = node.data; const externalLink = relations.externalLinks[externalLinkIndex++]; + if (slots.textOnly) { + return {type: 'text', data: label}; + } + externalLink.setSlots({ content: label, fromContent: true, @@ -542,12 +564,19 @@ export default { ? valueFn(replacerValue) : replacerValue); - const contents = + const content = (htmlFn ? htmlFn(value, {html, language}) : value); - return {type: 'text', data: contents.toString()}; + const contentText = + html.resolve(content, {normalize: 'string'}); + + if (slots.textOnly) { + return {type: 'text', data: striptags(contentText)}; + } else { + return {type: 'text', data: contentText}; + } } default: diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index d2a60935..1d94f74b 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -11,6 +11,7 @@ export {default as inputNotFoundMode} from './inputNotFoundMode.js'; export {default as inputSoupyFind} from './inputSoupyFind.js'; export {default as inputSoupyReverse} from './inputSoupyReverse.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as processContentEntryDates} from './processContentEntryDates.js'; export {default as withClonedThings} from './withClonedThings.js'; export {default as withConstitutedArtwork} from './withConstitutedArtwork.js'; export {default as withContributionListSums} from './withContributionListSums.js'; @@ -18,6 +19,7 @@ export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; export {default as withParsedContentEntries} from './withParsedContentEntries.js'; +export {default as withParsedLyricsEntries} from './withParsedLyricsEntries.js'; export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js'; export {default as withRedatedContributionList} from './withRedatedContributionList.js'; export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js'; diff --git a/src/data/composite/wiki-data/processContentEntryDates.js b/src/data/composite/wiki-data/processContentEntryDates.js new file mode 100644 index 00000000..e418a121 --- /dev/null +++ b/src/data/composite/wiki-data/processContentEntryDates.js @@ -0,0 +1,181 @@ +import {input, templateCompositeFrom} from '#composite'; +import {stitchArrays} from '#sugar'; +import {isContentString, isString, looseArrayOf} from '#validators'; + +import {fillMissingListItems} from '#composite/data'; + +// Important note: These two kinds of inputs have the exact same shape!! +// This isn't on purpose (besides that they *are* both supposed to be strings). +// They just don't have any more particular validation, yet. + +const inputDateList = defaultDependency => + input({ + validate: looseArrayOf(isString), + defaultDependency, + }); + +const inputKindList = defaultDependency => + input.staticDependency({ + validate: looseArrayOf(isString), + defaultDependency: defaultDependency, + }); + +export default templateCompositeFrom({ + annotation: `processContentEntryDates`, + + inputs: { + annotations: input({ + validate: looseArrayOf(isContentString), + defaultDependency: '#entries.annotation', + }), + + dates: inputDateList('#entries.date'), + secondDates: inputDateList('#entries.secondDate'), + accessDates: inputDateList('#entries.accessDate'), + + dateKinds: inputKindList('#entries.dateKind'), + accessKinds: inputKindList('#entries.accessKind'), + }, + + outputs: ({ + [input.staticDependency('dates')]: dates, + [input.staticDependency('secondDates')]: secondDates, + [input.staticDependency('accessDates')]: accessDates, + [input.staticDependency('dateKinds')]: dateKinds, + [input.staticDependency('accessKinds')]: accessKinds, + }) => [ + dates ?? '#processedContentEntryDates', + secondDates ?? '#processedContentEntrySecondDates', + accessDates ?? '#processedContentEntryAccessDates', + dateKinds ?? '#processedContentEntryDateKinds', + accessKinds ?? '#processedContentEntryAccessKinds', + ], + + steps: () => [ + { + dependencies: [input('annotations')], + compute: (continuation, { + [input('annotations')]: annotations, + }) => continuation({ + ['#webArchiveDates']: + annotations + .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)) + .map(match => match?.[1]) + .map(dateText => + (dateText + ? dateText.slice(0, 4) + '/' + + dateText.slice(4, 6) + '/' + + dateText.slice(6, 8) + : null)), + }), + }, + + { + dependencies: [input('dates')], + compute: (continuation, { + [input('dates')]: dates, + }) => continuation({ + ['#processedContentEntryDates']: + dates + .map(date => date ? new Date(date) : null), + }), + }, + + { + dependencies: [input('secondDates')], + compute: (continuation, { + [input('secondDates')]: secondDates, + }) => continuation({ + ['#processedContentEntrySecondDates']: + secondDates + .map(date => date ? new Date(date) : null), + }), + }, + + fillMissingListItems({ + list: input('dateKinds'), + fill: input.value(null), + }).outputs({ + '#list': '#processedContentEntryDateKinds', + }), + + { + dependencies: [input('accessDates'), '#webArchiveDates'], + compute: (continuation, { + [input('accessDates')]: accessDates, + ['#webArchiveDates']: webArchiveDates, + }) => continuation({ + ['#processedContentEntryAccessDates']: + stitchArrays({ + accessDate: accessDates, + webArchiveDate: webArchiveDates + }).map(({accessDate, webArchiveDate}) => + accessDate ?? + webArchiveDate ?? + null) + .map(date => date ? new Date(date) : date), + }), + }, + + { + dependencies: [input('accessKinds'), '#webArchiveDates'], + compute: (continuation, { + [input('accessKinds')]: accessKinds, + ['#webArchiveDates']: webArchiveDates, + }) => continuation({ + ['#processedContentEntryAccessKinds']: + stitchArrays({ + accessKind: accessKinds, + webArchiveDate: webArchiveDates, + }).map(({accessKind, webArchiveDate}) => + accessKind ?? + (webArchiveDate && 'captured') ?? + null), + }), + }, + + // TODO: Annoying conversion step for outputs, would be nice to avoid. + { + dependencies: [ + '#processedContentEntryDates', + '#processedContentEntrySecondDates', + '#processedContentEntryAccessDates', + '#processedContentEntryDateKinds', + '#processedContentEntryAccessKinds', + input.staticDependency('dates'), + input.staticDependency('secondDates'), + input.staticDependency('accessDates'), + input.staticDependency('dateKinds'), + input.staticDependency('accessKinds'), + ], + + compute: (continuation, { + ['#processedContentEntryDates']: processedContentEntryDates, + ['#processedContentEntrySecondDates']: processedContentEntrySecondDates, + ['#processedContentEntryAccessDates']: processedContentEntryAccessDates, + ['#processedContentEntryDateKinds']: processedContentEntryDateKinds, + ['#processedContentEntryAccessKinds']: processedContentEntryAccessKinds, + [input.staticDependency('dates')]: dates, + [input.staticDependency('secondDates')]: secondDates, + [input.staticDependency('accessDates')]: accessDates, + [input.staticDependency('dateKinds')]: dateKinds, + [input.staticDependency('accessKinds')]: accessKinds, + }) => continuation({ + [dates ?? '#processedContentEntryDates']: + processedContentEntryDates, + + [secondDates ?? '#processedContentEntrySecondDates']: + processedContentEntrySecondDates, + + [accessDates ?? '#processedContentEntryAccessDates']: + processedContentEntryAccessDates, + + [dateKinds ?? '#processedContentEntryDateKinds']: + processedContentEntryDateKinds, + + [accessKinds ?? '#processedContentEntryAccessKinds']: + processedContentEntryAccessKinds, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js index 885ea28d..6794c479 100644 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -11,6 +11,7 @@ import { } from '#composite/data'; import inputSoupyFind from './inputSoupyFind.js'; +import processContentEntryDates from './processContentEntryDates.js'; import withParsedContentEntries from './withParsedContentEntries.js'; import withResolvedReferenceList from './withResolvedReferenceList.js'; @@ -84,81 +85,7 @@ export default templateCompositeFrom({ fill: input.value(null), }), - { - dependencies: ['#entries.annotation'], - compute: (continuation, { - ['#entries.annotation']: annotation, - }) => continuation({ - ['#entries.webArchiveDate']: - annotation - .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)) - .map(match => match?.[1]) - .map(dateText => - (dateText - ? dateText.slice(0, 4) + '/' + - dateText.slice(4, 6) + '/' + - dateText.slice(6, 8) - : null)), - }), - }, - - { - dependencies: ['#entries.date'], - compute: (continuation, { - ['#entries.date']: date, - }) => continuation({ - ['#entries.date']: - date - .map(date => date ? new Date(date) : null), - }), - }, - - { - dependencies: ['#entries.secondDate'], - compute: (continuation, { - ['#entries.secondDate']: secondDate, - }) => continuation({ - ['#entries.secondDate']: - secondDate - .map(date => date ? new Date(date) : null), - }), - }, - - fillMissingListItems({ - list: '#entries.dateKind', - fill: input.value(null), - }), - - { - dependencies: ['#entries.accessDate', '#entries.webArchiveDate'], - compute: (continuation, { - ['#entries.accessDate']: accessDate, - ['#entries.webArchiveDate']: webArchiveDate, - }) => continuation({ - ['#entries.accessDate']: - stitchArrays({accessDate, webArchiveDate}) - .map(({accessDate, webArchiveDate}) => - accessDate ?? - webArchiveDate ?? - null) - .map(date => date ? new Date(date) : date), - }), - }, - - { - dependencies: ['#entries.accessKind', '#entries.webArchiveDate'], - compute: (continuation, { - ['#entries.accessKind']: accessKind, - ['#entries.webArchiveDate']: webArchiveDate, - }) => continuation({ - ['#entries.accessKind']: - stitchArrays({accessKind, webArchiveDate}) - .map(({accessKind, webArchiveDate}) => - accessKind ?? - (webArchiveDate && 'captured') ?? - null), - }), - }, + processContentEntryDates(), { dependencies: [ diff --git a/src/data/composite/wiki-data/withParsedLyricsEntries.js b/src/data/composite/wiki-data/withParsedLyricsEntries.js new file mode 100644 index 00000000..28e4c9b5 --- /dev/null +++ b/src/data/composite/wiki-data/withParsedLyricsEntries.js @@ -0,0 +1,130 @@ +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {stitchArrays} from '#sugar'; +import {isLyrics} from '#validators'; +import {commentaryRegexCaseSensitive} from '#wiki-data'; + +import { + fillMissingListItems, + withFlattenedList, + withPropertiesFromList, + withUnflattenedList, +} from '#composite/data'; + +import processContentEntryDates from './processContentEntryDates.js'; +import withParsedContentEntries from './withParsedContentEntries.js'; +import withResolvedReferenceList from './withResolvedReferenceList.js'; + +export default templateCompositeFrom({ + annotation: `withParsedLyricsEntries`, + + inputs: { + from: input({validate: isLyrics}), + }, + + outputs: ['#parsedLyricsEntries'], + + steps: () => [ + withParsedContentEntries({ + from: input('from'), + caseSensitiveRegex: input.value(commentaryRegexCaseSensitive), + }), + + withPropertiesFromList({ + list: '#parsedContentEntryHeadings', + prefix: input.value('#entries'), + properties: input.value([ + 'artistReferences', + 'artistDisplayText', + 'annotation', + 'date', + 'secondDate', + 'dateKind', + 'accessDate', + 'accessKind', + ]), + }), + + // The artistReferences group will always have a value, since it's required + // for the line to match in the first place. + + { + dependencies: ['#entries.artistReferences'], + compute: (continuation, { + ['#entries.artistReferences']: artistReferenceTexts, + }) => continuation({ + ['#entries.artistReferences']: + artistReferenceTexts + .map(text => text.split(',').map(ref => ref.trim())), + }), + }, + + withFlattenedList({ + list: '#entries.artistReferences', + }), + + withResolvedReferenceList({ + list: '#flattenedList', + data: 'artistData', + find: input.value(find.artist), + notFoundMode: input.value('null'), + }), + + withUnflattenedList({ + list: '#resolvedReferenceList', + }).outputs({ + '#unflattenedList': '#entries.artists', + }), + + fillMissingListItems({ + list: '#entries.artistDisplayText', + fill: input.value(null), + }), + + fillMissingListItems({ + list: '#entries.annotation', + fill: input.value(null), + }), + + processContentEntryDates(), + + { + dependencies: [ + '#entries.artists', + '#entries.artistDisplayText', + '#entries.annotation', + '#entries.date', + '#entries.secondDate', + '#entries.dateKind', + '#entries.accessDate', + '#entries.accessKind', + '#parsedContentEntryBodies', + ], + + compute: (continuation, { + ['#entries.artists']: artists, + ['#entries.artistDisplayText']: artistDisplayText, + ['#entries.annotation']: annotation, + ['#entries.date']: date, + ['#entries.secondDate']: secondDate, + ['#entries.dateKind']: dateKind, + ['#entries.accessDate']: accessDate, + ['#entries.accessKind']: accessKind, + ['#parsedContentEntryBodies']: body, + }) => continuation({ + ['#parsedLyricsEntries']: + stitchArrays({ + artists, + artistDisplayText, + annotation, + date, + secondDate, + dateKind, + accessDate, + accessKind, + body, + }), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js index 9625278d..928bbd1b 100644 --- a/src/data/composite/wiki-properties/commentary.js +++ b/src/data/composite/wiki-properties/commentary.js @@ -12,9 +12,13 @@ export default templateCompositeFrom({ compose: false, + update: { + validate: isCommentary, + }, + steps: () => [ exitWithoutDependency({ - dependency: input.updateValue({validate: isCommentary}), + dependency: input.updateValue(), mode: input.value('falsy'), value: input.value([]), }), diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 06a627ec..892fc44a 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -20,6 +20,7 @@ export {default as duration} from './duration.js'; export {default as externalFunction} from './externalFunction.js'; export {default as fileExtension} from './fileExtension.js'; export {default as flag} from './flag.js'; +export {default as lyrics} from './lyrics.js'; export {default as name} from './name.js'; export {default as referenceList} from './referenceList.js'; export {default as referencedArtworkList} from './referencedArtworkList.js'; diff --git a/src/data/composite/wiki-properties/lyrics.js b/src/data/composite/wiki-properties/lyrics.js new file mode 100644 index 00000000..eb5e524a --- /dev/null +++ b/src/data/composite/wiki-properties/lyrics.js @@ -0,0 +1,36 @@ +// Lyrics! This comes in two styles - "old", where there's just one set of +// lyrics, or the newer/standard one, with multiple sets that are each +// annotated, credited, etc. + +import {input, templateCompositeFrom} from '#composite'; +import {isLyrics} from '#validators'; + +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withParsedLyricsEntries} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `lyrics`, + + compose: false, + + update: { + validate: isLyrics, + }, + + steps: () => [ + exitWithoutDependency({ + dependency: input.updateValue(), + mode: input.value('falsy'), + value: input.value([]), + }), + + withParsedLyricsEntries({ + from: input.updateValue(), + }), + + exposeDependency({ + dependency: '#parsedLyricsEntries', + }), + ], +}); diff --git a/src/data/things/track.js b/src/data/things/track.js index ca1d69af..bcf84aa8 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -46,6 +46,7 @@ import { directory, duration, flag, + lyrics, name, referenceList, referencedArtworkList, @@ -220,7 +221,7 @@ export class Track extends Thing { lyrics: [ inheritFromMainRelease(), - contentString(), + lyrics(), ], additionalFiles: additionalFiles(), diff --git a/src/static/css/site.css b/src/static/css/site.css index ab86915c..6b61af72 100644 --- a/src/static/css/site.css +++ b/src/static/css/site.css @@ -1610,6 +1610,19 @@ p.content-heading:has(+ .commentary-entry-heading.dated) { box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important; } +.lyrics-switcher { + padding-left: 20px; +} + +.lyrics-switcher > span:not(:first-child)::before { + content: "\0020\00b7\0020"; + font-weight: 800; +} + +.lyrics-entry { + padding-left: 40px; +} + .js-hide, .js-show-once-data, .js-hide-once-data { diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js index 81ea3415..b2343f07 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -15,6 +15,7 @@ import * as hoverableTooltipModule from './hoverable-tooltip.js'; import * as imageOverlayModule from './image-overlay.js'; import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js'; import * as liveMousePositionModule from './live-mouse-position.js'; +import * as lyricsSwitcherModule from './lyrics-switcher.js'; import * as quickDescriptionModule from './quick-description.js'; import * as scriptedLinkModule from './scripted-link.js'; import * as sidebarSearchModule from './sidebar-search.js'; @@ -37,6 +38,7 @@ export const modules = [ imageOverlayModule, intrapageDotSwitcherModule, liveMousePositionModule, + lyricsSwitcherModule, quickDescriptionModule, scriptedLinkModule, sidebarSearchModule, diff --git a/src/static/js/client/lyrics-switcher.js b/src/static/js/client/lyrics-switcher.js new file mode 100644 index 00000000..b350ea50 --- /dev/null +++ b/src/static/js/client/lyrics-switcher.js @@ -0,0 +1,70 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'lyricsSwitcherInfo', + + entries: null, + switchLinks: null, + currentLinks: null, +}; + +export function getPageReferences() { + const content = document.getElementById('content'); + + if (!content) return; + + const switcher = content.querySelector('.lyrics-switcher'); + + if (!switcher) return; + + info.entries = + Array.from(content.querySelectorAll('.lyrics-entry')); + + info.currentLinks = + Array.from(switcher.querySelectorAll('a.current')); + + info.switchLinks = + Array.from(switcher.querySelectorAll('a:not(.current)')); +} + +export function addPageListeners() { + if (!info.switchLinks) return; + + for (const {switchLink, entry} of stitchArrays({ + switchLink: info.switchLinks, + entry: info.entries, + })) { + switchLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + showLyricsEntry(entry); + }); + } +} + +function showLyricsEntry(entry) { + const entryToShow = entry; + + stitchArrays({ + entry: info.entries, + currentLink: info.currentLinks, + switchLink: info.switchLinks, + }).forEach(({ + entry, + currentLink, + switchLink, + }) => { + if (entry === entryToShow) { + cssProp(entry, 'display', null); + cssProp(currentLink, 'display', null); + cssProp(switchLink, 'display', 'none'); + } else { + cssProp(entry, 'display', 'none'); + cssProp(currentLink, 'display', 'none'); + cssProp(switchLink, 'display', null); + } + }); +} diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 7d50dbb3..7a40bd0d 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -286,7 +286,12 @@ releaseInfo: duration: "Duration: {DURATION}." contributors: "Contributors:" - lyrics: "Lyrics:" + + lyrics: + _: "Lyrics:" + + switcher: "({ENTRIES})" + note: "Context notes:" alsoReleasedOn: "Also released on {ALBUMS}." diff --git a/src/validators.js b/src/validators.js index 9b34cc04..5300d4ad 100644 --- a/src/validators.js +++ b/src/validators.js @@ -368,9 +368,28 @@ export const isCommentary = caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot, }); +export function isOldStyleLyrics(content) { + isContentString(content); + + if (/^<i>/m.test(content)) { + throw new TypeError( + `Expected old-style lyrics block not to include <i> at start of any line`); + } + return true; } +export const isLyrics = + anyOf( + isOldStyleLyrics, + validateContentEntries({ + headingPhrase: `lyrics heading`, + entryPhrase: `lyrics entry`, + + caseInsensitiveRegex: commentaryRegexCaseInsensitive, + caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot, + })); + const isArtistRef = validateReference('artist'); export function validateProperties(spec) { |