diff options
35 files changed, 1106 insertions, 233 deletions
diff --git a/README.md b/README.md index a7fc5824..2c99e3d8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagini Install dependencies: -- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 16.x LTS should also work +- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work - [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php) Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git: diff --git a/package-lock.json b/package-lock.json index 6433ea16..bca46bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "he": "^1.2.0", "image-size": "^1.0.2", "js-yaml": "^4.1.0", - "marked": "^5.0.2", + "marked": "^10.0.0", + "printable-characters": "^1.0.42", "striptags": "^4.0.0-alpha.4", "word-wrap": "^1.2.3" }, @@ -26,6 +27,9 @@ "chokidar": "^3.5.3", "tap": "^18.4.0", "tcompare": "^6.0.0" + }, + "engines": { + "node": ">= 20.9.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -3303,9 +3307,9 @@ } }, "node_modules/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz", + "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==", "bin": { "marked": "bin/marked.js" }, @@ -3954,6 +3958,11 @@ "node": ">= 0.8.0" } }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -8096,9 +8105,9 @@ } }, "marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==" + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz", + "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==" }, "mimic-fn": { "version": "2.1.0", @@ -8576,6 +8585,11 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, + "printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + }, "prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", diff --git a/package.json b/package.json index abf9e1df..7cd9e0ef 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "eslint src && node src/upd8.js" }, "imports": { + "#cacheable-object": "./src/data/things/cacheable-object.js", "#colors": "./src/util/colors.js", "#composite": "./src/data/things/composite.js", "#composite/control-flow": "./src/data/composite/control-flow/index.js", @@ -42,6 +43,9 @@ "#wiki-data": "./src/util/wiki-data.js", "#yaml": "./src/data/yaml.js" }, + "engines": { + "node": ">= 20.9.0" + }, "dependencies": { "chroma-js": "^2.4.2", "command-exists": "^1.2.9", @@ -49,7 +53,8 @@ "he": "^1.2.0", "image-size": "^1.0.2", "js-yaml": "^4.1.0", - "marked": "^5.0.2", + "marked": "^10.0.0", + "printable-characters": "^1.0.42", "striptags": "^4.0.0-alpha.4", "word-wrap": "^1.2.3" }, diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index e2415516..001003ae 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -6,13 +6,12 @@ export default { 'generateAlbumNavAccent', 'generateAlbumSidebarTrackSection', 'generateAlbumStyleRules', - 'generateColorStyleVariables', + 'generateCommentaryEntry', 'generateContentHeading', 'generateTrackCoverArtwork', 'generatePageLayout', 'linkAlbum', 'linkTrack', - 'transformContent', ], extraDependencies: ['html', 'language'], @@ -38,8 +37,9 @@ export default { relation('generateAlbumCoverArtwork', album); } - relations.albumCommentaryContent = - relation('transformContent', album.commentary); + relations.albumCommentaryEntries = + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)); } const tracksWithCommentary = @@ -61,16 +61,11 @@ export default { ? relation('generateTrackCoverArtwork', track) : null)); - relations.trackCommentaryContent = - tracksWithCommentary - .map(track => relation('transformContent', track.commentary)); - - relations.trackCommentaryColorVariables = + relations.trackCommentaryEntries = tracksWithCommentary .map(track => - (track.color === album.color - ? null - : relation('generateColorStyleVariables'))); + track.commentary + .map(entry => relation('generateCommentaryEntry', entry))); relations.sidebarAlbumLink = relation('linkAlbum', album); @@ -163,10 +158,9 @@ export default { link: relations.trackCommentaryLinks, directory: data.trackCommentaryDirectories, cover: relations.trackCommentaryCovers, - content: relations.trackCommentaryContent, - colorVariables: relations.trackCommentaryColorVariables, + entries: relations.trackCommentaryEntries, color: data.trackCommentaryColors, - }).map(({heading, link, directory, cover, content, colorVariables, color}) => [ + }).map(({heading, link, directory, cover, entries, color}) => [ heading.slots({ tag: 'h3', id: directory, @@ -175,11 +169,7 @@ export default { cover?.slots({mode: 'commentary'}), - html.tag('blockquote', - (color - ? {style: colorVariables.slot('color', color).content} - : {}), - content), + entries.map(entry => entry.slot('color', color)), ]), ], diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index 5fe27caf..90a120ca 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -17,6 +17,7 @@ export default { 'generateAlbumStyleRules', 'generateAlbumTrackList', 'generateChronologyLinks', + 'generateCommentarySection', 'generateContentHeading', 'generatePageLayout', 'linkAlbum', @@ -126,13 +127,8 @@ export default { // Section: Artist commentary if (album.commentary) { - const artistCommentary = sections.artistCommentary = {}; - - artistCommentary.heading = - relation('generateContentHeading'); - - artistCommentary.content = - relation('transformContent', album.commentary); + sections.artistCommentary = + relation('generateCommentarySection', album.commentary); } return relations; @@ -235,17 +231,7 @@ export default { sec.additionalFiles.additionalFilesList, ], - sec.artistCommentary && [ - sec.artistCommentary.heading - .slots({ - id: 'artist-commentary', - title: language.$('releaseInfo.artistCommentary') - }), - - html.tag('blockquote', - sec.artistCommentary.content - .slot('mode', 'multiline')), - ], + sec.artistCommentary, ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js new file mode 100644 index 00000000..0b2b2558 --- /dev/null +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -0,0 +1,99 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleVariables', + 'linkArtist', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + artistLinks: + (!empty(entry.artists) && !entry.artistDisplayText + ? entry.artists + .map(artist => relation('linkArtist', artist)) + : null), + + artistsContent: + (entry.artistDisplayText + ? relation('transformContent', entry.artistDisplayText) + : null), + + annotationContent: + (entry.annotation + ? relation('transformContent', entry.annotation) + : null), + + bodyContent: + (entry.body + ? relation('transformContent', entry.body) + : null), + + colorVariables: + relation('generateColorStyleVariables'), + }), + + data: (entry) => ({ + date: entry.date, + }), + + slots: { + color: {validate: v => v.isColor}, + }, + + generate(data, relations, slots, {html, language}) { + const artistsSpan = + html.tag('span', {class: 'commentary-entry-artists'}, + (relations.artistsContent + ? relations.artistsContent.slot('mode', 'inline') + : relations.artistLinks + ? language.formatConjunctionList(relations.artistLinks) + : language.$('misc.artistCommentary.entry.title.noArtists'))); + + const accentParts = ['misc.artistCommentary.entry.title.accent']; + const accentOptions = {}; + + if (relations.annotationContent) { + accentParts.push('withAnnotation'); + accentOptions.annotation = + relations.annotationContent.slot('mode', 'inline'); + } + + if (data.date) { + accentParts.push('withDate'); + accentOptions.date = + language.formatDate(data.date); + } + + const accent = + (accentParts.length > 1 + ? html.tag('span', {class: 'commentary-entry-accent'}, + language.$(...accentParts, accentOptions)) + : null); + + const titleParts = ['misc.artistCommentary.entry.title']; + const titleOptions = {artists: artistsSpan}; + + if (accent) { + titleParts.push('withAccent'); + titleOptions.accent = accent; + } + + const style = + (slots.color + ? relations.colorVariables + .slot('color', slots.color) + .content + : null); + + return html.tags([ + html.tag('p', {class: 'commentary-entry-heading', style}, + language.$(...titleParts, titleOptions)), + + html.tag('blockquote', {class: 'commentary-entry-body', style}, + relations.bodyContent.slot('mode', 'multiline')), + ]); + }, +}; diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js new file mode 100644 index 00000000..8ae1b2d0 --- /dev/null +++ b/src/content/dependencies/generateCommentarySection.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: [ + 'transformContent', + 'generateCommentaryEntry', + 'generateContentHeading', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + entries: + entries.map(entry => + relation('generateCommentaryEntry', entry)), + }), + + generate: (relations, {html, language}) => + html.tags([ + relations.heading + .slots({ + id: 'artist-commentary', + title: language.$('misc.artistCommentary') + }), + + relations.entries, + ]), +}; diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 180e5c29..2848b15c 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -12,6 +12,7 @@ export default { 'generateAlbumSidebar', 'generateAlbumStyleRules', 'generateChronologyLinks', + 'generateCommentarySection', 'generateContentHeading', 'generateContributionList', 'generatePageLayout', @@ -274,13 +275,8 @@ export default { // Section: Artist commentary if (track.commentary) { - const artistCommentary = sections.artistCommentary = {}; - - artistCommentary.heading = - relation('generateContentHeading'); - - artistCommentary.content = - relation('transformContent', track.commentary); + sections.artistCommentary = + relation('generateCommentarySection', track.commentary); } return relations; @@ -499,17 +495,7 @@ export default { sec.additionalFiles.list, ], - sec.artistCommentary && [ - sec.artistCommentary.heading - .slots({ - id: 'artist-commentary', - title: language.$('releaseInfo.artistCommentary') - }), - - html.tag('blockquote', - sec.artistCommentary.content - .slot('mode', 'multiline')), - ], + sec.artistCommentary, ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index 3c2c3521..7b2d0573 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -1,7 +1,7 @@ import {bindFind} from '#find'; import {parseInput} from '#replacer'; -import {marked} from 'marked'; +import {Marked} from 'marked'; export const replacerSpec = { album: { @@ -147,6 +147,29 @@ const linkIndexRelationMap = { newsIndex: 'linkNewsIndex', }; +const commonMarkedOptions = { + headerIds: false, + mangle: false, +}; + +const multilineMarked = new Marked({ + ...commonMarkedOptions, +}); + +const inlineMarked = new Marked({ + ...commonMarkedOptions, + + renderer: { + paragraph(text) { + return text; + }, + }, +}); + +const lyricsMarked = new Marked({ + ...commonMarkedOptions, +}); + function getPlaceholder(node, content) { return {type: 'text', data: content.slice(node.i, node.iEnd)}; } @@ -447,19 +470,9 @@ export default { return link.data; } - // In inline mode, no further processing is needed! - - if (slots.mode === 'inline') { - return html.tags(contentFromNodes.map(node => node.data)); - } - - // Multiline mode has a secondary processing stage where it's passed... - // through marked! Rolling your own Markdown only gets you so far :D - - const markedOptions = { - headerIds: false, - mangle: false, - }; + // Content always goes through marked (i.e. parsing as Markdown). + // This does require some attention to detail, mostly to do with line + // breaks (in multiline mode) and extracting/re-inserting non-text nodes. // The content of non-text nodes can end up getting mangled by marked. // To avoid this, we replace them with mundane placeholders, then @@ -534,6 +547,16 @@ export default { return html.tags(tags, {[html.joinChildren]: ''}); }; + if (slots.mode === 'inline') { + const markedInput = + extractNonTextNodes(); + + const markedOutput = + inlineMarked.parse(markedInput); + + return reinsertNonTextNodes(markedOutput); + } + // This is separated into its own function just since we're gonna reuse // it in a minute if everything goes to heck in lyrics mode. const transformMultiline = () => { @@ -550,7 +573,7 @@ export default { .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n'); const markedOutput = - marked.parse(markedInput, markedOptions); + multilineMarked.parse(markedInput); return reinsertNonTextNodes(markedOutput); } @@ -600,7 +623,7 @@ export default { }); const markedOutput = - marked.parse(markedInput, markedOptions); + lyricsMarked.parse(markedInput); return reinsertNonTextNodes(markedOutput); } diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js index dfc53db7..7fad88b2 100644 --- a/src/data/composite/control-flow/index.js +++ b/src/data/composite/control-flow/index.js @@ -1,3 +1,8 @@ +// #composite/control-flow +// +// No entries depend on any other entries, except siblings in this directory. +// + export {default as exitWithoutDependency} from './exitWithoutDependency.js'; export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js'; export {default as exposeConstant} from './exposeConstant.js'; diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index ecd05129..e2927afd 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -1,3 +1,8 @@ +// #composite/data +// +// Entries here may depend on entries in #composite/control-flow. +// + export {default as excludeFromList} from './excludeFromList.js'; export {default as fillMissingListItems} from './fillMissingListItems.js'; export {default as withFlattenedList} from './withFlattenedList.js'; @@ -6,3 +11,4 @@ export {default as withPropertiesFromObject} from './withPropertiesFromObject.js export {default as withPropertyFromList} from './withPropertyFromList.js'; export {default as withPropertyFromObject} from './withPropertyFromObject.js'; export {default as withUnflattenedList} from './withUnflattenedList.js'; +export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js'; diff --git a/src/data/composite/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js new file mode 100644 index 00000000..7ee08b08 --- /dev/null +++ b/src/data/composite/data/withUniqueItemsOnly.js @@ -0,0 +1,40 @@ +// Excludes duplicate items from a list and provides the results, overwriting +// the list in-place, if possible. + +import {input, templateCompositeFrom} from '#composite'; +import {unique} from '#sugar'; + +export default templateCompositeFrom({ + annotation: `withUniqueItemsOnly`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list ?? '#uniqueItems'], + + steps: () => [ + { + dependencies: [input('list')], + compute: (continuation, { + [input('list')]: list, + }) => continuation({ + ['#values']: + unique(list), + }), + }, + + { + dependencies: ['#values', input.staticDependency('list')], + compute: (continuation, { + '#values': values, + [input.staticDependency('list')]: list, + }) => continuation({ + [list ?? '#uniqueItems']: + values, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 1d0400fc..df50a2db 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -1,6 +1,13 @@ +// #composite/wiki-data +// +// Entries here may depend on entries in #composite/control-flow and in +// #composite/data. +// + export {default as exitWithoutContribs} from './exitWithoutContribs.js'; export {default as inputThingClass} from './inputThingClass.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; export {default as withResolvedContribs} from './withResolvedContribs.js'; export {default as withResolvedReference} from './withResolvedReference.js'; export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js new file mode 100644 index 00000000..edfc9e3c --- /dev/null +++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -0,0 +1,179 @@ +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {stitchArrays} from '#sugar'; +import {isCommentary} from '#validators'; +import {commentaryRegex} from '#wiki-data'; + +import { + fillMissingListItems, + withFlattenedList, + withPropertiesFromList, + withUnflattenedList, +} from '#composite/data'; + +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(commentaryRegex)), + }), + }, + + withPropertiesFromList({ + list: '#rawMatches', + properties: input.value([ + '0', // The entire match as a string. + 'groups', + 'index', + ]), + }).outputs({ + '#rawMatches.0': '#rawMatches.text', + '#rawMatches.groups': '#rawMatches.groups', + '#rawMatches.index': '#rawMatches.startIndex', + }), + + { + dependencies: [ + '#rawMatches.text', + '#rawMatches.startIndex', + ], + + compute: (continuation, { + ['#rawMatches.text']: text, + ['#rawMatches.startIndex']: startIndex, + }) => continuation({ + ['#rawMatches.endIndex']: + stitchArrays({text, startIndex}) + .map(({text, startIndex}) => startIndex + text.length), + }), + }, + + { + dependencies: [ + input('from'), + '#rawMatches.startIndex', + '#rawMatches.endIndex', + ], + + compute: (continuation, { + [input('from')]: commentaryText, + ['#rawMatches.startIndex']: startIndex, + ['#rawMatches.endIndex']: endIndex, + }) => continuation({ + ['#entries.body']: + stitchArrays({startIndex, endIndex}) + .map(({endIndex}, index, stitched) => + (index === stitched.length - 1 + ? commentaryText.slice(endIndex) + : commentaryText.slice( + endIndex, + stitched[index + 1].startIndex))) + .map(body => body.trim()), + }), + }, + + withPropertiesFromList({ + list: '#rawMatches.groups', + prefix: input.value('#entries'), + properties: input.value([ + 'artistReferences', + 'artistDisplayText', + 'annotation', + 'date', + ]), + }), + + // 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), + }), + + { + dependencies: ['#entries.date'], + compute: (continuation, { + ['#entries.date']: date, + }) => continuation({ + ['#entries.date']: + date.map(date => date ? new Date(date) : null), + }), + }, + + { + dependencies: [ + '#entries.artists', + '#entries.artistDisplayText', + '#entries.annotation', + '#entries.date', + '#entries.body', + ], + + compute: (continuation, { + ['#entries.artists']: artists, + ['#entries.artistDisplayText']: artistDisplayText, + ['#entries.annotation']: annotation, + ['#entries.date']: date, + ['#entries.body']: body, + }) => continuation({ + ['#parsedCommentaryEntries']: + stitchArrays({ + artists, + artistDisplayText, + annotation, + date, + body, + }), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js index fbea9d5c..cd6b7ac4 100644 --- a/src/data/composite/wiki-properties/commentary.js +++ b/src/data/composite/wiki-properties/commentary.js @@ -1,12 +1,30 @@ // Artist commentary! Generally present on tracks and albums. +import {input, templateCompositeFrom} from '#composite'; import {isCommentary} from '#validators'; -// TODO: Not templateCompositeFrom. +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withParsedCommentaryEntries} from '#composite/wiki-data'; -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isCommentary}, - }; -} +export default templateCompositeFrom({ + annotation: `commentary`, + + compose: false, + + steps: () => [ + exitWithoutDependency({ + dependency: input.updateValue({validate: isCommentary}), + mode: input.value('falsy'), + value: input.value(null), + }), + + withParsedCommentaryEntries({ + from: input.updateValue(), + }), + + exposeDependency({ + dependency: '#parsedCommentaryEntries', + }), + ], +}); diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index 52aeb868..f400bbfc 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -1,13 +1,14 @@ -// This one's kinda tricky: it parses artist "references" from the -// commentary content, and finds the matching artist for each reference. +// List of artists referenced in commentary entries. // This is mostly useful for credits and listings on artist pages. import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; import {unique} from '#sugar'; -import {exitWithoutDependency} from '#composite/control-flow'; -import {withResolvedReferenceList} from '#composite/wiki-data'; +import {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,35 +22,29 @@ export default templateCompositeFrom({ value: input.value([]), }), - { - dependencies: ['commentary'], - compute: (continuation, {commentary}) => - continuation({ - '#artistRefs': - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g)) - .map(({groups: {who}}) => who), - }), - }, - - withResolvedReferenceList({ - list: '#artistRefs', - data: 'artistData', - find: input.value(find.artist), + withParsedCommentaryEntries({ + from: 'commentary', + }), + + withPropertyFromList({ + list: '#parsedCommentaryEntries', + property: input.value('artists'), }).outputs({ - '#resolvedReferenceList': '#artists', + '#parsedCommentaryEntries.artists': '#artistLists', }), - { - flags: {expose: true}, + withFlattenedList({ + list: '#artistLists', + }).outputs({ + '#flattenedList': '#artists', + }), - expose: { - dependencies: ['#artists'], - compute: ({'#artists': artists}) => - unique(artists), - }, - }, + withUniqueItemsOnly({ + list: '#artists', + }), + + exposeDependency({ + dependency: '#artists', + }), ], }); diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 7607e361..17d51bb8 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -1,3 +1,8 @@ +// #composite/wiki-properties +// +// Entries here may depend on entries in #composite/control-flow, +// #composite/data, and #composite/wiki-data. + export {default as additionalFiles} from './additionalFiles.js'; export {default as additionalNameList} from './additionalNameList.js'; export {default as color} from './color.js'; diff --git a/src/data/serialize.js b/src/data/serialize.js index 52aacb07..8cac3309 100644 --- a/src/data/serialize.js +++ b/src/data/serialize.js @@ -19,6 +19,10 @@ export function toContribRefs(contribs) { return contribs?.map(({who, what}) => ({who: toRef(who), what})); } +export function toCommentaryRefs(entries) { + return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props})); +} + // Interface export const serializeDescriptors = Symbol(); diff --git a/src/data/things/album.js b/src/data/things/album.js index af3eb042..63ec1140 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -181,7 +181,8 @@ export class Album extends Thing { hasTrackArt: S.id, isListedOnHomepage: S.id, - commentary: S.id, + commentary: S.toCommentaryRefs, + additionalFiles: S.id, tracks: S.toRefs, diff --git a/src/data/things/index.js b/src/data/things/index.js index 4ea1f007..d1143b0a 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -22,11 +22,6 @@ import * as wikiInfoClasses from './wiki-info.js'; export {default as Thing} from './thing.js'; -export { - default as CacheableObject, - CacheableObjectPropertyValueError, -} from './cacheable-object.js'; - const allClassLists = { 'album.js': albumClasses, 'art-tag.js': artTagClasses, diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 71570c5a..55eedbcf 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,7 +1,12 @@ import {inspect as nodeInspect} from 'node:util'; +// Heresy. +import printable_characters from 'printable-characters'; +const {strlen} = printable_characters; + import {colors, ENABLE_COLOR} from '#cli'; -import {empty, typeAppearance, withAggregate} from '#sugar'; +import {cut, empty, typeAppearance, withAggregate} from '#sugar'; +import {commentaryRegex} from '#wiki-data'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -169,29 +174,42 @@ export function is(...values) { } function validateArrayItemsHelper(itemValidator) { - return (item, index) => { + return (item, index, array) => { try { - const value = itemValidator(item); + const value = itemValidator(item, index, array); if (value !== true) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`; + const annotation = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)})`; + + error.message = + (error.message.includes('\n') || strlen(annotation) > 20 + ? annotation + '\n' + + error.message + .split('\n') + .map(line => ` ${line}`) + .join('\n') + : `${annotation} ${error}`); + error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index; + throw error; } }; } export function validateArrayItems(itemValidator) { - const fn = validateArrayItemsHelper(itemValidator); + const helper = validateArrayItemsHelper(itemValidator); return (array) => { isArray(array); - withAggregate({message: 'Errors validating array items'}, ({wrap}) => { - array.forEach(wrap(fn)); + withAggregate({message: 'Errors validating array items'}, ({call}) => { + for (let index = 0; index < array.length; index++) { + call(helper, array[index], index, array); + } }); return true; @@ -203,12 +221,12 @@ export function strictArrayOf(itemValidator) { } export function sparseArrayOf(itemValidator) { - return validateArrayItems(item => { + return validateArrayItems((item, index, array) => { if (item === false || item === null) { return true; } - return itemValidator(item); + return itemValidator(item, index, array); }); } @@ -234,18 +252,56 @@ export function isColor(color) { throw new TypeError(`Unknown color format`); } -export function isCommentary(commentary) { - isString(commentary); +export function isCommentary(commentaryText) { + isString(commentaryText); - const [firstLine] = commentary.match(/.*/); - if (!firstLine.replace(/<\/b>/g, '').includes(':</i>')) { - throw new TypeError(`Missing commentary citation: "${ - firstLine.length > 40 - ? firstLine.slice(0, 40) + '...' - : firstLine - }"`); + const rawMatches = + Array.from(commentaryText.matchAll(commentaryRegex)); + + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one commentary heading`); } + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); + + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first commentary heading to be at top`); + } + + const ownInput = commentaryText.slice(position, position + length); + const restOfInput = commentaryText.slice(position + length); + const nextLineBreak = restOfInput.indexOf('\n'); + const upToNextLineBreak = restOfInput.slice(0, nextLineBreak); + + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected commentary heading to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } + + const nextHeading = + (index === niceMatches.length - 1 + ? commentaryText.length + : niceMatches[index + 1].position); + + const upToNextHeading = + commentaryText.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected commentary entry to have body text, only got a heading`); + } + + return true; + })(niceMatches); + return true; } diff --git a/src/data/yaml.js b/src/data/yaml.js index 5da66c93..2c600341 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,15 +7,13 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; +import CacheableObject, {CacheableObjectPropertyValueError} + from '#cacheable-object'; import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; -import T, { - CacheableObject, - CacheableObjectPropertyValueError, - Thing, -} from '#things'; +import T, {Thing} from '#things'; import { annotateErrorWithFile, @@ -23,6 +21,7 @@ import { decorateErrorWithIndex, decorateErrorWithAnnotation, empty, + filterAggregate, filterProperties, openAggregate, showAggregate, @@ -30,6 +29,7 @@ import { } from '#sugar'; import { + commentaryRegex, sortAlbumsTracksChronologically, sortAlphabetically, sortChronologically, @@ -38,8 +38,8 @@ import { // --> General supporting stuff -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); +function inspect(value, opts = {}) { + return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); } // --> YAML data repository structure constants @@ -308,7 +308,12 @@ export class FieldCombinationError extends Error { constructor(fields, message) { const fieldNames = Object.keys(fields); - const mainMessage = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`; + const fieldNamesText = + fieldNames + .map(field => colors.red(field)) + .join(', '); + + const mainMessage = `Don't combine ${fieldNamesText}`; const causeMessage = (typeof message === 'function' @@ -329,8 +334,15 @@ export class FieldCombinationError extends Error { } export class FieldValueAggregateError extends AggregateError { + [Symbol.for('hsmusic.aggregate.translucent')] = true; + constructor(thingConstructor, errors) { - super(errors, `Errors processing field values for ${colors.green(thingConstructor.name)}`); + const constructorText = + colors.green(thingConstructor.name); + + super( + errors, + `Errors processing field values for ${constructorText}`); } } @@ -341,8 +353,17 @@ export class FieldValueError extends Error { ? caughtError.cause : caughtError); + const fieldText = + colors.green(`"${field}"`); + + const propertyText = + colors.green(property); + + const valueText = + inspect(value, {maxStringLength: 40}); + super( - `Failed to set ${colors.green(`"${field}"`)} field (${colors.green(property)}) to ${inspect(value)}`, + `Failed to set ${fieldText} field (${propertyText}) to ${valueText}`, {cause}); } } @@ -354,13 +375,18 @@ export class SkippedFieldsSummaryError extends Error { const lines = entries.map(([field, value]) => ` - ${field}: ` + - inspect(value) + inspect(value, {maxStringLength: 70}) .split('\n') .map((line, index) => index === 0 ? line : ` ${line}`) .join('\n')); + const numFieldsText = + (entries.length === 1 + ? `1 field` + : `${entries.length} fields`); + super( - colors.bright(colors.yellow(`Altogether, skipped ${entries.length === 1 ? `1 field` : `${entries.length} fields`}:\n`)) + + colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) + lines.join('\n') + '\n' + colors.bright(colors.yellow(`See above errors for details.`))); } @@ -1166,7 +1192,10 @@ export async function loadAndProcessDataDocuments({dataPath}) { for (const dataStep of dataSteps) { await processDataAggregate.nestAsync( - {message: `Errors during data step: ${colors.bright(dataStep.title)}`}, + { + message: `Errors during data step: ${colors.bright(dataStep.title)}`, + translucent: true, + }, async ({call, callAsync, map, mapAsync, push}) => { const {documentMode} = dataStep; @@ -1411,7 +1440,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { switch (documentMode) { case documentModes.headerAndEntries: - map(yamlResults, {message: `Errors processing documents in data files`}, + map(yamlResults, {message: `Errors processing documents in data files`, translucent: true}, decorateErrorWithFile(({documents}) => { const headerDocument = documents[0]; const entryDocuments = documents.slice(1).filter(Boolean); @@ -1646,6 +1675,7 @@ export function filterReferenceErrors(wikiData) { bannerArtistContribs: '_contrib', groups: 'group', artTags: 'artTag', + commentary: '_commentary', }], ['trackData', processTrackDocument, { @@ -1656,6 +1686,7 @@ export function filterReferenceErrors(wikiData) { sampledTracks: '_trackNotRerelease', artTags: 'artTag', originalReleaseTrack: '_trackNotRerelease', + commentary: '_commentary', }], ['groupCategoryData', processGroupCategoryDocument, { @@ -1705,7 +1736,21 @@ export function filterReferenceErrors(wikiData) { nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { - const value = CacheableObject.getUpdateValue(thing, property); + let value = CacheableObject.getUpdateValue(thing, property); + let writeProperty = true; + + switch (findFnKey) { + case '_commentary': + if (value) { + value = + Array.from(value.matchAll(commentaryRegex)) + .map(({groups}) => groups.artistReferences) + .map(text => text.split(',').map(text => text.trim())); + } + + writeProperty = false; + break; + } if (value === undefined) { push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); @@ -1718,19 +1763,25 @@ export function filterReferenceErrors(wikiData) { let findFn; + const findArtistOrAlias = artistRef => { + const alias = find.artist(artistRef, wikiData.artistAliasData, {mode: 'quiet'}); + if (alias) { + // No need to check if the original exists here. Aliases are automatically + // created from a field on the original, so the original certainly exists. + const original = alias.aliasedArtist; + throw new Error(`Reference ${colors.red(artistRef)} is to an alias, should be ${colors.green(original.name)}`); + } + + return boundFind.artist(artistRef); + }; + switch (findFnKey) { - case '_contrib': - findFn = contribRef => { - const alias = find.artist(contribRef.who, wikiData.artistAliasData, {mode: 'quiet'}); - if (alias) { - // No need to check if the original exists here. Aliases are automatically - // created from a field on the original, so the original certainly exists. - const original = alias.aliasedArtist; - throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`); - } + case '_commentary': + findFn = findArtistOrAlias; + break; - return boundFind.artist(contribRef.who); - }; + case '_contrib': + findFn = contribRef => findArtistOrAlias(contribRef.who); break; case '_homepageSourceGroup': @@ -1811,22 +1862,39 @@ export function filterReferenceErrors(wikiData) { ? `Reference errors` + fieldPropertyMessage + findFnMessage : `Reference error` + fieldPropertyMessage + findFnMessage); - if (Array.isArray(value)) { - thing[property] = filter( - value, - decorateErrorWithIndex(suppress(findFn)), - {message: errorMessage}); + let newPropertyValue = value; + + if (findFnKey === '_commentary') { + // Commentary doesn't write a property value, so no need to set. + filter( + value, {message: errorMessage}, + decorateErrorWithIndex(refs => + (refs.length === 1 + ? suppress(findFn)(refs[0]) + : filterAggregate( + refs, {message: `Errors in entry's artist references`}, + decorateErrorWithIndex(suppress(findFn))) + .aggregate + .close()))); + } else if (Array.isArray(value)) { + newPropertyValue = filter( + value, {message: errorMessage}, + decorateErrorWithIndex(suppress(findFn))); } else { nest({message: errorMessage}, suppress(({call}) => { try { call(findFn, value); } catch (error) { - thing[property] = null; + newPropertyValue = null; throw error; } })); } + + if (writeProperty) { + thing[property] = newPropertyValue; + } } }); } diff --git a/src/find.js b/src/find.js index dfcaa9aa..4d3e996a 100644 --- a/src/find.js +++ b/src/find.js @@ -1,8 +1,8 @@ import {inspect} from 'node:util'; +import CacheableObject from '#cacheable-object'; import {colors, logWarn} from '#cli'; import {typeAppearance} from '#sugar'; -import {CacheableObject} from '#things'; function warnOrThrow(mode, message) { if (mode === 'error') { diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 1bbcb9c1..e6c1f5c2 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -101,8 +101,8 @@ import { import dimensionsOf from 'image-size'; +import CacheableObject from '#cacheable-object'; import {delay, empty, queue, unique} from '#sugar'; -import {CacheableObject} from '#things'; import {sortByName} from '#wiki-data'; import { diff --git a/src/repl.js b/src/repl.js index 3f5d752a..dd61133c 100644 --- a/src/repl.js +++ b/src/repl.js @@ -11,7 +11,8 @@ import {generateURLs, urlSpec} from '#urls'; import {quickLoadAllFromYAML} from '#yaml'; import _find, {bindFind} from '#find'; -import thingConstructors, {CacheableObject} from '#things'; +import CacheableObject from '#cacheable-object'; +import thingConstructors from '#things'; import * as serialize from '#serialize'; import * as sugar from '#sugar'; import * as wikiDataUtils from '#wiki-data'; diff --git a/src/static/site5.css b/src/static/site5.css index bf2eea11..0be536a4 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -607,6 +607,18 @@ p .current { margin-top: 5px; } +.commentary-entry-heading { + margin-left: 15px; + padding-left: 5px; + max-width: 625px; + padding-bottom: 0.2em; + border-bottom: 1px dotted var(--primary-color); +} + +.commentary-entry-accent { + font-style: oblique; +} + .commentary-art { float: right; width: 30%; @@ -1826,7 +1838,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content float: right; width: 40%; max-width: 400px; - margin: -60px 0 10px 10px; + margin: -60px 0 10px 20px; position: relative; z-index: 2; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 72883e7c..d7cd84e8 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -273,10 +273,6 @@ releaseInfo: _: "Read {LINK}." link: "artist commentary" - artistCommentary: - _: "Artist commentary:" - seeOriginalRelease: "See {ORIGINAL}!" - additionalFiles: heading: "View or download {ADDITIONAL_FILES}:" @@ -364,6 +360,23 @@ misc: artistAvatar: "artist avatar" flashArt: "flash art" + # artistCommentary: + + artistCommentary: + _: "Artist commentary:" + + entry: + title: + _: "{ARTISTS}:" + noArtists: "Unknown artist" + withAccent: "{ARTISTS}: {ACCENT}" + accent: + withAnnotation: "({ANNOTATION})" + withDate: ({DATE})" + withAnnotation.withDate: "({ANNOTATION}, {DATE})" + + seeOriginalRelease: "See {ORIGINAL}!" + # artistLink: # Artist links have special accents which are made conditionally # present in a variety of places across the wiki. diff --git a/src/upd8.js b/src/upd8.js index ff7d7c5c..ebb278b2 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -38,13 +38,13 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; +import CacheableObject from '#cacheable-object'; import {displayCompositeCacheAnalysis} from '#composite'; import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile} from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; import {empty, showAggregate, withEntries} from '#sugar'; -import {CacheableObject} from '#things'; import {generateURLs, urlSpec} from '#urls'; import {sortByName} from '#wiki-data'; diff --git a/src/util/sugar.js b/src/util/sugar.js index 3f0eb2ea..eab44b75 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -250,6 +250,16 @@ export function typeAppearance(value) { return typeof value; } +// Limits a string to the desired length, filling in an ellipsis at the end +// if it cuts any text off. +export function cut(text, length = 40) { + if (text.length >= length) { + return text.slice(0, Math.max(1, length - 3)) + '...'; + } else { + return text; + } +} + // Binds default values for arguments in a {key: value} type function argument // (typically the second argument, but may be overridden by providing a // [bindOpts.bindIndex] argument). Typically useful for preparing a function for @@ -315,6 +325,12 @@ export function openAggregate({ // constructed. message = '', + // Optional flag to indicate that this layer of the aggregate error isn't + // generally useful outside of developer debugging purposes - it will be + // skipped by default when using showAggregate, showing contained errors + // inline with other children of this aggregate's parent. + translucent = false, + // Value to return when a provided function throws an error. If this is a // function, it will be called with the arguments given to the function. // (This is primarily useful when wrapping a function and then providing it @@ -397,7 +413,13 @@ export function openAggregate({ aggregate.close = () => { if (errors.length) { - throw Reflect.construct(errorClass, [errors, message]); + const error = Reflect.construct(errorClass, [errors, message]); + + if (translucent) { + error[Symbol.for(`hsmusic.aggregate.translucent`)] = true; + } + + throw error; } }; @@ -570,34 +592,101 @@ export function _withAggregate(mode, aggregateOpts, fn) { export function showAggregate(topError, { pathToFileURL = f => f, showTraces = true, + showTranslucent = showTraces, print = true, } = {}) { - const recursive = (error, {level}) => { - let headerPart = showTraces - ? `[${error.constructor.name || 'unnamed'}] ${ - error.message || '(no message)' - }` - : error instanceof AggregateError - ? `[${error.message || '(no message)'}]` - : error.message || '(no message)'; + const translucentSymbol = Symbol.for('hsmusic.aggregate.translucent'); + + const determineCause = error => { + let cause = error.cause; + if (showTranslucent) return cause ?? null; + + while (cause) { + if (!cause[translucentSymbol]) return cause; + cause = cause.cause; + } + + return null; + }; + + const determineErrors = parentError => { + if (!parentError.errors) return null; + if (showTranslucent) return parentError.errors; + + const errors = []; + for (const error of parentError.errors) { + if (!error[translucentSymbol]) { + errors.push(error); + continue; + } + + if (error.cause) { + errors.push(determineCause(error)); + } + + if (error.errors) { + errors.push(...determineErrors(error)); + } + } + + return errors; + }; + + const flattenErrorStructure = (error, level = 0) => { + const cause = determineCause(error); + const errors = determineErrors(error); + + return { + level, + + kind: error.constructor.name, + message: error.message, + stack: error.stack, + + cause: + (cause + ? flattenErrorStructure(cause, level + 1) + : null), + + errors: + (errors + ? errors.map(error => flattenErrorStructure(error, level + 1)) + : null), + }; + }; + + const recursive = ({level, kind, message, stack, cause, errors}) => { + const messagePart = + message || `(no message)`; + + const kindPart = + kind || `unnamed kind`; + + let headerPart = + (showTraces + ? `[${kindPart}] ${messagePart}` + : errors + ? `[${messagePart}]` + : messagePart); if (showTraces) { - const stackLines = error.stack?.split('\n'); + const stackLines = + stack?.split('\n'); - const stackLine = stackLines?.find( - (line) => + const stackLine = + stackLines?.find(line => line.trim().startsWith('at') && !line.includes('sugar') && !line.includes('node:') && - !line.includes('<anonymous>') - ); + !line.includes('<anonymous>')); - const tracePart = stackLine - ? '- ' + - stackLine - .trim() - .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) - : '(no stack trace)'; + const tracePart = + (stackLine + ? '- ' + + stackLine + .trim() + .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) + : '(no stack trace)'); headerPart += ` ${colors.dim(tracePart)}`; } @@ -606,8 +695,8 @@ export function showAggregate(topError, { const bar1 = ' '; const causePart = - (error.cause - ? recursive(error.cause, {level: level + 1}) + (cause + ? recursive(cause) .split('\n') .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`) .join('\n') @@ -616,19 +705,20 @@ export function showAggregate(topError, { const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); - const aggregatePart = - (error instanceof AggregateError - ? error.errors - .map(error => recursive(error, {level: level + 1})) + const errorsPart = + (errors + ? errors + .map(error => recursive(error)) .flatMap(str => str.split('\n')) .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`) .join('\n') : ''); - return [headerPart, causePart, aggregatePart].filter(Boolean).join('\n'); + return [headerPart, causePart, errorsPart].filter(Boolean).join('\n'); }; - const message = recursive(topError, {level: 0}); + const structure = flattenErrorStructure(topError); + const message = recursive(structure); if (print) { console.error(message); diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 0790ae91..b5813c7a 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -629,6 +629,41 @@ export function sortFlashesChronologically(data, { // Specific data utilities +// Matches heading details from commentary data in roughly the formats: +// +// <i>artistReference:</i> (annotation, date) +// <i>artistReference|artistDisplayText:</i> (annotation, date) +// +// where capturing group "annotation" can be any text at all, except that the +// last entry (past a comma or the only content within parentheses), if parsed +// as a date, is the capturing group "date". "Parsing as a date" means matching +// one of these formats: +// +// * "25 December 2019" - one or two number digits, followed by any text, +// followed by four number digits +// * "December 25, 2019" - one all-letters word, a space, one or two number +// digits, a comma, and four number digits +// * "12/25/2019" etc - three sets of one to four number digits, separated +// by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD) +// +// Note that the annotation and date are always wrapped by one opening and one +// closing parentheses. The whole heading does NOT need to match the entire +// line it occupies (though it does always start at the first position on that +// line), and if there is more than one closing parenthesis on the line, the +// annotation will always cut off only at the last parenthesis, or a comma +// preceding a date and then the last parenthesis. This is to ensure that +// parentheses can be part of the actual annotation content. +// +// Capturing group "artistReference" is all the characters between <i> and </i> +// (apart from the pipe and "artistDisplayText" text, if present), and is either +// the name of an artist or an "artist:directory"-style reference. +// +// This regular expression *doesn't* match bodies, which will need to be parsed +// out of the original string based on the indices matched using this. +// +export const commentaryRegex = + /^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?<date>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?/gm; + export function filterAlbumsByCommentary(albums) { return albums .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js index 57e562d8..5ed9a4a9 100644 --- a/test/unit/data/cacheable-object.js +++ b/test/unit/data/cacheable-object.js @@ -1,6 +1,6 @@ import t from 'tap'; -import {CacheableObject} from '#things'; +import CacheableObject from '#cacheable-object'; function newCacheableObject(PD) { return new (class extends CacheableObject { diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js new file mode 100644 index 00000000..965b14b5 --- /dev/null +++ b/test/unit/data/composite/data/withUniqueItemsOnly.js @@ -0,0 +1,84 @@ +import t from 'tap'; + +import {compositeFrom, input} from '#composite'; +import {exposeDependency} from '#composite/control-flow'; +import {withUniqueItemsOnly} from '#composite/data'; + +t.test(`withUniqueItemsOnly: basic behavior`, t => { + t.plan(3); + + const composite = compositeFrom({ + compose: false, + + steps: [ + withUniqueItemsOnly({ + list: 'list', + }), + + exposeDependency({dependency: '#list'}), + ], + }); + + t.match(composite, { + expose: { + dependencies: ['list'], + }, + }); + + t.same(composite.expose.compute({ + list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'], + }), ['apple', 'banana', 'watermelon']); + + t.same(composite.expose.compute({ + list: [], + }), []); +}); + +t.test(`withUniqueItemsOnly: output shapes & values`, t => { + t.plan(2 * 3 ** 1); + + const dependencies = { + ['list_dependency']: + [1, 1, 2, 3, 3, 4, 'foo', false, false, 4], + [input('list_neither')]: + [8, 8, 7, 6, 6, 5, 'bar', true, true, 5], + }; + + const mapLevel1 = [ + ['list_dependency', { + '#list_dependency': [1, 2, 3, 4, 'foo', false], + }], + [input.value([-1, -1, 'interesting', 'very', 'interesting']), { + '#uniqueItems': [-1, 'interesting', 'very'], + }], + [input('list_neither'), { + '#uniqueItems': [8, 7, 6, 5, 'bar', true], + }], + ]; + + for (const [listInput, outputDict] of mapLevel1) { + const step = withUniqueItemsOnly({ + list: listInput, + }); + + quickCheckOutputs(step, outputDict); + } + + function quickCheckOutputs(step, outputDict) { + t.same( + Object.keys(step.toDescription().outputs), + Object.keys(outputDict)); + + const composite = compositeFrom({ + compose: false, + steps: [step, { + dependencies: Object.keys(outputDict), + compute: dependencies => dependencies, + }], + }); + + t.same( + composite.expose.compute(dependencies), + outputDict); + } +}); diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js new file mode 100644 index 00000000..babe4fae --- /dev/null +++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js @@ -0,0 +1,102 @@ +import t from 'tap'; + +import {compositeFrom, input} from '#composite'; +import thingConstructors from '#things'; + +import {exposeDependency} from '#composite/control-flow'; +import {withParsedCommentaryEntries} from '#composite/wiki-data'; + +const {Artist} = thingConstructors; + +const composite = compositeFrom({ + compose: false, + + steps: [ + withParsedCommentaryEntries({ + from: 'from', + }), + + exposeDependency({dependency: '#parsedCommentaryEntries'}), + ], +}); + +function stubArtist(artistName = `Test Artist`) { + const artist = new Artist(); + artist.name = artistName; + + return artist; +} + +t.test(`withParsedCommentaryEntries: basic behavior`, t => { + t.plan(3); + + const artist1 = stubArtist(`Mobius Trip`); + const artist2 = stubArtist(`Hadron Kaleido`); + + const artistData = [artist1, artist2]; + + t.match(composite, { + expose: { + dependencies: ['from', 'artistData'], + }, + }); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Mobius Trip:</i>\n` + + `Some commentary.\n` + + `Very cool.\n`, + }), [ + { + artists: [artist1], + artistDisplayText: null, + annotation: null, + date: null, + body: `Some commentary.\nVery cool.`, + }, + ]); + + t.same(composite.expose.compute({ + artistData, + from: + `<i>Mobius Trip|Moo-bius Trip:</i> (music, art, 12 January 2015)\n` + + `First commentary entry.\n` + + `Very cool.\n` + + `<i>Hadron Kaleido|<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>:</i> (moral support, 4/4/2022)\n` + + `Second commentary entry. Yes. So cool.\n` + + `<i>Mystery Artist:</i> (pingas, August 25, 2023)\n` + + `Oh no.. Oh dear...\n` + + `<i>Mobius Trip, Hadron Kaleido:</i>\n` + + `And back around we go.`, + }), [ + { + artists: [artist1], + artistDisplayText: `Moo-bius Trip`, + annotation: `music, art`, + date: new Date('12 January 2015'), + body: `First commentary entry.\nVery cool.`, + }, + { + artists: [artist2], + artistDisplayText: `<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>`, + annotation: `moral support`, + date: new Date('4 April 2022'), + body: `Second commentary entry. Yes. So cool.`, + }, + { + artists: [], + artistDisplayText: null, + annotation: `pingas`, + date: new Date('25 August 2023'), + body: `Oh no.. Oh dear...`, + }, + { + artists: [artist1, artist2], + artistDisplayText: null, + annotation: null, + date: null, + body: `And back around we go.`, + }, + ]); +}); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 51f86070..f84ba1cb 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -215,7 +215,7 @@ t.test(`Track.color`, t => { }); t.test(`Track.commentatorArtists`, t => { - t.plan(6); + t.plan(8); const track = new Track(); const artist1 = stubArtist(`SnooPING`); @@ -227,47 +227,67 @@ t.test(`Track.commentatorArtists`, t => { artistData: [artist1, artist2, artist3], }); - track.commentary = + // Keep track of the last commentary string in a separate value, since + // the track.commentary property exposes as a completely different format + // (i.e. an array of objects, one for each entry), and so isn't compatible + // with the += operator on its own. + let commentary; + + track.commentary = commentary = `<i>SnooPING:</i>\n` + `Wow.\n`; t.same(track.commentatorArtists, [artist1], `Track.commentatorArtists #1: works with one commentator`); - track.commentary += + track.commentary = commentary += `<i>ASUsual:</i>\n` + `Yes!\n`; t.same(track.commentatorArtists, [artist1, artist2], `Track.commentatorArtists #2: works with two commentators`); - track.commentary += - `<i><b>Icy:</b></i>\n` + + track.commentary = commentary += + `<i>Icy|<b>Icy What You Did There</b>:</i>\n` + `Incredible.\n`; t.same(track.commentatorArtists, [artist1, artist2, artist3], - `Track.commentatorArtists #3: works with boldface name`); + `Track.commentatorArtists #3: works with custom artist text`); - track.commentary = + track.commentary = commentary = `<i>Icy:</i> (project manager)\n` + `Very good track.\n`; t.same(track.commentatorArtists, [artist3], - `Track.commentatorArtists #4: works with parenthical accent`); + `Track.commentatorArtists #4: works with annotation`); - track.commentary += - `<i>SNooPING ASUsual Icy:</i>\n` + - `WITH ALL THREE POWERS COMBINED...`; + track.commentary = commentary = + `<i>Icy:</i> (project manager, 08/15/2023)\n` + + `Very very good track.\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #5: works with date`); + + track.commentary = commentary += + `<i>Ohohohoho:</i>\n` + + `OHOHOHOHOHOHO...\n`; t.same(track.commentatorArtists, [artist3], - `Track.commentatorArtists #5: ignores artist names not found`); + `Track.commentatorArtists #6: ignores artist names not found`); - track.commentary += + track.commentary = commentary += `<i>Icy:</i>\n` + `I'm back!\n`; t.same(track.commentatorArtists, [artist3], - `Track.commentatorArtists #6: ignores duplicate artist`); + `Track.commentatorArtists #7: ignores duplicate artist`); + + track.commentary = commentary += + `<i>SNooPING, ASUsual, Icy:</i>\n` + + `WITH ALL THREE POWERS COMBINED...`; + + t.same(track.commentatorArtists, [artist3, artist1, artist2], + `Track.commentatorArtists #8: works with more than one artist in one entry`); }); t.test(`Track.coverArtistContribs`, t => { diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js index bb33bf86..aa56a10e 100644 --- a/test/unit/data/things/validators.js +++ b/test/unit/data/things/validators.js @@ -149,13 +149,18 @@ t.test('isColor', t => { }); t.test('isCommentary', t => { - t.plan(6); + t.plan(9); + + // TODO: Test specific error messages. t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`)); - t.ok(isCommentary(`Technically, this works:</i>`)); - t.ok(isCommentary(`<i><b>Whodunnit:</b></i>`)); - t.throws(() => isCommentary(123), TypeError); - t.throws(() => isCommentary(``), TypeError); - t.throws(() => isCommentary(`<i><u>Toby Fox:</u></i>`)); + t.ok(isCommentary(`<i>Toby Fox:</i> (music)\ndogsong.mp3`)); + t.throws(() => isCommentary(`dogsong.mp3\n<i>Toby Fox:</i>\ndogsong.mp3`)); + t.throws(() => isCommentary(`<i>Toby Fox:</i> dogsong.mp3`)); + t.throws(() => isCommentary(`<i>Toby Fox:</i> (music) dogsong.mp3`)); + t.throws(() => isCommentary(`<i>I Have Nothing To Say:</i>`)); + t.throws(() => isCommentary(123)); + t.throws(() => isCommentary(``)); + t.throws(() => isCommentary(`Technically, ah, er:</i>\nCorrect`)); }); t.test('isContribution', t => { |