diff options
Diffstat (limited to 'src')
34 files changed, 853 insertions, 460 deletions
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js index 4bbef8ab..a4c6b3bd 100644 --- a/src/common-util/wiki-data.js +++ b/src/common-util/wiki-data.js @@ -102,6 +102,36 @@ export const commentaryRegexCaseSensitive = export const commentaryRegexCaseSensitiveOneShot = new RegExp(commentaryRegexRaw); +// The #validators function isOldStyleLyrics() describes +// what this regular expression detects against. +export const multipleLyricsDetectionRegex = + /^<i>.*:<\/i>/m; + +export function matchContentEntries(sourceText) { + const matchEntries = []; + + let previousMatchEntry = null; + let previousEndIndex = null; + + for (const {0: matchText, index: startIndex, groups: matchEntry} + of sourceText.matchAll(commentaryRegexCaseSensitive)) { + if (previousMatchEntry) { + previousMatchEntry.body = sourceText.slice(previousEndIndex, startIndex); + } + + matchEntries.push(matchEntry); + + previousMatchEntry = matchEntry; + previousEndIndex = startIndex + matchText.length; + } + + if (previousMatchEntry) { + previousMatchEntry.body = sourceText.slice(previousEndIndex); + } + + return matchEntries; +} + export function filterAlbumsByCommentary(albums) { return albums .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js index ad02e180..e28a3fd0 100644 --- a/src/content/dependencies/generateAlbumSocialEmbed.js +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -32,8 +32,7 @@ export default { data.hasImage = album.hasCoverArt; if (data.hasImage) { - data.coverArtDirectory = album.directory; - data.coverArtFileExtension = album.coverArtFileExtension; + data.imagePath = album.coverArtworks[0].path; } data.albumName = album.name; @@ -65,7 +64,7 @@ export default { imagePath: (data.hasImage - ? ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension] + ? data.imagePath : null), })), }; diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index c93020f3..4cb618e3 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -98,8 +98,6 @@ export default { return language.$(workingCapsule, workingOptions); })), - - relations.date, ])), html.tag('blockquote', {class: 'commentary-entry-body'}, @@ -107,6 +105,10 @@ export default { relations.colorStyle.clone() .slot('color', slots.color), - relations.bodyContent.slot('mode', 'multiline')), + [ + relations.date, + + relations.bodyContent.slot('mode', 'multiline'), + ]), ])), }; diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js index 6cb529b1..08a01cfe 100644 --- a/src/content/dependencies/generateCoverArtworkOriginDetails.js +++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js @@ -28,7 +28,7 @@ export default { : null), datetimestamp: - (artwork.date !== artwork.thing.date + (artwork.date && artwork.date !== artwork.thing.date ? relation('generateAbsoluteDatetimestamp', artwork.date) : null), }), diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js index 3f300676..1d58367d 100644 --- a/src/content/dependencies/generateIntrapageDotSwitcher.js +++ b/src/content/dependencies/generateIntrapageDotSwitcher.js @@ -42,6 +42,8 @@ export default { }).map(({title, targetID}) => html.tag('a', {href: '#'}, {'data-target-id': targetID}, + {[html.onlyIfContent]: true}, + language.sanitize(title))), }), }; 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..f6b719a9 --- /dev/null +++ b/src/content/dependencies/generateLyricsSection.js @@ -0,0 +1,81 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateIntrapageDotSwitcher', + 'generateLyricsEntry', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + switcher: + relation('generateIntrapageDotSwitcher'), + + entries: + entries + .map(entry => relation('generateLyricsEntry', entry)), + + annotations: + entries + .map(entry => entry.annotation) + .map(annotation => relation('transformContent', annotation)), + }), + + data: (entries) => ({ + ids: + Array.from( + {length: entries.length}, + (_, index) => 'lyrics-entry-' + index), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo.lyrics', capsule => + html.tags([ + relations.heading + .slots({ + attributes: {id: 'lyrics'}, + title: language.$(capsule), + }), + + html.tag('p', {class: 'lyrics-switcher'}, + {[html.onlyIfContent]: true}, + + language.$(capsule, 'switcher', { + [language.onlyIfOptions]: ['entries'], + + entries: + relations.switcher.slots({ + initialOptionIndex: 0, + + titles: + relations.annotations.map(annotation => + annotation.slots({ + mode: 'inline', + textOnly: true, + })), + + targetIDs: + data.ids, + }), + })), + + stitchArrays({ + entry: relations.entries, + id: data.ids, + }).map(({entry, id}, index) => + entry.slots({ + attributes: [ + {id}, + + index >= 1 && + {style: 'display: none'}, + ], + })), + ])), +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 070c7c82..0acf401c 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -583,6 +583,11 @@ export default { ` background-image: url("${to('media.path', 'bg.jpg')}");\n` + `}`); + const goshFrigginDarnitStyleRule = + `.image-media-link::after {\n` + + ` mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` + + `}`; + const numWallpaperParts = html.resolve(slots.styleRules, {normalize: 'string'}) .match(/\.wallpaper-part:nth-child/g) @@ -733,6 +738,7 @@ export default { .slot('color', slots.color ?? data.wikiColor), fallbackBackgroundStyleRule, + goshFrigginDarnitStyleRule, slots.styleRules, ]), diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 7d531124..11d179ad 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,7 @@ export default { relations.flashesThatFeatureList, ]), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'lyrics'}, - title: language.$('releaseInfo.lyrics'), - }), - - html.tag('blockquote', - {[html.onlyIfContent]: true}, - relations.lyrics.slot('mode', 'lyrics')), - ]), + relations.lyricsSection, html.tags([ relations.contentHeading.clone() diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js index a13a76f0..e6ab9d7d 100644 --- a/src/content/dependencies/listTracksWithLyrics.js +++ b/src/content/dependencies/listTracksWithLyrics.js @@ -2,7 +2,7 @@ export default { contentDependencies: ['listTracksWithExtra'], relations: (relation, spec) => - ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}), + ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}), generate: (relations) => relations.page, 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/checks.js b/src/data/checks.js index b11b5d55..52024144 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -9,7 +9,6 @@ import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline} from '#sugar'; import Thing from '#thing'; import thingConstructors from '#things'; -import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data'; import { annotateErrorWithIndex, @@ -185,7 +184,8 @@ export function filterReferenceErrors(wikiData, { groups: 'group', artTags: '_artTag', referencedArtworks: '_artwork', - commentary: '_commentary', + commentary: '_content', + creditSources: '_content', }], ['artTagData', { @@ -193,7 +193,8 @@ export function filterReferenceErrors(wikiData, { }], ['flashData', { - commentary: '_commentary', + commentary: '_content', + creditSources: '_content', }], ['groupCategoryData', { @@ -233,7 +234,9 @@ export function filterReferenceErrors(wikiData, { artTags: '_artTag', referencedArtworks: '_artwork', mainReleaseTrack: '_trackMainReleasesOnly', - commentary: '_commentary', + commentary: '_content', + creditSources: '_content', + lyrics: '_content', }], ['wikiInfo', { @@ -268,12 +271,12 @@ export function filterReferenceErrors(wikiData, { let writeProperty = true; switch (findFnKey) { - case '_commentary': + case '_content': if (value) { value = - Array.from(value.matchAll(commentaryRegexCaseSensitive)) - .map(({groups}) => groups.artistReferences) - .map(text => text.split(',').map(text => text.trim())); + value.map(entry => + CacheableObject.getUpdateValue(entry, 'artists') ?? + []); } writeProperty = false; @@ -329,7 +332,7 @@ export function filterReferenceErrors(wikiData, { findFn = boundFind.artTag; break; - case '_commentary': + case '_content': findFn = findArtistOrAlias; break; @@ -461,7 +464,7 @@ export function filterReferenceErrors(wikiData, { } } - if (findFnKey === '_commentary') { + if (findFnKey === '_content') { filter( value, {message: errorMessage}, decorateErrorWithIndex(refs => @@ -568,6 +571,12 @@ export function reportContentTextErrors(wikiData, { annotation: 'commentary annotation', }; + const lyricsShape = { + body: 'lyrics body', + artistDisplayText: 'lyrics artist display text', + annotation: 'lyrics annotation', + }; + const contentTextSpec = [ ['albumData', { additionalFiles: additionalFileShape, @@ -614,7 +623,7 @@ export function reportContentTextErrors(wikiData, { additionalFiles: additionalFileShape, commentary: commentaryShape, creditSources: commentaryShape, - lyrics: '_content', + lyrics: lyricsShape, midiProjectFiles: additionalFileShape, sheetMusicFiles: additionalFileShape, }], @@ -737,8 +746,8 @@ export function reportContentTextErrors(wikiData, { for (const thing of things) { nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => { - for (const [property, shape] of Object.entries(propSpec)) { - const value = thing[property]; + for (let [property, shape] of Object.entries(propSpec)) { + let value = thing[property]; if (value === undefined) { push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js index 8b5098f0..dfc6864f 100644 --- a/src/data/composite/things/album/index.js +++ b/src/data/composite/things/album/index.js @@ -1 +1,2 @@ +export {default as withHasCoverArt} from './withHasCoverArt.js'; export {default as withTracks} from './withTracks.js'; diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js new file mode 100644 index 00000000..fd3f2894 --- /dev/null +++ b/src/data/composite/things/album/withHasCoverArt.js @@ -0,0 +1,64 @@ +// TODO: This shouldn't be coded as an Album-specific thing, +// or even really to do with cover artworks in particular, either. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: 'withHasCoverArt', + + outputs: ['#hasCoverArt'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: 'coverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? continuation.raiseOutput({ + ['#hasCoverArt']: true, + }) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'coverArtworks', + mode: input.value('empty'), + output: input.value({'#hasCoverArt': false}), + }), + + withPropertyFromList({ + list: 'coverArtworks', + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#coverArtworks.artistContribs', + fill: input.value([]), + }), + + withFlattenedList({ + list: '#coverArtworks.artistContribs', + }), + + withResultOfAvailabilityCheck({ + from: '#flattenedList', + mode: input.value('empty'), + }).outputs({ + '#availability': '#hasCoverArt', + }), + ], +}); diff --git a/src/data/composite/things/commentary-entry/index.js b/src/data/composite/things/commentary-entry/index.js new file mode 100644 index 00000000..091bae1a --- /dev/null +++ b/src/data/composite/things/commentary-entry/index.js @@ -0,0 +1 @@ +export {default as withWebArchiveDate} from './withWebArchiveDate.js'; diff --git a/src/data/composite/things/commentary-entry/withWebArchiveDate.js b/src/data/composite/things/commentary-entry/withWebArchiveDate.js new file mode 100644 index 00000000..3aaa4f64 --- /dev/null +++ b/src/data/composite/things/commentary-entry/withWebArchiveDate.js @@ -0,0 +1,41 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `withWebArchiveDate`, + + outputs: ['#webArchiveDate'], + + steps: () => [ + { + dependencies: ['annotation'], + + compute: (continuation, {annotation}) => + continuation({ + ['#dateText']: + annotation + ?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//) + ?.[1] ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#dateText', + output: input.value({['#webArchiveDate']: null}), + }), + + { + dependencies: ['#dateText'], + compute: (continuation, {['#dateText']: dateText}) => + continuation({ + ['#webArchiveDate']: + new Date( + dateText.slice(0, 4) + '/' + + dateText.slice(4, 6) + '/' + + dateText.slice(6, 8)), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js index af68073e..85d3b92a 100644 --- a/src/data/composite/things/track/withHasUniqueCoverArt.js +++ b/src/data/composite/things/track/withHasUniqueCoverArt.js @@ -15,7 +15,8 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} from '#composite/control-flow'; -import {withFlattenedList, withPropertyFromList} from '#composite/data'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; import withPropertyFromAlbum from './withPropertyFromAlbum.js'; @@ -86,6 +87,13 @@ export default templateCompositeFrom({ internal: input.value(true), }), + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#trackArtworks.artistContribs', + fill: input.value([]), + }), + withFlattenedList({ list: '#trackArtworks.artistContribs', }), @@ -93,17 +101,8 @@ export default templateCompositeFrom({ withResultOfAvailabilityCheck({ from: '#flattenedList', mode: input.value('empty'), + }).outputs({ + '#availability': '#hasUniqueCoverArt', }), - - { - dependencies: ['#availability'], - compute: (continuation, { - ['#availability']: availability, - }) => - continuation({ - ['#hasUniqueCoverArt']: - availability, - }), - }, ], }); diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index ee7411f2..005c68c0 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -16,7 +16,6 @@ export {default as withConstitutedArtwork} from './withConstitutedArtwork.js'; export {default as withContributionListSums} from './withContributionListSums.js'; export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; -export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.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/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js deleted file mode 100644 index 9bf4278c..00000000 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ /dev/null @@ -1,260 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {stitchArrays} from '#sugar'; -import {isCommentary} from '#validators'; -import {commentaryRegexCaseSensitive} from '#wiki-data'; - -import { - fillMissingListItems, - withFlattenedList, - withPropertiesFromList, - withUnflattenedList, -} from '#composite/data'; - -import inputSoupyFind from './inputSoupyFind.js'; -import withResolvedReferenceList from './withResolvedReferenceList.js'; - -export default templateCompositeFrom({ - annotation: `withParsedCommentaryEntries`, - - inputs: { - from: input({validate: isCommentary}), - }, - - outputs: ['#parsedCommentaryEntries'], - - steps: () => [ - { - dependencies: [input('from')], - - compute: (continuation, { - [input('from')]: commentaryText, - }) => continuation({ - ['#rawMatches']: - Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)), - }), - }, - - withPropertiesFromList({ - list: '#rawMatches', - properties: input.value([ - '0', // The entire match as a string. - 'groups', - 'index', - ]), - }).outputs({ - '#rawMatches.0': '#rawMatches.text', - '#rawMatches.groups': '#rawMatches.groups', - '#rawMatches.index': '#rawMatches.startIndex', - }), - - { - dependencies: [ - '#rawMatches.text', - '#rawMatches.startIndex', - ], - - compute: (continuation, { - ['#rawMatches.text']: text, - ['#rawMatches.startIndex']: startIndex, - }) => continuation({ - ['#rawMatches.endIndex']: - stitchArrays({text, startIndex}) - .map(({text, startIndex}) => startIndex + text.length), - }), - }, - - { - dependencies: [ - input('from'), - '#rawMatches.startIndex', - '#rawMatches.endIndex', - ], - - compute: (continuation, { - [input('from')]: commentaryText, - ['#rawMatches.startIndex']: startIndex, - ['#rawMatches.endIndex']: endIndex, - }) => continuation({ - ['#entries.body']: - stitchArrays({startIndex, endIndex}) - .map(({endIndex}, index, stitched) => - (index === stitched.length - 1 - ? commentaryText.slice(endIndex) - : commentaryText.slice( - endIndex, - stitched[index + 1].startIndex))) - .map(body => body.trim()), - }), - }, - - withPropertiesFromList({ - list: '#rawMatches.groups', - prefix: input.value('#entries'), - properties: input.value([ - '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', - find: inputSoupyFind.input('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), - }), - - { - 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), - }), - }, - - { - dependencies: [ - '#entries.artists', - '#entries.artistDisplayText', - '#entries.annotation', - '#entries.date', - '#entries.secondDate', - '#entries.dateKind', - '#entries.accessDate', - '#entries.accessKind', - '#entries.body', - ], - - 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, - ['#entries.body']: body, - }) => continuation({ - ['#parsedCommentaryEntries']: - 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 deleted file mode 100644 index 9625278d..00000000 --- a/src/data/composite/wiki-properties/commentary.js +++ /dev/null @@ -1,30 +0,0 @@ -// Artist commentary! Generally present on tracks and albums. - -import {input, templateCompositeFrom} from '#composite'; -import {isCommentary} from '#validators'; - -import {exitWithoutDependency, exposeDependency} - from '#composite/control-flow'; -import {withParsedCommentaryEntries} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `commentary`, - - compose: false, - - steps: () => [ - exitWithoutDependency({ - dependency: input.updateValue({validate: isCommentary}), - mode: input.value('falsy'), - value: input.value([]), - }), - - withParsedCommentaryEntries({ - from: input.updateValue(), - }), - - exposeDependency({ - dependency: '#parsedCommentaryEntries', - }), - ], -}); diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index c5c14769..54d3e1a5 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -7,7 +7,6 @@ import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly} from '#composite/data'; -import {withParsedCommentaryEntries} from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `commentatorArtists`, @@ -21,19 +20,13 @@ export default templateCompositeFrom({ value: input.value([]), }), - withParsedCommentaryEntries({ - from: 'commentary', - }), - withPropertyFromList({ - list: '#parsedCommentaryEntries', + list: 'commentary', property: input.value('artists'), - }).outputs({ - '#parsedCommentaryEntries.artists': '#artistLists', }), withFlattenedList({ - list: '#artistLists', + list: '#commentary.artists', }).outputs({ '#flattenedList': '#artists', }), diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 06a627ec..d5e7657e 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -7,7 +7,6 @@ export {default as additionalFiles} from './additionalFiles.js'; export {default as additionalNameList} from './additionalNameList.js'; export {default as annotatedReferenceList} from './annotatedReferenceList.js'; export {default as color} from './color.js'; -export {default as commentary} from './commentary.js'; export {default as commentatorArtists} from './commentatorArtists.js'; export {default as constitutibleArtwork} from './constitutibleArtwork.js'; export {default as constitutibleArtworkList} from './constitutibleArtworkList.js'; diff --git a/src/data/things/album.js b/src/data/things/album.js index e8106e24..8a25a8ac 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -3,6 +3,7 @@ export const DATA_ALBUM_DIRECTORY = 'album'; import * as path from 'node:path'; import {inspect} from 'node:util'; +import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; import {traverse} from '#node-utils'; @@ -16,7 +17,9 @@ import { parseAdditionalNames, parseAnnotatedReferences, parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, parseWallpaperParts, @@ -32,7 +35,6 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate} import { additionalFiles, additionalNameList, - commentary, color, commentatorArtists, constitutibleArtwork, @@ -59,7 +61,7 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withTracks} from '#composite/things/album'; +import {withHasCoverArt, withTracks} from '#composite/things/album'; import {withAlbum, withContinueCountingFrom, withStartCountingFrom} from '#composite/things/track-section'; @@ -69,6 +71,8 @@ export class Album extends Thing { static [Thing.getPropertyDescriptors] = ({ ArtTag, Artwork, + CommentaryEntry, + CreditingSourcesEntry, Group, Track, TrackSection, @@ -188,9 +192,11 @@ export class Album extends Thing { ], coverArtworks: [ + withHasCoverArt(), + exitWithoutDependency({ - dependency: 'coverArtistContribs', - mode: input.value('empty'), + dependency: '#hasCoverArt', + mode: input.value('falsy'), value: input.value([]), }), @@ -202,8 +208,14 @@ export class Album extends Thing { isListedOnHomepage: flag(true), isListedInGalleries: flag(true), - commentary: commentary(), - creditSources: commentary(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + additionalFiles: additionalFiles(), trackSections: thingList({ @@ -297,7 +309,11 @@ export class Album extends Thing { commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasCoverArt: [ + withHasCoverArt(), + exposeDependency({dependency: '#hasCoverArt'}), + ], + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), @@ -590,8 +606,15 @@ export class Album extends Thing { transform: parseDimensions, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Credit Sources': { + property: 'creditSources', + transform: parseCreditingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -662,7 +685,11 @@ export class Album extends Thing { const albumData = []; const trackSectionData = []; const trackData = []; + const artworkData = []; + const commentaryData = []; + const creditingSourceData = []; + const lyricsData = []; for (const {header: album, entries} of results) { const trackSections = []; @@ -709,6 +736,13 @@ export class Album extends Thing { entry.album = album; artworkData.push(...entry.trackArtworks); + commentaryData.push(...entry.commentary); + creditingSourceData.push(...entry.creditSources); + + // TODO: As exposed, Track.lyrics tries to inherit from the main + // release, which is impossible before the data's been linked. + // We just use the update value here. But it's icky! + lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []); } closeCurrentTrackSection(); @@ -725,6 +759,9 @@ export class Album extends Thing { artworkData.push(album.wallpaperArtwork); } + commentaryData.push(...album.commentary); + creditingSourceData.push(...album.creditSources); + album.trackSections = trackSections; } @@ -732,7 +769,11 @@ export class Album extends Thing { albumData, trackSectionData, trackData, + artworkData, + commentaryData, + creditingSourceData, + lyricsData, }; }, @@ -763,12 +804,18 @@ export class Album extends Thing { ]; } + // TODO: using trackCover here is obviously, badly wrong + // but we ought to refactor banners and wallpapers similarly + // (i.e. depend on those intrinsic artwork paths rather than + // accessing media.{albumBanner,albumWallpaper} from content + // or other code directly) return [ - 'media.albumCover', + 'media.trackCover', + this.directory, (artwork.unqualifiedDirectory - ? this.directory + '-' + artwork.unqualifiedDirectory - : this.directory), + ? 'cover-' + artwork.unqualifiedDirectory + : 'cover'), artwork.fileExtension, ]; diff --git a/src/data/things/content.js b/src/data/things/content.js new file mode 100644 index 00000000..7f352795 --- /dev/null +++ b/src/data/things/content.js @@ -0,0 +1,122 @@ +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; +import {is, isDate} from '#validators'; +import {parseDate} from '#yaml'; + +import {contentString, referenceList, simpleDate, soupyFind, thing} + from '#composite/wiki-properties'; + +import { + exposeConstant, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import {withWebArchiveDate} from '#composite/things/commentary-entry'; + +export class ContentEntry extends Thing { + static [Thing.getPropertyDescriptors] = ({Artist}) => ({ + // Update & expose + + thing: thing(), + + artists: referenceList({ + class: input.value(Artist), + find: soupyFind.input('artist'), + }), + + artistText: contentString(), + + annotation: contentString(), + + dateKind: { + flags: {update: true, expose: true}, + + update: { + validate: is(...[ + 'sometime', + 'throughout', + 'around', + ]), + }, + }, + + accessKind: [ + exposeUpdateValueOrContinue({ + validate: input.value( + is(...[ + 'captured', + 'accessed', + ])), + }), + + withWebArchiveDate(), + + withResultOfAvailabilityCheck({ + from: '#webArchiveDate', + }), + + { + dependencies: ['#availability'], + compute: (continuation, {['#availability']: availability}) => + (availability + ? continuation.exit('captured') + : continuation()), + }, + + exposeConstant({ + value: input.value(null), + }), + ], + + date: simpleDate(), + + secondDate: simpleDate(), + + accessDate: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withWebArchiveDate(), + + exposeDependencyOrContinue({ + dependency: '#webArchiveDate', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + body: contentString(), + + // Update only + + find: soupyFind(), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Artists': {property: 'artists'}, + 'Artist Text': {property: 'artistText'}, + + 'Annotation': {property: 'annotation'}, + + 'Date Kind': {property: 'dateKind'}, + 'Access Kind': {property: 'accessKind'}, + + 'Date': {property: 'date', transform: parseDate}, + 'Second Date': {property: 'secondDate', transform: parseDate}, + 'Access Date': {property: 'accessDate', transform: parseDate}, + + 'Body': {property: 'body'}, + }, + }; +} + +export class CommentaryEntry extends ContentEntry {} +export class LyricsEntry extends ContentEntry {} +export class CreditingSourcesEntry extends ContentEntry {} diff --git a/src/data/things/flash.js b/src/data/things/flash.js index ace18af9..dac674dd 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -10,7 +10,9 @@ import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} import { parseArtwork, parseAdditionalNames, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, } from '#yaml'; @@ -27,7 +29,6 @@ import { import { additionalNameList, color, - commentary, commentatorArtists, constitutibleArtwork, contentString, @@ -41,6 +42,7 @@ import { soupyFind, soupyReverse, thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; @@ -52,6 +54,8 @@ export class Flash extends Thing { static [Thing.referenceType] = 'flash'; static [Thing.getPropertyDescriptors] = ({ + CommentaryEntry, + CreditingSourcesEntry, Track, FlashAct, WikiInfo, @@ -125,8 +129,13 @@ export class Flash extends Thing { additionalNames: additionalNameList(), - commentary: commentary(), - creditSources: commentary(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), // Update only @@ -240,8 +249,15 @@ export class Flash extends Thing { transform: parseContributors, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Credit Sources': { + property: 'creditSources', + transform: parseCreditingSources, + }, 'Review Points': {ignore: true}, }, @@ -441,8 +457,18 @@ export class FlashSide extends Thing { const flashSideData = results.filter(x => x instanceof FlashSide); const artworkData = flashData.map(flash => flash.coverArtwork); - - return {flashData, flashActData, flashSideData, artworkData}; + const commentaryData = flashData.flatMap(flash => flash.commentary); + const creditingSourceData = flashData.flatMap(flash => flash.creditSources); + + return { + flashData, + flashActData, + flashSideData, + + artworkData, + commentaryData, + creditingSourceData, + }; }, sort({flashData}) { diff --git a/src/data/things/index.js b/src/data/things/index.js index 96cec88e..b832ab75 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -13,6 +13,7 @@ import * as albumClasses from './album.js'; import * as artTagClasses from './art-tag.js'; import * as artistClasses from './artist.js'; import * as artworkClasses from './artwork.js'; +import * as contentClasses from './content.js'; import * as contributionClasses from './contribution.js'; import * as flashClasses from './flash.js'; import * as groupClasses from './group.js'; @@ -29,6 +30,7 @@ const allClassLists = { 'art-tag.js': artTagClasses, 'artist.js': artistClasses, 'artwork.js': artworkClasses, + 'content.js': contentClasses, 'contribution.js': contributionClasses, 'flash.js': flashClasses, 'group.js': groupClasses, diff --git a/src/data/things/track.js b/src/data/things/track.js index ca1d69af..ae7be170 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -12,10 +12,13 @@ import { parseAdditionalNames, parseAnnotatedReferences, parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, parseDuration, + parseLyrics, } from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -37,7 +40,6 @@ import { import { additionalFiles, additionalNameList, - commentary, commentatorArtists, constitutibleArtworkList, contentString, @@ -56,6 +58,7 @@ import { soupyFind, soupyReverse, thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; @@ -86,7 +89,10 @@ export class Track extends Thing { Album, ArtTag, Artwork, + CommentaryEntry, + CreditingSourcesEntry, Flash, + LyricsEntry, TrackSection, WikiInfo, }) => ({ @@ -215,12 +221,23 @@ export class Track extends Thing { dimensions(), ], - commentary: commentary(), - creditSources: commentary(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), lyrics: [ + // TODO: Inherited lyrics are literally the same objects, so of course + // their .thing properties aren't going to point back to this one, and + // certainly couldn't be recontextualized... inheritFromMainRelease(), - contentString(), + + thingList({ + class: input.value(LyricsEntry), + }), ], additionalFiles: additionalFiles(), @@ -480,9 +497,20 @@ export class Track extends Thing { 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Lyrics': {property: 'lyrics'}, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Credit Sources': { + property: 'creditSources', + transform: parseCreditingSources, + }, 'Additional Files': { property: 'additionalFiles', diff --git a/src/data/yaml.js b/src/data/yaml.js index 50317238..79602faa 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -11,6 +11,7 @@ import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import {sortByName} from '#sort'; import Thing from '#thing'; import thingConstructors from '#things'; +import {matchContentEntries, multipleLyricsDetectionRegex} from '#wiki-data'; import { aggregateThrows, @@ -824,6 +825,73 @@ export function parseArtwork({ return transform; } +export function parseContentEntries(thingClass, sourceText, {subdoc}) { + const map = matchEntry => ({ + 'Artists': + matchEntry.artistReferences + .split(',') + .map(ref => ref.trim()), + + 'Artist Text': + matchEntry.artistDisplayText, + + 'Annotation': + matchEntry.annotation, + + 'Date': + matchEntry.date, + + 'Second Date': + matchEntry.secondDate, + + 'Date Kind': + matchEntry.dateKind, + + 'Access Date': + matchEntry.accessDate, + + 'Access Kind': + matchEntry.accessKind, + + 'Body': + matchEntry.body, + }); + + const documents = + matchContentEntries(sourceText) + .map(matchEntry => + withEntries( + map(matchEntry), + entries => entries + .filter(([key, value]) => + value !== undefined && + value !== null))); + + const subdocs = + documents.map(document => + subdoc(thingClass, document, {bindInto: 'thing'})); + + return subdocs; +} + +export function parseCommentary(sourceText, {subdoc, CommentaryEntry}) { + return parseContentEntries(CommentaryEntry, sourceText, {subdoc}); +} + +export function parseCreditingSources(sourceText, {subdoc, CreditingSourcesEntry}) { + return parseContentEntries(CreditingSourcesEntry, sourceText, {subdoc}); +} + +export function parseLyrics(sourceText, {subdoc, LyricsEntry}) { + if (!multipleLyricsDetectionRegex.test(sourceText)) { + const document = {'Body': sourceText}; + + return [subdoc(LyricsEntry, document, {bindInto: 'thing'})]; + } + + return parseContentEntries(LyricsEntry, sourceText, {subdoc}); +} + // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { @@ -1499,6 +1567,10 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { ['artworkData', ['artworkData']], + ['commentaryData', [/* find */]], + + ['creditingSourceData', [/* find */]], + ['flashData', [ 'wikiInfo', ]], @@ -1513,6 +1585,8 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { ['homepageLayout.sections.rows', [/* find */]], + ['lyricsData', [/* find */]], + ['trackData', [ 'artworkData', 'trackData', @@ -1781,14 +1855,16 @@ export function flattenThingLayoutToDocumentOrder(layout) { } export function* splitDocumentsInYAMLSourceText(sourceText) { - const dividerRegex = /^-{3,}\n?/gm; + // Not multiline! + const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g; + let previousDivider = ''; while (true) { const {lastIndex} = dividerRegex; const match = dividerRegex.exec(sourceText); if (match) { - const nextDivider = match[0].trim(); + const nextDivider = match[0]; yield { previousDivider, @@ -1799,11 +1875,12 @@ export function* splitDocumentsInYAMLSourceText(sourceText) { previousDivider = nextDivider; } else { const nextDivider = ''; + const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? ''; yield { previousDivider, nextDivider, - text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, '\n'), + text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak), }; return; @@ -1829,7 +1906,7 @@ export function recombineDocumentsIntoYAMLSourceText(documents) { for (const document of documents) { if (sourceText) { - sourceText += divider + '\n'; + sourceText += divider; } sourceText += document.text; diff --git a/src/static/css/site.css b/src/static/css/site.css index ab86915c..0a7e36ae 100644 --- a/src/static/css/site.css +++ b/src/static/css/site.css @@ -931,7 +931,11 @@ a .normal-content { background-color: var(--primary-color); - mask-image: url(/static-4p1/misc/image.svg); + /* mask-image is set in content JavaScript, + * because we can't identify the correct nor + * absolute path to the file from CSS. + */ + mask-repeat: no-repeat; mask-position: calc(100% - 2px); vertical-align: text-bottom; @@ -1119,7 +1123,7 @@ a .normal-content { font-size: 0.9rem; } -li:not(:first-child:last-child) .tooltip, +li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), .offset-tooltips > :not(:first-child:last-child) .tooltip { left: 14px; } @@ -1161,7 +1165,7 @@ li:not(:first-child:last-child) .tooltip, .thing-name-tooltip, .wiki-edits-tooltip { padding: 3px 4px 2px 2px; - left: -6px !important; + left: -6px; } .thing-name-tooltip .tooltip-content, @@ -1169,11 +1173,15 @@ li:not(:first-child:last-child) .tooltip, font-size: 0.85em; } -/* Terrifying? - * https://stackoverflow.com/a/64424759/4633828 - */ -.thing-name-tooltip { margin-right: -120px; } -.wiki-edits-tooltip { margin-right: -200px; } +.thing-name-tooltip .tooltip-content { + width: max-content; + max-width: 120px; +} + +.wiki-edits-tooltip .tooltip-content { + width: max-content; + max-width: 200px; +} .contribution-tooltip .tooltip-content { padding: 6px 2px 2px 2px; @@ -1548,14 +1556,13 @@ p.image-details.origin-details { margin: 0; } +/* p.content-heading:has(+ .commentary-entry-heading.dated) { clear: right; } +*/ .commentary-entry-heading { - display: flex; - flex-direction: row; - margin-left: 15px; padding-left: 5px; max-width: 625px; @@ -1565,7 +1572,7 @@ p.content-heading:has(+ .commentary-entry-heading.dated) { } .commentary-entry-heading-text { - flex-grow: 1; + display: block; padding-left: 1.25ch; text-indent: -1.25ch; } @@ -1574,20 +1581,6 @@ p.content-heading:has(+ .commentary-entry-heading.dated) { font-style: oblique; } -.commentary-entry-heading .commentary-date { - flex-shrink: 0; - - margin-left: 0.75ch; - align-self: flex-end; - - padding-left: 0.5ch; - padding-right: 0.25ch; -} - -.commentary-entry-heading .hoverable { - box-shadow: 1px 2px 6px 5px #04040460; -} - .commentary-entry-body summary { list-style-position: outside; } @@ -1596,6 +1589,19 @@ p.content-heading:has(+ .commentary-entry-heading.dated) { color: var(--primary-color); } +.commentary-date { + float: right; + margin-top: -0.8em; + margin-left: 0.75ch; + padding-left: 0.5ch; + padding-right: 0.4em; + font-size: 0.9em; +} + +.commentary-date .hoverable { + box-shadow: 1px 2px 6px 5px #04040460; +} + .commentary-art { float: right; width: 30%; @@ -1610,6 +1616,20 @@ 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; + max-width: 600px; +} + .js-hide, .js-show-once-data, .js-hide-once-data { @@ -1834,7 +1854,7 @@ li .by a { display: inline-block; } -p code { +p code, li code { font-size: 0.95em; font-family: "courier new", monospace; font-weight: 800; @@ -3481,12 +3501,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r max-width: 375px; } - html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+10)) { + html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) { flex-basis: 23%; margin: 15px; } - html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+10) { + html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) { flex-basis: 18%; margin: 10px; } diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js index 02d3cd96..b65574d0 100644 --- a/src/static/js/client/sticky-heading.js +++ b/src/static/js/client/sticky-heading.js @@ -89,7 +89,7 @@ export function getPageReferences() { info.contentCovers = info.contentCoverColumns - .map(el => el.querySelector('.cover-artwork')); + .map(el => el ? el.querySelector('.cover-artwork') : null); info.contentCoversReveal = info.contentCovers diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js index cdab2cb8..b00ed98e 100644 --- a/src/static/js/rectangles.js +++ b/src/static/js/rectangles.js @@ -510,4 +510,46 @@ export class WikiRect extends DOMRect { height: this.height, }); } + + // Other utilities + + #display = null; + + display() { + if (!this.#display) { + this.#display = document.createElement('div'); + document.body.appendChild(this.#display); + } + + Object.assign(this.#display.style, { + position: 'fixed', + background: '#000c', + border: '3px solid var(--primary-color)', + borderRadius: '4px', + top: this.top + 'px', + left: this.left + 'px', + width: this.width + 'px', + height: this.height + 'px', + pointerEvents: 'none', + }); + + let i = 0; + const int = setInterval(() => { + i++; + if (i >= 3) clearInterval(int); + if (!this.#display) return; + + this.#display.style.display = 'none'; + setTimeout(() => { + this.#display.style.display = ''; + }, 200); + }, 600); + } + + hide() { + if (this.#display) { + this.#display.remove(); + this.#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/urls-default.yaml b/src/urls-default.yaml index c3bf89eb..74225efd 100644 --- a/src/urls-default.yaml +++ b/src/urls-default.yaml @@ -11,7 +11,7 @@ yamlAliases: # part of a build. This is so that multiple builds of a wiki can coexist # served from the same server / file system root: older builds' HTML files # refer to earlier values of STATIC_VERSION, avoiding name collisions. - - &staticVersion 4p1 + - &staticVersion 5p1 data: prefix: 'data/' diff --git a/src/validators.js b/src/validators.js index 3b23e8f6..5b8227fb 100644 --- a/src/validators.js +++ b/src/validators.js @@ -3,8 +3,12 @@ import {inspect as nodeInspect} from 'node:util'; import {openAggregate, withAggregate} from '#aggregate'; import {colors, ENABLE_COLOR} from '#cli'; import {cut, empty, matchMultiline, typeAppearance} from '#sugar'; -import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot} - from '#wiki-data'; + +import { + commentaryRegexCaseInsensitive, + commentaryRegexCaseSensitiveOneShot, + multipleLyricsDetectionRegex, +} from '#wiki-data'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -288,69 +292,108 @@ export function isColor(color) { throw new TypeError(`Unknown color format`); } -export function isCommentary(commentaryText) { - isContentString(commentaryText); - - const rawMatches = - Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive)); +export function validateContentEntries({ + headingPhrase, + entryPhrase, - if (empty(rawMatches)) { - throw new TypeError(`Expected at least one commentary heading`); - } + caseInsensitiveRegex, + caseSensitiveOneShotRegex, +}) { + return content => { + isContentString(content); - const niceMatches = - rawMatches.map(match => ({ - position: match.index, - length: match[0].length, - })); + const rawMatches = + Array.from(content.matchAll(caseInsensitiveRegex)); - validateArrayItems(({position, length}, index) => { - if (index === 0 && position > 0) { - throw new TypeError(`Expected first commentary heading to be at top`); + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one ${headingPhrase}`); } - const ownInput = commentaryText.slice(position, position + length); - const restOfInput = commentaryText.slice(position + length); + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); - const upToNextLineBreak = - (restOfInput.includes('\n') - ? restOfInput.slice(0, restOfInput.indexOf('\n')) - : restOfInput); + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first ${headingPhrase} to be at top`); + } - if (/\S/.test(upToNextLineBreak)) { - throw new TypeError( - `Expected commentary heading to occupy entire line, got extra text:\n` + - `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + - `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + - `(Check for missing "|-" in YAML, or a misshapen annotation)`); - } + const ownInput = content.slice(position, position + length); + const restOfInput = content.slice(position + length); - if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) { - throw new TypeError( - `Miscapitalization in commentary heading:\n` + - `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + - `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`); - } + const upToNextLineBreak = + (restOfInput.includes('\n') + ? restOfInput.slice(0, restOfInput.indexOf('\n')) + : restOfInput); - const nextHeading = - (index === niceMatches.length - 1 - ? commentaryText.length - : niceMatches[index + 1].position); + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected ${headingPhrase} to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } - const upToNextHeading = - commentaryText.slice(position + length, nextHeading); + if (!caseSensitiveOneShotRegex.test(ownInput)) { + throw new TypeError( + `Miscapitalization in ${headingPhrase}:\n` + + `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + + `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`); + } - if (!/\S/.test(upToNextHeading)) { - throw new TypeError( - `Expected commentary entry to have body text, only got a heading`); - } + const nextHeading = + (index === niceMatches.length - 1 + ? content.length + : niceMatches[index + 1].position); + + const upToNextHeading = + content.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected ${entryPhrase} to have body text, only got a heading`); + } + + return true; + })(niceMatches); return true; - })(niceMatches); + }; +} + +export const isCommentary = + validateContentEntries({ + headingPhrase: `commentary heading`, + entryPhrase: `commentary entry`, + + caseInsensitiveRegex: commentaryRegexCaseInsensitive, + caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot, + }); + +export function isOldStyleLyrics(content) { + isContentString(content); + + if (multipleLyricsDetectionRegex.test(content)) { + throw new TypeError( + `Expected old-style lyrics block not to include "<i> ... :</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) { |