diff options
35 files changed, 3247 insertions, 2565 deletions
diff --git a/.gitignore b/.gitignore index 447e8916..fd4f2b06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,2 @@ node_modules .DS_Store - -# FOR NOW: We are using site2.css instead of site.css, -# thanks to breaking changes - we can't have the release -# site reuse the same CSS as the staging/preview one -# anymore! -src/static/site.css diff --git a/package-lock.json b/package-lock.json index 65e8bcac..fcccac83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "chroma-js": "^2.4.2", "command-exists": "^1.2.9", "he": "^1.2.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "word-wrap": "^1.2.3" }, "bin": { "hsmusic": "src/upd8.js" @@ -1895,7 +1896,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3289,8 +3289,7 @@ "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, "wrappy": { "version": "1.0.2", diff --git a/package.json b/package.json index c9f0c1d7..348ca896 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "chroma-js": "^2.4.2", "command-exists": "^1.2.9", "he": "^1.2.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "word-wrap": "^1.2.3" }, "license": "GPL-3.0", "devDependencies": { diff --git a/src/data/things/album.js b/src/data/things/album.js index ade34e3d..0c0c7fb4 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -10,13 +10,11 @@ export class Album extends Thing { Artist, Group, Track, - TrackGroup, validators: { isDate, isDimensions, - validateArrayItems, - validateInstanceOf, + isTrackSectionList, }, }) => ({ // Update & expose @@ -56,11 +54,39 @@ export class Album extends Thing { groupsByRef: Thing.common.referenceList(Group), artTagsByRef: Thing.common.referenceList(ArtTag), - trackGroups: { + trackSections: { flags: {update: true, expose: true}, update: { - validate: validateArrayItems(validateInstanceOf(TrackGroup)), + validate: isTrackSectionList, + }, + + expose: { + dependencies: ['color', 'trackData'], + transform(trackSections, { + color: albumColor, + trackData, + }) { + let startIndex = 0; + return trackSections?.map(section => ({ + name: section.name ?? null, + color: section.color ?? albumColor ?? null, + dateOriginallyReleased: section.dateOriginallyReleased ?? null, + isDefaultTrackSection: section.isDefaultTrackSection ?? false, + + startIndex: ( + startIndex += section.tracksByRef.length, + startIndex - section.tracksByRef.length + ), + + tracksByRef: section.tracksByRef ?? [], + tracks: + (trackData && section.tracksByRef + ?.map(ref => find.track(ref, trackData, {mode: 'quiet'})) + .filter(Boolean)) ?? + [], + })); + }, }, }, @@ -114,11 +140,11 @@ export class Album extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackGroups', 'trackData'], - compute: ({trackGroups, trackData}) => - trackGroups && trackData - ? trackGroups - .flatMap((group) => group.tracksByRef ?? []) + dependencies: ['trackSections', 'trackData'], + compute: ({trackSections, trackData}) => + trackSections && trackData + ? trackSections + .flatMap((section) => section.tracksByRef ?? []) .map((ref) => find.track(ref, trackData, {mode: 'quiet'})) .filter(Boolean) : [], @@ -179,72 +205,11 @@ export class Album extends Thing { }); } -export class TrackGroup extends Thing { - static [Thing.getPropertyDescriptors] = ({ - isColor, - Track, - - validators: { - validateInstanceOf, - }, - }) => ({ - // Update & expose - +export class TrackSectionHelper extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ name: Thing.common.name('Unnamed Track Group'), - - color: { - flags: {update: true, expose: true}, - - update: {validate: isColor}, - - expose: { - dependencies: ['album'], - - transform(color, {album}) { - return color ?? album?.color ?? null; - }, - }, - }, - + color: Thing.common.color(), dateOriginallyReleased: Thing.common.simpleDate(), - - tracksByRef: Thing.common.referenceList(Track), - isDefaultTrackGroup: Thing.common.flag(false), - - // Update only - - album: { - flags: {update: true}, - update: {validate: validateInstanceOf(Album)}, - }, - - trackData: Thing.common.wikiData(Track), - - // Expose only - - tracks: { - flags: {expose: true}, - - expose: { - dependencies: ['tracksByRef', 'trackData'], - compute: ({tracksByRef, trackData}) => - tracksByRef && trackData - ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean) - : [], - }, - }, - - startIndex: { - flags: {expose: true}, - - expose: { - dependencies: ['album'], - compute: ({album, [TrackGroup.instance]: trackGroup}) => - album.trackGroups - .slice(0, album.trackGroups.indexOf(trackGroup)) - .reduce((acc, tg) => acc + tg.tracks.length, 0), - }, - }, }) } diff --git a/src/data/things/track.js b/src/data/things/track.js index a8020956..6b1e958b 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -151,9 +151,9 @@ export class Track extends Thing { dependencies: ['albumData'], compute: ({albumData, [Track.instance]: track}) => - Track.findAlbum(track, albumData)?.trackGroups.find((tg) => - tg.tracks.includes(track) - )?.color ?? null, + Track.findAlbum(track, albumData) + ?.trackSections.find(({tracks}) => tracks.includes(track)) + ?.color ?? null, }, }, diff --git a/src/data/things/validators.js b/src/data/things/validators.js index a0d473ba..24db3c79 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -96,6 +96,10 @@ export function isStringNonEmpty(value) { return true; } +export function optional(validator) { + return value => value === null || value === undefined || validator(value); +} + // Complex types (non-primitives) export function isInstance(value, constructor) { @@ -252,6 +256,16 @@ export const isAdditionalFile = validateProperties({ export const isAdditionalFileList = validateArrayItems(isAdditionalFile); +export const isTrackSection = validateProperties({ + name: optional(isString), + color: optional(isColor), + dateOriginallyReleased: optional(isDate), + isDefaultTrackSection: optional(isBoolean), + tracksByRef: optional(validateReferenceList('track')), +}); + +export const isTrackSectionList = validateArrayItems(isTrackSection); + export function isDimensions(dimensions) { isArray(dimensions); diff --git a/src/data/yaml.js b/src/data/yaml.js index 13b746f7..9c3a4b88 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -227,7 +227,7 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { }, }); -export const processTrackGroupDocument = makeProcessDocument(T.TrackGroup, { +export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHelper, { fieldTransformations: { 'Date Originally Released': (value) => new Date(value), }, @@ -653,7 +653,7 @@ export const dataSteps = [ processHeaderDocument: processAlbumDocument, processEntryDocument(document) { return 'Group' in document - ? processTrackGroupDocument(document) + ? processTrackSectionDocument(document) : processTrackDocument(document); }, @@ -662,39 +662,39 @@ export const dataSteps = [ const trackData = []; for (const {header: album, entries} of results) { - // We can't mutate an array once it's set as a property - // value, so prepare the tracks and track groups that will - // show up in a track list all the way before actually - // applying them. - const trackGroups = []; - let currentTracksByRef = null; - let currentTrackGroup = null; + // We can't mutate an array once it's set as a property value, + // so prepare the track sections that will show up in a track list + // all the way before actually applying them. (It's okay to mutate + // an individual section before applying it, since those are just + // generic objects; they aren't Things in and of themselves.) + const trackSections = []; + + let currentTrackSection = { + name: `Default Track Section`, + isDefaultTrackSection: true, + tracksByRef: [], + }; const albumRef = T.Thing.getReference(album); - const closeCurrentTrackGroup = () => { - if (currentTracksByRef) { - let trackGroup; - - if (currentTrackGroup) { - trackGroup = currentTrackGroup; - } else { - trackGroup = new T.TrackGroup(); - trackGroup.name = `Default Track Group`; - trackGroup.isDefaultTrackGroup = true; - } - - trackGroup.album = album; - trackGroup.tracksByRef = currentTracksByRef; - trackGroups.push(trackGroup); + const closeCurrentTrackSection = () => { + if (!empty(currentTrackSection.tracksByRef)) { + trackSections.push(currentTrackSection); } }; for (const entry of entries) { - if (entry instanceof T.TrackGroup) { - closeCurrentTrackGroup(); - currentTracksByRef = []; - currentTrackGroup = entry; + if (entry instanceof T.TrackSectionHelper) { + closeCurrentTrackSection(); + + currentTrackSection = { + name: entry.name, + color: entry.color, + dateOriginallyReleased: entry.dateOriginallyReleased, + isDefaultTrackSection: false, + tracksByRef: [], + }; + continue; } @@ -702,17 +702,12 @@ export const dataSteps = [ entry.dataSourceAlbumByRef = albumRef; - const trackRef = T.Thing.getReference(entry); - if (currentTracksByRef) { - currentTracksByRef.push(trackRef); - } else { - currentTracksByRef = [trackRef]; - } + currentTrackSection.tracksByRef.push(T.Thing.getReference(entry)); } - closeCurrentTrackGroup(); + closeCurrentTrackSection(); - album.trackGroups = trackGroups; + album.trackSections = trackSections; albumData.push(album); } @@ -1160,8 +1155,6 @@ export function linkWikiDataArrays(wikiData) { assignWikiData([WD.wikiInfo], 'groupData'); assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData'); - WD.albumData.forEach((album) => assignWikiData(album.trackGroups, 'trackData')); - assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData'); assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData'); assignWikiData(WD.groupData, 'albumData', 'groupCategoryData'); diff --git a/src/listing-spec.js b/src/listing-spec.js index 26910c05..ef51fe90 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,3 +1,5 @@ +import {OFFICIAL_GROUP_DIRECTORY} from './util/magic-constants.js'; + import { empty, accumulateSum, @@ -879,16 +881,20 @@ const listingSpec = [ directory: 'random', stringsKey: 'other.randomPages', - data: ({wikiData: {fandomAlbumData, officialAlbumData}}) => [ + data: ({wikiData: {albumData}}) => [ { - albums: officialAlbumData, name: 'Official', randomCode: 'official', + albums: albumData + .filter((album) => album.groups + .some((group) => group.directory === OFFICIAL_GROUP_DIRECTORY)), }, { - albums: fandomAlbumData, name: 'Fandom', randomCode: 'fandom', + albums: albumData + .filter((album) => album.groups + .every((group) => group.directory !== OFFICIAL_GROUP_DIRECTORY)), }, ], diff --git a/src/misc-templates.js b/src/misc-templates.js index 0e177e0f..8a61bf7f 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -10,6 +10,8 @@ import { unique, } from './util/sugar.js'; +import {thumb} from './util/urls.js'; + import { getTotalDuration, sortAlbumsTracksChronologically, @@ -218,7 +220,7 @@ function unbound_generateChronologyLinks(currentThing, { // Content warning tags -function unbound_getRevealStringFromWarnings(warnings, { +function unbound_getRevealStringFromContentWarningMessage(warnings, { html, language, }) { @@ -230,14 +232,13 @@ function unbound_getRevealStringFromWarnings(warnings, { ); } -function unbound_getRevealStringFromTags(tags, { +function unbound_getRevealStringFromArtTags(tags, { + getRevealStringFromContentWarningMessage, language, - - getRevealStringFromWarnings, }) { return ( tags?.some(tag => tag.isContentWarning) && - getRevealStringFromWarnings( + getRevealStringFromContentWarningMessage( language.formatUnitList( tags .filter(tag => tag.isContentWarning) @@ -253,7 +254,7 @@ function unbound_generateCoverLink({ language, link, - getRevealStringFromTags, + getRevealStringFromArtTags, alt, path, @@ -282,7 +283,7 @@ function unbound_generateCoverLink({ id: 'cover-art', link: true, square: true, - reveal: getRevealStringFromTags(tags), + reveal: getRevealStringFromArtTags(tags), }), wikiInfo.enableArtTagUI && @@ -574,7 +575,7 @@ function unbound_getGridHTML({ html, language, - getRevealStringFromTags, + getRevealStringFromArtTags, entries, srcFn, @@ -595,7 +596,7 @@ function unbound_getGridHTML({ thumb: 'medium', lazy: typeof lazy === 'number' ? i >= lazy : lazy, square: true, - reveal: getRevealStringFromTags(item.artTags, {language}), + reveal: getRevealStringFromArtTags(item.artTags, {language}), noSrcText: noSrcTextFn(item), }), html.tag('span', item.name), @@ -646,6 +647,106 @@ function unbound_getFlashGridHTML({ }); } +// Images + +function unbound_img({ + html, + + src, + alt, + noSrcText = '', + thumb: thumbKey, + reveal, + id, + class: className, + width, + height, + link = false, + lazy = false, + square = false, +}) { + const willSquare = square; + const willLink = typeof link === 'string' || link; + + const originalSrc = src; + const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src); + + const imgAttributes = { + id: link ? '' : id, + class: className, + alt, + width, + height, + }; + + const noSrcHTML = + !src && + wrap( + html.tag('div', + {class: 'image-text-area'}, + noSrcText)); + + const nonlazyHTML = + src && + wrap( + html.tag('img', { + ...imgAttributes, + src: thumbSrc, + })); + + const lazyHTML = + src && + lazy && + wrap( + html.tag('img', + { + ...imgAttributes, + class: [className, 'lazy'], + 'data-original': thumbSrc, + }), + true); + + if (!src) { + return noSrcHTML; + } else if (lazy) { + return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML; + } else { + return nonlazyHTML; + } + + function wrap(input, hide = false) { + let wrapped = input; + + wrapped = html.tag('div', {class: 'image-container'}, wrapped); + + if (reveal) { + wrapped = html.tag('div', {class: 'reveal'}, [ + wrapped, + html.tag('span', {class: 'reveal-text'}, reveal), + ]); + } + + if (willSquare) { + wrapped = html.tag('div', {class: 'square-content'}, wrapped); + wrapped = html.tag('div', + {class: ['square', hide && !willLink && 'js-hide']}, + wrapped); + } + + if (willLink) { + wrapped = html.tag('a', + { + id, + class: ['box', hide && 'js-hide'], + href: typeof link === 'string' ? link : originalSrc, + }, + wrapped); + } + + return wrapped; + } +} + // Carousel reels // Layout constants: @@ -831,14 +932,14 @@ function unbound_generateNavigationLinks(current, { // Sticky heading, ooooo function unbound_generateStickyHeadingContainer({ - getRevealStringFromTags, + getRevealStringFromArtTags, html, img, class: classes, coverSrc, coverAlt, - coverTags, + coverArtTags, title, }) { return html.tag('div', @@ -859,7 +960,7 @@ function unbound_generateStickyHeadingContainer({ thumb: 'small', link: false, square: true, - reveal: getRevealStringFromTags(coverTags), + reveal: getRevealStringFromArtTags(coverArtTags), }))), ]), @@ -870,48 +971,38 @@ function unbound_generateStickyHeadingContainer({ // Footer stuff -function unbound_getFooterLocalizationLinks(pathname, { +function unbound_getFooterLocalizationLinks({ html, - language, - to, - paths, - defaultLanguage, + language, languages, + pagePath, + to, }) { - const {urlPath} = paths; - const keySuffix = urlPath[0].replace(/^localized\./, '.'); - const toArgs = urlPath.slice(1); - const links = Object.entries(languages) .filter(([code, language]) => code !== 'default' && !language.hidden) .map(([code, language]) => language) .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0)) .map((language) => - html.tag( - 'span', - html.tag( - 'a', + html.tag('span', + html.tag('a', { href: language === defaultLanguage - ? to('localizedDefaultLanguage' + keySuffix, ...toArgs) + ? to( + 'localizedDefaultLanguage.' + pagePath[0], + ...pagePath.slice(1)) : to( - 'localizedWithBaseDirectory' + keySuffix, + 'localizedWithBaseDirectory.' + pagePath[0], language.code, - ...toArgs - ), + ...pagePath.slice(1)), }, - language.name - ) - ) - ); - - return html.tag( - 'div', - {class: 'footer-localization-links'}, - language.$('misc.uiLanguage', {languages: links.join('\n')}) - ); + language.name))); + + return html.tag('div', {class: 'footer-localization-links'}, + language.$('misc.uiLanguage', { + languages: links.join('\n'), + })); } // Exports @@ -924,8 +1015,8 @@ export { unbound_generateChronologyLinks as generateChronologyLinks, - unbound_getRevealStringFromWarnings as getRevealStringFromWarnings, - unbound_getRevealStringFromTags as getRevealStringFromTags, + unbound_getRevealStringFromContentWarningMessage as getRevealStringFromContentWarningMessage, + unbound_getRevealStringFromArtTags as getRevealStringFromArtTags, unbound_generateCoverLink as generateCoverLink, @@ -944,6 +1035,8 @@ export { unbound_getCarouselHTML as getCarouselHTML, + unbound_img as img, + unbound_generateInfoGalleryLinks as generateInfoGalleryLinks, unbound_generateNavigationLinks as generateNavigationLinks, diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js index 74eee2b0..5fd78beb 100644 --- a/src/page/album-commentary.js +++ b/src/page/album-commentary.js @@ -4,6 +4,8 @@ import {generateAlbumExtrasPageNav} from './album.js'; import {accumulateSum} from '../util/sugar.js'; import {filterAlbumsByCommentary} from '../util/wiki-data.js'; +export const description = `per-album artist commentary pages & index` + export function condition({wikiData}) { return filterAlbumsByCommentary(wikiData.albumData).length; } @@ -22,7 +24,6 @@ export function write(album) { type: 'page', path: ['albumCommentary', album.directory], page: ({ - generateStickyHeadingContainer, getAlbumStylesheet, getLinkThemeString, getThemeString, @@ -36,13 +37,10 @@ export function write(album) { theme: getThemeString(album.color), main: { - content: html.tag('div', {class: 'long-content'}, [ - generateStickyHeadingContainer({ - title: language.$('albumCommentaryPage.title', { - album: album.name, - }), - }), + classes: ['long-content'], + headingMode: 'sticky', + content: [ html.tag('p', language.$('albumCommentaryPage.infoLine', { words: html.tag('b', language.formatWordCount(words, {unit: true})), @@ -68,7 +66,7 @@ export function write(album) { {style: getLinkThemeString(track.color)}, transformMultiline(track.commentary)), ]) - ]), + ], }, nav: generateAlbumExtrasPageNav(album, 'commentary', { @@ -110,20 +108,24 @@ export function writeTargetless({wikiData}) { title: language.$('commentaryIndex.title'), main: { - content: html.tag('div', {class: 'long-content'}, [ - html.tag('h1', language.$('commentaryIndex.title')), + classes: ['long-content'], + headingMode: 'static', + + content: [ html.tag('p', language.$('commentaryIndex.infoLine', { words: html.tag('b', language.formatWordCount(totalWords, {unit: true})), entries: html.tag('b', language.countCommentaryEntries(totalEntries, {unit: true})), })), + html.tag('p', language.$('commentaryIndex.albumList.title')), + html.tag('ul', data.map(({album, entries, words}) => html.tag('li', language.$('commentaryIndex.albumList.item', { album: link.albumCommentary(album), words: language.formatWordCount(words, {unit: true}), entries: language.countCommentaryEntries(entries.length, {unit: true}), - })))) - ]), + })))), + ], }, nav: {simple: true}, diff --git a/src/page/album.js b/src/page/album.js index 80397065..897e5110 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -12,6 +12,8 @@ import { getTotalDuration, } from '../util/wiki-data.js'; +export const description = `per-album info & track artwork gallery pages`; + export function targets({wikiData}) { return wikiData.albumData; } @@ -50,10 +52,10 @@ export function write(album, {wikiData}) { const hasAdditionalFiles = !empty(album.additionalFiles); const albumDuration = getTotalDuration(album.tracks); - const displayTrackGroups = - album.trackGroups && - (album.trackGroups.length > 1 || - !album.trackGroups[0]?.isDefaultTrackGroup); + const displayTrackSections = + album.trackSections && + (album.trackSections.length > 1 || + !album.trackSections[0]?.isDefaultTrackSection); const listTag = getAlbumListTag(album); @@ -107,10 +109,10 @@ export function write(album, {wikiData}) { wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs), bannerArtistContribs: serializeContribs(album.bannerArtistContribs), groups: serializeGroupsForAlbum(album), - trackGroups: album.trackGroups?.map((trackGroup) => ({ - name: trackGroup.name, - color: trackGroup.color, - tracks: trackGroup.tracks.map((track) => track.directory), + trackSections: album.trackSections?.map((section) => ({ + name: section.name, + color: section.color, + tracks: section.tracks.map((track) => track.directory), })), tracks: album.tracks.map((track) => ({ link: serializeLink(track), @@ -128,9 +130,7 @@ export function write(album, {wikiData}) { generateAdditionalFilesShortcut, generateAdditionalFilesList, generateChronologyLinks, - generateCoverLink, generateNavigationLinks, - generateStickyHeadingContainer, getAlbumCover, getAlbumStylesheet, getArtistString, @@ -151,8 +151,6 @@ export function write(album, {wikiData}) { link, }); - const cover = getAlbumCover(album); - return { title: language.$('albumPage.title', {album: album.name}), stylesheet: getAlbumStylesheet(album), @@ -195,22 +193,16 @@ export function write(album, {wikiData}) { position: 'top', }, - main: { - content: [ - generateStickyHeadingContainer({ - title: language.$('albumPage.title', {album: album.name}), - - coverSrc: cover, - coverAlt: language.$('misc.alt.albumCover'), - coverTags: album.artTags, - }), + cover: { + src: getAlbumCover(album), + alt: language.$('misc.alt.albumCover'), + artTags: album.artTags, + }, - cover && generateCoverLink({ - src: cover, - alt: language.$('misc.alt.albumCover'), - tags: album.artTags, - }), + main: { + headingMode: 'sticky', + content: [ html.tag('p', { [html.onlyIfContent]: true, @@ -254,6 +246,7 @@ export function write(album, {wikiData}) { date: language.formatDate(album.date), }), + album.hasCoverArt && album.coverArtDate && +album.coverArtDate !== +album.date && language.$('releaseInfo.artReleased', { @@ -300,11 +293,11 @@ export function write(album, {wikiData}) { ), })), - displayTrackGroups && - !empty(album.trackGroups) && + displayTrackSections && + !empty(album.trackSections) && html.tag('dl', {class: 'album-group-list'}, - album.trackGroups.flatMap(({ + album.trackSections.flatMap(({ name, startIndex, tracks, @@ -323,7 +316,7 @@ export function write(album, {wikiData}) { tracks.map(trackToListItem))), ])), - !displayTrackGroups && + !displayTrackSections && !empty(album.tracks) && html.tag(listTag, album.tracks.map(trackToListItem)), @@ -436,12 +429,9 @@ export function write(album, {wikiData}) { main: { classes: ['top-index'], - content: [ - html.tag('h1', - language.$('albumGalleryPage.title', { - album: album.name, - })), + headingMode: 'static', + content: [ html.tag('p', {class: 'quick-info'}, (album.date @@ -517,7 +507,7 @@ export function generateAlbumSidebar(album, currentTrack, { const listTag = getAlbumListTag(album); - const {trackGroups} = album; + const {trackSections} = album; const trackToListItem = (track) => html.tag('li', @@ -526,19 +516,19 @@ export function generateAlbumSidebar(album, currentTrack, { track: link.track(track), })); - const nameOrDefault = (isDefaultTrackGroup, name) => - isDefaultTrackGroup - ? language.$('albumSidebar.trackList.fallbackGroupName') + const nameOrDefault = (isDefaultTrackSection, name) => + isDefaultTrackSection + ? language.$('albumSidebar.trackList.fallbackSectionName') : name; const trackListPart = [ html.tag('h1', link.album(album)), - ...trackGroups.map(({name, color, startIndex, tracks, isDefaultTrackGroup}) => { + ...trackSections.map(({name, color, startIndex, tracks, isDefaultTrackSection}) => { const groupName = html.tag('span', {class: 'group-name'}, nameOrDefault( - isDefaultTrackGroup, + isDefaultTrackSection, name )); return html.tag('details', diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js index e2b16046..f867d123 100644 --- a/src/page/artist-alias.js +++ b/src/page/artist-alias.js @@ -1,6 +1,8 @@ // Artist alias redirect pages. // (Makes old permalinks bring visitors to the up-to-date page.) +export const description = `redirects for aliased artist names`; + export function targets({wikiData}) { return wikiData.artistAliasData; } diff --git a/src/page/artist.js b/src/page/artist.js index 235fe113..87859c89 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -15,6 +15,8 @@ import { sortChronologically, } from '../util/wiki-data.js'; +export const description = `per-artist info & artwork gallery pages`; + export function targets({wikiData}) { return wikiData.artistData; } @@ -319,9 +321,7 @@ export function write(artist, {wikiData}) { path: ['artist', artist.directory], page: ({ fancifyURL, - generateCoverLink, generateInfoGalleryLinks, - generateStickyHeadingContainer, getArtistAvatar, getArtistString, html, @@ -339,20 +339,15 @@ export function write(artist, {wikiData}) { return { title: language.$('artistPage.title', {artist: name}), - main: { - content: [ - artist.hasAvatar && - generateCoverLink({ - src: getArtistAvatar(artist), - alt: language.$('misc.alt.artistAvatar'), - }), + cover: artist.hasAvatar && { + src: getArtistAvatar(artist), + alt: language.$('misc.alt.artistAvatar'), + }, - generateStickyHeadingContainer({ - title: language.$('artistPage.title', { - artist: name, - }), - }), + main: { + headingMode: 'sticky', + content: [ ...html.fragment( contextNotes && [ html.tag('p', @@ -608,12 +603,9 @@ export function write(artist, {wikiData}) { main: { classes: ['top-index'], - content: [ - html.tag('h1', - language.$('artistGalleryPage.title', { - artist: name, - })), + headingMode: 'static', + content: [ html.tag('p', {class: 'quick-info'}, language.$('artistGalleryPage.infoLine', { diff --git a/src/page/flash.js b/src/page/flash.js index 581092a6..a9ce053d 100644 --- a/src/page/flash.js +++ b/src/page/flash.js @@ -3,6 +3,8 @@ import {empty} from '../util/sugar.js'; import {getFlashLink} from '../util/wiki-data.js'; +export const description = `flash & game pages`; + export function condition({wikiData}) { return wikiData.wikiInfo.enableFlashesAndGames; } @@ -18,9 +20,7 @@ export function write(flash, {wikiData}) { page: ({ fancifyFlashURL, generateChronologyLinks, - generateCoverLink, generateNavigationLinks, - generateStickyHeadingContainer, getArtistString, getFlashCover, getThemeString, @@ -38,19 +38,15 @@ export function write(flash, {wikiData}) { ], }), - main: { - content: [ - generateStickyHeadingContainer({ - title: language.$('flashPage.title', { - flash: flash.name, - }), - }), + cover: { + src: getFlashCover(flash), + alt: language.$('misc.alt.flashArt'), + }, - generateCoverLink({ - src: getFlashCover(flash), - alt: language.$('misc.alt.flashArt'), - }), + main: { + headingMode: 'sticky', + content: [ html.tag('p', language.$('releaseInfo.released', { date: language.formatDate(flash.date), @@ -144,30 +140,25 @@ export function writeTargetless({ main: { classes: ['flash-index'], - content: [ - html.tag('h1', - language.$('flashIndex.title')), + headingMode: 'static', - html.tag('div', - {class: 'long-content'}, - [ - html.tag('p', - {class: 'quick-info'}, - language.$('misc.jumpTo')), - - html.tag('ul', - {class: 'quick-info'}, - flashActData - .filter(act => act.jump) - .map(({anchor, jump, jumpColor}) => - html.tag('li', - html.tag('a', - { - href: '#' + anchor, - style: getLinkThemeString(jumpColor), - }, - jump)))), - ]), + content: [ + html.tag('p', + {class: 'quick-info'}, + language.$('misc.jumpTo')), + + html.tag('ul', + {class: 'quick-info'}, + flashActData + .filter(act => act.jump) + .map(({anchor, jump, jumpColor}) => + html.tag('li', + html.tag('a', + { + href: '#' + anchor, + style: getLinkThemeString(jumpColor), + }, + jump)))), ...flashActData.flatMap((act, i) => [ html.tag('h2', diff --git a/src/page/group.js b/src/page/group.js index f9af8e80..9a48c1d8 100644 --- a/src/page/group.js +++ b/src/page/group.js @@ -9,6 +9,8 @@ import { sortChronologically, } from '../util/wiki-data.js'; +export const description = `per-group info & album gallery pages`; + export function targets({wikiData}) { return wikiData.groupData; } @@ -31,7 +33,6 @@ export function write(group, {wikiData}) { fancifyURL, generateInfoGalleryLinks, generateNavigationLinks, - generateStickyHeadingContainer, getLinkThemeString, getThemeString, html, @@ -45,13 +46,9 @@ export function write(group, {wikiData}) { theme: getThemeString(group.color), main: { - content: [ - generateStickyHeadingContainer({ - title: language.$('groupInfoPage.title', { - group: group.name - }), - }), + headingMode: 'sticky', + content: [ !empty(group.urls) && html.tag('p', language.$('releaseInfo.visitOn', { @@ -144,12 +141,9 @@ export function write(group, {wikiData}) { main: { classes: ['top-index'], - content: [ - html.tag('h1', - language.$('groupGalleryPage.title', { - group: group.name, - })), + headingMode: 'static', + content: [ getCarouselHTML({ items: group.featuredAlbums.slice(0, 12 + 1), srcFn: getAlbumCover, diff --git a/src/page/homepage.js b/src/page/homepage.js index cb1e1da1..465152aa 100644 --- a/src/page/homepage.js +++ b/src/page/homepage.js @@ -11,6 +11,8 @@ import { getNewReleases, } from '../util/wiki-data.js'; +export const description = `main wiki homepage`; + export function writeTargetless({wikiData}) { const {newsData, homepageLayout, wikiInfo} = wikiData; @@ -82,7 +84,9 @@ export function writeTargetless({wikiData}) { main: { classes: ['top-index'], - content: html.fragment([ + headingMode: 'none', + + content: [ html.tag('h1', wikiInfo.name), @@ -134,7 +138,7 @@ export function writeTargetless({wikiData}) { })), ]), ]))), - ]), + ], }, sidebarLeft: homepageLayout.sidebarContent && { diff --git a/src/page/listing.js b/src/page/listing.js index dce38526..2412efe6 100644 --- a/src/page/listing.js +++ b/src/page/listing.js @@ -12,6 +12,8 @@ import {empty} from '../util/sugar.js'; import {getTotalDuration} from '../util/wiki-data.js'; +export const description = `wiki-wide listing pages & index`; + export function condition({wikiData}) { return wikiData.wikiInfo.enableListings; } @@ -32,7 +34,6 @@ export function write(listing, {wikiData}) { path: ['listing', listing.directory], page: (opts) => { const { - generateStickyHeadingContainer, getLinkThemeString, html, language, @@ -45,11 +46,9 @@ export function write(listing, {wikiData}) { title: language.$(titleKey), main: { - content: [ - generateStickyHeadingContainer({ - title: language.$(titleKey), - }), + headingMode: 'sticky', + content: [ ...html.fragment( listing.html && (listing.data @@ -109,10 +108,9 @@ export function writeTargetless({wikiData}) { title: language.$('listingIndex.title'), main: { - content: [ - html.tag('h1', - language.$('listingIndex.title')), + headingMode: 'static', + content: [ html.tag('p', language.$('listingIndex.infoLine', { wiki: wikiInfo.name, diff --git a/src/page/news.js b/src/page/news.js index 61a52dc0..00d1e4dc 100644 --- a/src/page/news.js +++ b/src/page/news.js @@ -1,5 +1,7 @@ // News entry & index page specifications. +export const description = `per-entry news pages & index`; + export function condition({wikiData}) { return wikiData.wikiInfo.enableNews; } @@ -14,7 +16,6 @@ export function write(entry, {wikiData}) { path: ['newsEntry', entry.directory], page: ({ generateNavigationLinks, - generateStickyHeadingContainer, html, language, link, @@ -23,22 +24,16 @@ export function write(entry, {wikiData}) { title: language.$('newsEntryPage.title', {entry: entry.name}), main: { + classes: ['long-content'], + headingMode: 'sticky', + content: [ - generateStickyHeadingContainer({ - class: ['long-content'], - title: language.$('newsEntryPage.title', { - entry: entry.name, - }), - }), - - html.tag('div', {class: 'long-content'}, [ - html.tag('p', - language.$('newsEntryPage.published', { - date: language.formatDate(entry.date), - })), - - transformMultiline(entry.content), - ]), + html.tag('p', + language.$('newsEntryPage.published', { + date: language.formatDate(entry.date), + })), + + transformMultiline(entry.content), ], }, @@ -62,7 +57,6 @@ export function writeTargetless({wikiData}) { type: 'page', path: ['newsIndex'], page: ({ - generateStickyHeadingContainer, html, language, link, @@ -71,37 +65,30 @@ export function writeTargetless({wikiData}) { title: language.$('newsIndex.title'), main: { - content: [ - generateStickyHeadingContainer({ - class: ['long-content'], - title: language.$('newsIndex.title'), - }), - - html.tag('div', - {class: ['long-content', 'news-index']}, - [ - ...newsData.map(entry => - html.tag('article', - {id: entry.directory}, - [ - html.tag('h2', [ - html.tag('time', - language.formatDate(entry.date)), - link.newsEntry(entry), - ]), - - transformMultiline(entry.contentShort), - - entry.contentShort !== entry.content && - html.tag('p', - link.newsEntry(entry, { - text: language.$( - 'newsIndex.entry.viewRest' - ), - })), - ])), - ]), - ], + classes: ['long-content', 'news-index'], + headingMode: 'sticky', + + content: + newsData.map(entry => + html.tag('article', + {id: entry.directory}, + [ + html.tag('h2', [ + html.tag('time', + language.formatDate(entry.date)), + link.newsEntry(entry), + ]), + + transformMultiline(entry.contentShort), + + entry.contentShort !== entry.content && + html.tag('p', + link.newsEntry(entry, { + text: language.$( + 'newsIndex.entry.viewRest' + ), + })), + ])), }, nav: {simple: true}, diff --git a/src/page/static.js b/src/page/static.js index 1689d16b..8572db4e 100644 --- a/src/page/static.js +++ b/src/page/static.js @@ -2,6 +2,8 @@ // wiki data folder, used for a variety of purposes, e.g. wiki info, // changelog, and so on.) +export const description = `static wiki-wide content pages specified in data`; + export function targets({wikiData}) { return wikiData.staticPageData; } @@ -11,22 +13,16 @@ export function write(staticPage) { type: 'page', path: ['staticPage', staticPage.directory], page: ({ - generateStickyHeadingContainer, - html, transformMultiline, }) => ({ title: staticPage.name, stylesheet: staticPage.stylesheet, main: { - content: [ - generateStickyHeadingContainer({ - class: ['long-content'], - title: staticPage.name, - }), - html.tag('div', {class: 'long-content'}, - transformMultiline(staticPage.content)), - ], + classes: ['long-content'], + headingMode: 'sticky', + + content: transformMultiline(staticPage.content), }, nav: {simple: true}, diff --git a/src/page/tag.js b/src/page/tag.js index da4f194a..81db6137 100644 --- a/src/page/tag.js +++ b/src/page/tag.js @@ -1,5 +1,7 @@ // Art tag page specification. +export const description = `per-artwork-tag gallery pages`; + export function condition({wikiData}) { return wikiData.wikiInfo.enableArtTagUI; } @@ -34,12 +36,9 @@ export function write(tag, {wikiData}) { main: { classes: ['top-index'], - content: [ - html.tag('h1', - language.$('tagPage.title', { - tag: tag.name, - })), + headingMode: 'static', + content: [ html.tag('p', {class: 'quick-info'}, language.$('tagPage.infoLine', { diff --git a/src/page/track.js b/src/page/track.js index 94c9c40a..caba3668 100644 --- a/src/page/track.js +++ b/src/page/track.js @@ -18,6 +18,8 @@ import { sortChronologically, } from '../util/wiki-data.js'; +export const description = `per-track info pages`; + export function targets({wikiData}) { return wikiData.trackData; } @@ -160,9 +162,7 @@ export function write(track, {wikiData}) { absoluteTo, fancifyURL, generateChronologyLinks, - generateCoverLink, generateNavigationLinks, - generateStickyHeadingContainer, generateTrackListDividedByGroups, getAlbumStylesheet, getArtistString, @@ -183,7 +183,6 @@ export function write(track, {wikiData}) { language, link, }); - const cover = getTrackCover(track); return { title: language.$('trackPage.title', {track: track.name}), @@ -222,22 +221,16 @@ export function write(track, {wikiData}) { }, */ - main: { - content: [ - generateStickyHeadingContainer({ - title: language.$('trackPage.title', {track: track.name}), - - coverSrc: cover, - coverAlt: language.$('misc.alt.trackCover'), - coverTags: track.artTags, - }), + cover: { + src: getTrackCover(track), + alt: language.$('misc.alt.trackCover'), + artTags: track.artTags, + }, - cover && generateCoverLink({ - src: cover, - alt: language.$('misc.alt.trackCover'), - tags: track.artTags, - }), + main: { + headingMode: 'sticky', + content: [ html.tag('p', { [html.onlyIfContent]: true, @@ -265,7 +258,7 @@ export function write(track, {wikiData}) { date: language.formatDate(track.date), }), - cover && + track.hasCoverArt && track.coverArtDate && +track.coverArtDate !== +track.date && language.$('releaseInfo.artReleased', { diff --git a/src/static/site2.css b/src/static/site3.css index 287bbd66..cc853b65 100644 --- a/src/static/site2.css +++ b/src/static/site3.css @@ -626,7 +626,8 @@ blockquote { margin-right: 0; } -.long-content { +main.long-content .main-content-container, +main.long-content > h1 { padding-left: 12%; padding-right: 12%; } @@ -1149,13 +1150,13 @@ html[data-url-key="localized.home"] .carousel-container { transform: translateY(-5px); } -.content-sticky-heading-container.long-content { +main.long-content .content-sticky-heading-container { padding-left: 0; padding-right: 0; } -.content-sticky-heading-container.long-content .content-sticky-heading-row, -.content-sticky-heading-container.long-content .content-sticky-subheading-row { +main.long-content .content-sticky-heading-container .content-sticky-heading-row, +main.long-content .content-sticky-heading-container .content-sticky-subheading-row { padding-left: calc(0.12 * (100% - 2 * var(--content-padding)) + var(--content-padding)); padding-right: calc(0.12 * (100% - 2 * var(--content-padding)) + var(--content-padding)); } diff --git a/src/strings-default.json b/src/strings-default.json index e5da1fab..0faa4f7c 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -192,7 +192,7 @@ "homepage.title": "{TITLE}", "homepage.news.title": "News", "homepage.news.entry.viewRest": "(View rest of entry!)", - "albumSidebar.trackList.fallbackGroupName": "Track list", + "albumSidebar.trackList.fallbackSectionName": "Track list", "albumSidebar.trackList.group": "{GROUP}", "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})", "albumSidebar.trackList.item": "{TRACK}", diff --git a/src/upd8.js b/src/upd8.js index 79b5747b..39372833 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -31,38 +31,19 @@ // Oh yeah, like. Just run this through some relatively recent version of // node.js and you'll 8e fine. ...Within the project root. O8viously. +import {execSync} from 'child_process'; import * as path from 'path'; import {fileURLToPath} from 'url'; - -import chroma from 'chroma-js'; - -import { - copyFile, - mkdir, - stat, - symlink, - writeFile, - unlink, -} from 'fs/promises'; - -import { execSync } from 'child_process'; +import wrap from 'word-wrap'; import genThumbs from './gen-thumbs.js'; import {listingSpec, listingTargetSpec} from './listing-spec.js'; import urlSpec from './url-spec.js'; -import * as pageSpecs from './page/index.js'; -import find, {bindFind} from './util/find.js'; -import * as html from './util/html.js'; -import {getColors} from './util/colors.js'; -import {findFiles} from './util/io.js'; -import {isMain} from './util/node-utils.js'; +import {processLanguageFile} from './data/language.js'; import CacheableObject from './data/things/cacheable-object.js'; -import {processLanguageFile} from './data/language.js'; -import {serializeThings} from './data/serialize.js'; - import { filterDuplicateDirectories, filterReferenceErrors, @@ -72,33 +53,18 @@ import { WIKI_INFO_FILE, } from './data/yaml.js'; -import { - fancifyFlashURL, - fancifyURL, - generateAdditionalFilesShortcut, - generateAdditionalFilesList, - generateChronologyLinks, - generateCoverLink, - generateInfoGalleryLinks, - generateNavigationLinks, - generateStickyHeadingContainer, - generateTrackListDividedByGroups, - getAlbumGridHTML, - getAlbumStylesheet, - getArtistString, - getFlashGridHTML, - getFooterLocalizationLinks, - getGridHTML, - getCarouselHTML, - getRevealStringFromTags, - getRevealStringFromWarnings, - getThemeString as unbound_getThemeString, - iconifyURL, -} from './misc-templates.js'; - -import unbound_link, { - getLinkThemeString as unbound_getLinkThemeString, -} from './util/link.js'; +import find from './util/find.js'; +import {findFiles} from './util/io.js'; +import link from './util/link.js'; +import {isMain} from './util/node-utils.js'; +import {validateReplacerSpec} from './util/replacer.js'; +import {empty, showAggregate, withEntries} from './util/sugar.js'; +import {replacerSpec} from './util/transform-content.js'; +import {generateURLs} from './util/urls.js'; +import {sortByName} from './util/wiki-data.js'; + +import {generateDevelopersCommentHTML} from './write/page-template.js'; +import * as buildModes from './write/build-modes/index.js'; import { color, @@ -111,43 +77,11 @@ import { progressPromiseAll, } from './util/cli.js'; -import {validateReplacerSpec, transformInline} from './util/replacer.js'; - -import { - getAlbumCover, - getArtistAvatar, - getFlashCover, - getTrackCover, -} from './util/wiki-data.js'; - -/* -import { - serializeContribs, - serializeCover, - serializeGroupsForAlbum, - serializeGroupsForTrack, - serializeImagePaths, - serializeLink, -} from './util/serialize.js'; -*/ - -import { - bindOpts, - queue, - showAggregate, - withEntries, -} from './util/sugar.js'; - -import {generateURLs, thumb} from './util/urls.js'; - -// Pensive emoji! -import { OFFICIAL_GROUP_DIRECTORY } from './util/magic-constants.js'; - import FileSizePreloader from './file-size-preloader.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CACHEBUST = 17; +const CACHEBUST = 18; let COMMIT; try { @@ -160,1508 +94,66 @@ const BUILD_TIME = new Date(); const DEFAULT_STRINGS_FILE = 'strings-default.json'; -// Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted -// site code should 8e put here. Which, uh, ~~only really means this one -// file~~ is now a variety of useful utilities! -// -// Rather than hard code it, anything in this directory can 8e shared across -// 8oth ends of the code8ase. -// (This gets symlinked into the --data-path directory.) -const UTILITY_DIRECTORY = 'util'; - -// Code that's used only in the static site! CSS, cilent JS, etc. -// (This gets symlinked into the --data-path directory.) -const STATIC_DIRECTORY = 'static'; - -// This exists adjacent to index.html for any page with oEmbed metadata. -const OEMBED_JSON_FILE = 'oembed.json'; - -// Automatically copied (if present) from media directory to site root. -const FAVICON_FILE = 'favicon.ico'; - -// Shared varia8les! These are more efficient to access than a shared varia8le -// (or at least I h8pe so), and are easier to pass across functions than a -// 8unch of specific arguments. -// -// Upd8: Okay yeah these aren't actually any different. Still cleaner than -// passing around a data object containing all this, though. -let dataPath; -let mediaPath; -let langPath; -let outputPath; - -// Glo8al data o8ject shared 8etween 8uild functions and all that. This keeps -// everything encapsul8ted in one place, so it's easy to pass and share across -// modules! -let wikiData = {}; - -let queueSize; - -const urls = generateURLs(urlSpec); - -function splitLines(text) { - return text.split(/\r\n|\r|\n/); -} - -const replacerSpec = { - album: { - find: 'album', - link: 'album', - }, - 'album-commentary': { - find: 'album', - link: 'albumCommentary', - }, - 'album-gallery': { - find: 'album', - link: 'albumGallery', - }, - artist: { - find: 'artist', - link: 'artist', - }, - 'artist-gallery': { - find: 'artist', - link: 'artistGallery', - }, - 'commentary-index': { - find: null, - link: 'commentaryIndex', - }, - date: { - find: null, - value: (ref) => new Date(ref), - html: (date, {language}) => - html.tag('time', - {datetime: date.toString()}, - language.formatDate(date)), - }, - 'flash-index': { - find: null, - link: 'flashIndex', - }, - flash: { - find: 'flash', - link: 'flash', - transformName(name, node, input) { - const nextCharacter = input[node.iEnd]; - const lastCharacter = name[name.length - 1]; - if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') { - return name.slice(0, -1); - } else { - return name; - } - }, - }, - group: { - find: 'group', - link: 'groupInfo', - }, - 'group-gallery': { - find: 'group', - link: 'groupGallery', - }, - home: { - find: null, - link: 'home', - }, - 'listing-index': { - find: null, - link: 'listingIndex', - }, - listing: { - find: 'listing', - link: 'listing', - }, - media: { - find: null, - link: 'media', - }, - 'news-index': { - find: null, - link: 'newsIndex', - }, - 'news-entry': { - find: 'newsEntry', - link: 'newsEntry', - }, - root: { - find: null, - link: 'root', - }, - site: { - find: null, - link: 'site', - }, - static: { - find: 'staticPage', - link: 'staticPage', - }, - string: { - find: null, - value: (ref) => ref, - html: (ref, {language, args}) => language.$(ref, args), - }, - tag: { - find: 'artTag', - link: 'tag', - }, - track: { - find: 'track', - link: 'track', - }, -}; - -if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) { +if (!validateReplacerSpec(replacerSpec, {find, link})) { process.exit(); } -function parseAttributes(string, {to}) { - const attributes = Object.create(null); - const skipWhitespace = (i) => { - const ws = /\s/; - if (ws.test(string[i])) { - const match = string.slice(i).match(/[^\s]/); - if (match) { - return i + match.index; - } else { - return string.length; - } - } else { - return i; - } - }; - - for (let i = 0; i < string.length; ) { - i = skipWhitespace(i); - const aStart = i; - const aEnd = i + string.slice(i).match(/[\s=]|$/).index; - const attribute = string.slice(aStart, aEnd); - i = skipWhitespace(aEnd); - if (string[i] === '=') { - i = skipWhitespace(i + 1); - let end, endOffset; - if (string[i] === '"' || string[i] === "'") { - end = string[i]; - endOffset = 1; - i++; - } else { - end = '\\s'; - endOffset = 0; - } - const vStart = i; - const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; - const value = string.slice(vStart, vEnd); - i = vEnd + endOffset; - if (attribute === 'src' && value.startsWith('media/')) { - attributes[attribute] = to('media.path', value.slice('media/'.length)); - } else { - attributes[attribute] = value; - } - } else { - attributes[attribute] = attribute; - } - } - return Object.fromEntries( - Object.entries(attributes).map(([key, val]) => [ - key, - val === 'true' - ? true - : val === 'false' - ? false - : val === key - ? true - : val, - ]) - ); -} - -function joinLineBreaks(sourceLines) { - const outLines = []; - - let lineSoFar = ''; - for (let i = 0; i < sourceLines.length; i++) { - const line = sourceLines[i]; - lineSoFar += line; - if (!line.endsWith('<br>')) { - outLines.push(lineSoFar); - lineSoFar = ''; - } - } - - if (lineSoFar) { - outLines.push(lineSoFar); - } - - return outLines; -} - -function transformMultiline(text, { - parseAttributes, - to, - transformInline, - thumb = null, -}) { - // Heck yes, HTML magics. - - text = transformInline(text.trim()); - - const outLines = []; - - const indentString = ' '.repeat(4); - - let levelIndents = []; - const openLevel = (indent) => { - // opening a sublist is a pain: to be semantically *and* visually - // correct, we have to append the <ul> at the end of the existing - // previous <li> - const previousLine = outLines[outLines.length - 1]; - if (previousLine?.endsWith('</li>')) { - // we will re-close the <li> later - outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>'; - } else { - // if the previous line isn't a list item, this is the opening of - // the first list level, so no need for indent - outLines.push('<ul>'); - } - levelIndents.push(indent); - }; - const closeLevel = () => { - levelIndents.pop(); - if (levelIndents.length) { - // closing a sublist, so close the list item containing it too - outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>'); - } else { - // closing the final list level! no need for indent here - outLines.push('</ul>'); - } - }; - - // okay yes we should support nested formatting, more than one blockquote - // layer, etc, but hear me out here: making all that work would basically - // be the same as implementing an entire markdown converter, which im not - // interested in doing lol. sorry!!! - let inBlockquote = false; - - let lines = splitLines(text); - lines = joinLineBreaks(lines); - for (let line of lines) { - const imageLine = line.startsWith('<img'); - line = line.replace(/<img (.*?)>/g, (match, attributes) => - img({ - lazy: true, - link: true, - thumb, - ...parseAttributes(attributes), - }) - ); - - let indentThisLine = 0; - let lineContent = line; - let lineTag = 'p'; - - const listMatch = line.match(/^( *)- *(.*)$/); - if (listMatch) { - // is a list item! - if (!levelIndents.length) { - // first level is always indent = 0, regardless of actual line - // content (this is to avoid going to a lesser indent than the - // initial level) - openLevel(0); - } else { - // find level corresponding to indent - const indent = listMatch[1].length; - let i; - for (i = levelIndents.length - 1; i >= 0; i--) { - if (levelIndents[i] <= indent) break; - } - // note: i cannot equal -1 because the first indentation level - // is always 0, and the minimum indentation is also 0 - if (levelIndents[i] === indent) { - // same indent! return to that level - while (levelIndents.length - 1 > i) closeLevel(); - // (if this is already the current level, the above loop - // will do nothing) - } else if (levelIndents[i] < indent) { - // lesser indent! branch based on index - if (i === levelIndents.length - 1) { - // top level is lesser: add a new level - openLevel(indent); - } else { - // lower level is lesser: return to that level - while (levelIndents.length - 1 > i) closeLevel(); - } - } - } - // finally, set variables for appending content line - indentThisLine = levelIndents.length; - lineContent = listMatch[2]; - lineTag = 'li'; - } else { - // not a list item! close any existing list levels - while (levelIndents.length) closeLevel(); - - // like i said, no nested shenanigans - quotes only appear outside - // of lists. sorry! - const quoteMatch = line.match(/^> *(.*)$/); - if (quoteMatch) { - // is a quote! open a blockquote tag if it doesnt already exist - if (!inBlockquote) { - inBlockquote = true; - outLines.push('<blockquote>'); - } - indentThisLine = 1; - lineContent = quoteMatch[1]; - } else if (inBlockquote) { - // not a quote! close a blockquote tag if it exists - inBlockquote = false; - outLines.push('</blockquote>'); - } - - // let some escaped symbols display as the normal symbol, since the - // point of escaping them is just to avoid having them be treated as - // syntax markers! - if (lineContent.match(/( *)\\-/)) { - lineContent = lineContent.replace('\\-', '-'); - } else if (lineContent.match(/( *)\\>/)) { - lineContent = lineContent.replace('\\>', '>'); - } - } - - if (lineTag === 'p') { - // certain inline element tags should still be postioned within a - // paragraph; other elements (e.g. headings) should be added as-is - const elementMatch = line.match(/^<(.*?)[ >]/); - if ( - elementMatch && - !imageLine && - ![ - 'a', - 'abbr', - 'b', - 'bdo', - 'br', - 'cite', - 'code', - 'data', - 'datalist', - 'del', - 'dfn', - 'em', - 'i', - 'img', - 'ins', - 'kbd', - 'mark', - 'output', - 'picture', - 'q', - 'ruby', - 'samp', - 'small', - 'span', - 'strong', - 'sub', - 'sup', - 'svg', - 'time', - 'var', - 'wbr', - ].includes(elementMatch[1]) - ) { - lineTag = ''; - } - - // for sticky headings! - if (elementMatch && elementMatch[1] === 'h2') { - lineContent = lineContent.replace(/<h2(.*?)>/g, (match, attributes) => { - const parsedAttributes = parseAttributes(attributes, {to}); - return `<h2 ${html.attributes({ - ...parsedAttributes, - class: [...parsedAttributes.class?.split(' ') ?? [], 'content-heading'], - })}>`; - }); - } - } - - let pushString = indentString.repeat(indentThisLine); - if (lineTag) { - pushString += `<${lineTag}>${lineContent}</${lineTag}>`; - } else { - pushString += lineContent; - } - outLines.push(pushString); - } - - // after processing all lines... - - // if still in a list, close all levels - while (levelIndents.length) closeLevel(); - - // if still in a blockquote, close its tag - if (inBlockquote) { - inBlockquote = false; - outLines.push('</blockquote>'); - } - - return outLines.join('\n'); -} - -function transformLyrics(text, {transformInline, transformMultiline}) { - // Different from transformMultiline 'cuz it joins multiple lines together - // with line 8reaks (<br>); transformMultiline treats each line as its own - // complete paragraph (or list, etc). - - // If it looks like old data, then like, oh god. - // Use the normal transformMultiline tool. - if (text.includes('<br')) { - return transformMultiline(text); - } - - text = transformInline(text.trim()); - - let buildLine = ''; - const addLine = () => outLines.push(`<p>${buildLine}</p>`); - const outLines = []; - for (const line of text.split('\n')) { - if (line.length) { - if (buildLine.length) { - buildLine += '<br>'; - } - buildLine += line; - } else if (buildLine.length) { - addLine(); - buildLine = ''; - } - } - if (buildLine.length) { - addLine(); - } - return outLines.join('\n'); -} - -function stringifyThings(thingData) { - return JSON.stringify(serializeThings(thingData)); -} - -function img({ - src, - alt, - noSrcText = '', - thumb: thumbKey, - reveal, - id, - class: className, - width, - height, - link = false, - lazy = false, - square = false, -}) { - const willSquare = square; - const willLink = typeof link === 'string' || link; - - const originalSrc = src; - const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src); - - const imgAttributes = { - id: link ? '' : id, - class: className, - alt, - width, - height, - }; +async function main() { + Error.stackTraceLimit = Infinity; - const noSrcHTML = - !src && - wrap( - html.tag('div', - {class: 'image-text-area'}, - noSrcText)); - - const nonlazyHTML = - src && - wrap( - html.tag('img', { - ...imgAttributes, - src: thumbSrc, - })); - - const lazyHTML = - src && - lazy && - wrap( - html.tag('img', - { - ...imgAttributes, - class: [className, 'lazy'], - 'data-original': thumbSrc, - }), - true); - - if (!src) { - return noSrcHTML; - } else if (lazy) { - return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML; + const defaultQueueSize = 500; + + const buildModeFlagOptions = ( + withEntries(buildModes, entries => + entries.map(([key, mode]) => [key, { + help: mode.description, + type: 'flag', + }]))); + + const selectedBuildModeFlags = Object.keys( + await parseOptions(process.argv.slice(2), { + [parseOptions.handleUnknown]: () => {}, + ...buildModeFlagOptions, + })); + + let selectedBuildModeFlag; + let usingDefaultBuildMode; + + if (empty(selectedBuildModeFlags)) { + selectedBuildModeFlag = 'static-build'; + usingDefaultBuildMode = true; + logInfo`No build mode specified, using default: ${selectedBuildModeFlag}`; + } else if (selectedBuildModeFlags.length > 1) { + logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`; + logError`Please specify a maximum of one build mode.`; + return; } else { - return nonlazyHTML; + selectedBuildModeFlag = selectedBuildModeFlags[0]; + usingDefaultBuildMode = false; + logInfo`Using specified build mode: ${selectedBuildModeFlag}`; } - function wrap(input, hide = false) { - let wrapped = input; - - wrapped = html.tag('div', {class: 'image-container'}, wrapped); - - if (reveal) { - wrapped = html.tag('div', {class: 'reveal'}, [ - wrapped, - html.tag('span', {class: 'reveal-text'}, reveal), - ]); - } - - if (willSquare) { - wrapped = html.tag('div', {class: 'square-content'}, wrapped); - wrapped = html.tag('div', - {class: ['square', hide && !willLink && 'js-hide']}, - wrapped); - } - - if (willLink) { - wrapped = html.tag('a', - { - id, - class: ['box', hide && 'js-hide'], - href: typeof link === 'string' ? link : originalSrc, - }, - wrapped); - } - - return wrapped; - } -} - -function validateWritePath(path, urlGroup) { - if (!Array.isArray(path)) { - return {error: `Expected array, got ${path}`}; - } - - const {paths} = urlGroup; - - const definedKeys = Object.keys(paths); - const specifiedKey = path[0]; - - if (!definedKeys.includes(specifiedKey)) { - return {error: `Specified key ${specifiedKey} isn't defined`}; - } - - const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0; - const specifiedArgs = path.length - 1; - - if (specifiedArgs !== expectedArgs) { - return { - error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`, - }; - } - - return {success: true}; -} - -function validateWriteObject(obj) { - if (typeof obj !== 'object') { - return {error: `Expected object, got ${typeof obj}`}; - } - - if (typeof obj.type !== 'string') { - return {error: `Expected type to be string, got ${obj.type}`}; - } + const selectedBuildMode = buildModes[selectedBuildModeFlag]; - switch (obj.type) { - case 'legacy': { - if (typeof obj.write !== 'function') { - return {error: `Expected write to be string, got ${obj.write}`}; - } - - break; - } - - case 'page': { - const path = validateWritePath(obj.path, urlSpec.localized); - if (path.error) { - return {error: `Path validation failed: ${path.error}`}; - } - - if (typeof obj.page !== 'function') { - return {error: `Expected page to be function, got ${obj.content}`}; - } - - break; - } - - case 'data': { - const path = validateWritePath(obj.path, urlSpec.data); - if (path.error) { - return {error: `Path validation failed: ${path.error}`}; - } - - if (typeof obj.data !== 'function') { - return {error: `Expected data to be function, got ${obj.data}`}; - } - - break; - } - - case 'redirect': { - const fromPath = validateWritePath(obj.fromPath, urlSpec.localized); - if (fromPath.error) { - return { - error: `Path (fromPath) validation failed: ${fromPath.error}`, - }; - } - - const toPath = validateWritePath(obj.toPath, urlSpec.localized); - if (toPath.error) { - return {error: `Path (toPath) validation failed: ${toPath.error}`}; - } - - if (typeof obj.title !== 'function') { - return {error: `Expected title to be function, got ${obj.title}`}; - } - - break; - } - - default: { - return {error: `Unknown type: ${obj.type}`}; - } - } - - return {success: true}; -} - -export function getURLsFrom({ - baseDirectory, - pageSubKey, - paths, -}) { - return (targetFullKey, ...args) => { - const [groupKey, subKey] = targetFullKey.split('.'); - let path = paths.subdirectoryPrefix; - - let from; - let to; - - // When linking to *outside* the localized area of the site, we need to - // make sure the result is correctly relative to the 8ase directory. - if ( - groupKey !== 'localized' && - groupKey !== 'localizedDefaultLanguage' && - baseDirectory - ) { - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = targetFullKey; - } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) { - // Special case for specifically linking *from* a page with base - // directory *to* a page without! Used for the language switcher and - // hopefully nothing else oh god. - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = 'localized.' + subKey; - } else if (groupKey === 'localizedDefaultLanguage') { - // Linking to the default, except surprise, we're already IN the default - // (no baseDirectory set). - from = 'localized.' + pageSubKey; - to = 'localized.' + subKey; - } else { - // If we're linking inside the localized area (or there just is no - // 8ase directory), the 8ase directory doesn't matter. - from = 'localized.' + pageSubKey; - to = targetFullKey; - } - - path += urls.from(from).to(to, ...args); - - return path; + // This is about to get a whole lot more stuff put in it. + const wikiData = { + listingSpec, + listingTargetSpec, }; -} - -export function generateDocumentHTML(pageInfo, { - defaultLanguage, - getThemeString, - language, - languages, - localizedPaths, - paths, - oEmbedJSONHref, - to, - transformMultiline, - wikiData, -}) { - const {wikiInfo} = wikiData; - - let { - title = '', - meta = {}, - theme = '', - stylesheet = '', - - showWikiNameInTitle = true, - themeColor = '', - - // missing properties are auto-filled, see below! - body = {}, - banner = {}, - main = {}, - sidebarLeft = {}, - sidebarRight = {}, - nav = {}, - secondaryNav = {}, - footer = {}, - socialEmbed = {}, - } = pageInfo; - - body.style ??= ''; - - theme = theme || getThemeString(wikiInfo.color); - - banner ||= {}; - banner.classes ??= []; - banner.src ??= ''; - banner.position ??= ''; - banner.dimensions ??= [0, 0]; - - main.classes ??= []; - main.content ??= ''; - - sidebarLeft ??= {}; - sidebarRight ??= {}; - - for (const sidebar of [sidebarLeft, sidebarRight]) { - sidebar.classes ??= []; - sidebar.content ??= ''; - sidebar.collapse ??= true; - } - nav.classes ??= []; - nav.content ??= ''; - nav.bottomRowContent ??= ''; - nav.links ??= []; - nav.linkContainerClasses ??= []; - - secondaryNav ??= {}; - secondaryNav.content ??= ''; - secondaryNav.content ??= ''; - - footer.classes ??= []; - footer.content ??= wikiInfo.footerContent - ? transformMultiline(wikiInfo.footerContent) - : ''; - - const colors = themeColor - ? getColors(themeColor, {chroma}) - : null; - - const canonical = wikiInfo.canonicalBase - ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname) - : ''; - - const localizedCanonical = wikiInfo.canonicalBase - ? Object.entries(localizedPaths).map(([code, {pathname}]) => ({ - lang: code, - href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname), - })) - : []; - - const collapseSidebars = - sidebarLeft.collapse !== false && sidebarRight.collapse !== false; - - const mainHTML = - main.content && - html.tag('main', - { - id: 'content', - class: main.classes, - }, - main.content); - - const footerHTML = - html.tag('footer', - { - [html.onlyIfContent]: true, - id: 'footer', - class: footer.classes, - }, - [ - html.tag('div', - { - [html.onlyIfContent]: true, - class: 'footer-content', - }, - footer.content), - - getFooterLocalizationLinks(paths.pathname, { - defaultLanguage, - html, - language, - languages, - paths, - to, - }), - ]); - - const generateSidebarHTML = (id, { - content, - multiple, - classes, - collapse = true, - wide = false, - - // 'last' - last or only sidebar box is sticky - // 'column' - entire column, incl. multiple boxes from top, is sticky - // 'none' - sidebar not sticky at all, stays at top of page - stickyMode = 'last', - }) => - content - ? html.tag('div', - { - id, - class: [ - 'sidebar-column', - 'sidebar', - wide && 'wide', - !collapse && 'no-hide', - stickyMode !== 'none' && 'sticky-' + stickyMode, - ...classes, - ], - }, - content) - : multiple - ? html.tag('div', - { - id, - class: [ - 'sidebar-column', - 'sidebar-multiple', - wide && 'wide', - !collapse && 'no-hide', - stickyMode !== 'none' && 'sticky-' + stickyMode, - ], - }, - multiple - .map((infoOrContent) => - (typeof infoOrContent === 'object' && !Array.isArray(infoOrContent)) - ? infoOrContent - : {content: infoOrContent}) - .filter(({content}) => content) - .map(({ - content, - classes: classes2 = [], - }) => - html.tag('div', - { - class: ['sidebar', ...classes, ...classes2], - }, - html.fragment(content)))) - : ''; - - const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft); - const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight); - - if (nav.simple) { - nav.linkContainerClasses = ['nav-links-hierarchy']; - nav.links = [{toHome: true}, {toCurrentPage: true}]; - } - - const links = (nav.links || []).filter(Boolean); - - const navLinkParts = []; - for (let i = 0; i < links.length; i++) { - let cur = links[i]; - - let {title: linkTitle} = cur; - - if (cur.toHome) { - linkTitle ??= wikiInfo.nameShort; - } else if (cur.toCurrentPage) { - linkTitle ??= title; - } + const buildOptions = selectedBuildMode.getCLIOptions(); - let partContent; - - if (typeof cur.html === 'string') { - partContent = cur.html; - } else { - const attributes = { - class: (cur.toCurrentPage || i === links.length - 1) && 'current', - href: cur.toCurrentPage - ? '' - : cur.toHome - ? to('localized.home') - : cur.path - ? to(...cur.path) - : cur.href - ? (() => { - logWarn`Using legacy href format nav link in ${paths.pathname}`; - return cur.href; - })() - : null, - }; - if (attributes.href === null) { - throw new Error( - `Expected some href specifier for link to ${linkTitle} (${JSON.stringify( - cur - )})` - ); - } - partContent = html.tag('a', attributes, linkTitle); - } - - if (!partContent) continue; - - const part = html.tag('span', - {class: cur.divider === false && 'no-divider'}, - partContent); - - navLinkParts.push(part); - } - - const navHTML = html.tag('nav', - { - [html.onlyIfContent]: true, - id: 'header', - class: [ - ...nav.classes, - links.length && 'nav-has-main-links', - nav.content && 'nav-has-content', - nav.bottomRowContent && 'nav-has-bottom-row', - ], - }, - [ - links.length && - html.tag( - 'div', - {class: ['nav-main-links', ...nav.linkContainerClasses]}, - navLinkParts - ), - nav.bottomRowContent && - html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent), - nav.content && html.tag('div', {class: 'nav-content'}, nav.content), - ]); - - const secondaryNavHTML = html.tag('nav', - { - [html.onlyIfContent]: true, - id: 'secondary-nav', - class: secondaryNav.classes, - }, - secondaryNav.content); - - const bannerSrc = banner.src - ? banner.src - : banner.path - ? to(...banner.path) - : null; - - const bannerHTML = - banner.position && - bannerSrc && - html.tag('div', - { - id: 'banner', - class: banner.classes, - }, - html.tag('img', { - src: bannerSrc, - alt: banner.alt, - width: banner.dimensions[0] || 1100, - height: banner.dimensions[1] || 200, - })); - - const layoutHTML = [ - navHTML, - banner.position === 'top' && bannerHTML, - secondaryNavHTML, - html.tag('div', - { - class: [ - 'layout-columns', - !collapseSidebars && 'vertical-when-thin', - (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar', - (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars', - !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars', - sidebarLeftHTML && 'has-sidebar-left', - sidebarRightHTML && 'has-sidebar-right', - ], - }, - [ - sidebarLeftHTML, - mainHTML, - sidebarRightHTML, - ]), - banner.position === 'bottom' && bannerHTML, - footerHTML, - ].filter(Boolean).join('\n'); - - const infoCardHTML = html.tag('div', {id: 'info-card-container'}, - html.tag('div', {id: 'info-card-decor'}, - html.tag('div', {id: 'info-card'}, [ - html.tag('div', {class: ['info-card-art-container', 'no-reveal']}, - img({ - class: 'info-card-art', - src: '', - link: true, - square: true, - })), - html.tag('div', {class: ['info-card-art-container', 'reveal']}, - img({ - class: 'info-card-art', - src: '', - link: true, - square: true, - reveal: getRevealStringFromWarnings( - html.tag('span', {class: 'info-card-art-warnings'}), - {html, language}), - })), - html.tag('h1', {class: 'info-card-name'}, - html.tag('a')), - html.tag('p', {class: 'info-card-album'}, - language.$('releaseInfo.from', { - album: html.tag('a'), - })), - html.tag('p', {class: 'info-card-artists'}, - language.$('releaseInfo.by', { - artists: html.tag('span'), - })), - html.tag('p', {class: 'info-card-cover-artists'}, - language.$('releaseInfo.coverArtBy', { - artists: html.tag('span'), - })), - ]))); - - const socialEmbedHTML = [ - socialEmbed.title && - html.tag('meta', {property: 'og:title', content: socialEmbed.title}), - - socialEmbed.description && - html.tag('meta', { - property: 'og:description', - content: socialEmbed.description, - }), - - socialEmbed.image && - html.tag('meta', {property: 'og:image', content: socialEmbed.image}), - - ...html.fragment( - colors && [ - html.tag('meta', { - name: 'theme-color', - content: colors.dark, - media: '(prefers-color-scheme: dark)', - }), - - html.tag('meta', { - name: 'theme-color', - content: colors.light, - media: '(prefers-color-scheme: light)', - }), - - html.tag('meta', { - name: 'theme-color', - content: colors.primary, - }), - ]), - - oEmbedJSONHref && - html.tag('link', { - type: 'application/json+oembed', - href: oEmbedJSONHref, - }), - ].filter(Boolean).join('\n'); - - return `<!DOCTYPE html>\n` + html.tag('html', - { - lang: language.intlCode, - 'data-language-code': language.code, - 'data-url-key': paths.urlPath[0], - ...Object.fromEntries( - paths.urlPath.slice(1).map((v, i) => [['data-url-value' + i], v]) - ), - 'data-rebase-localized': to('localized.root'), - 'data-rebase-shared': to('shared.root'), - 'data-rebase-media': to('media.root'), - 'data-rebase-data': to('data.root'), + const commonOptions = { + 'help': { + help: `Display usage info and basic information for the \`hsmusic\` command`, + type: 'flag', }, - [ - `<!--\n` + [ - wikiInfo.canonicalBase - ? `hsmusic.wiki - ${wikiInfo.name}, ${wikiInfo.canonicalBase}` - : `hsmusic.wiki - ${wikiInfo.name}`, - 'Code copyright 2019-2022 Quasar Nebula et al (MIT License)', - ...wikiInfo.canonicalBase === 'https://hsmusic.wiki/' ? [ - 'Data avidly compiled and localization brought to you', - 'by our awesome team and community of wiki contributors', - '***', - 'Want to contribute? Join our Discord or leave feedback!', - '- https://hsmusic.wiki/discord/', - '- https://hsmusic.wiki/feedback/', - '- https://github.com/hsmusic/', - ] : [ - 'https://github.com/hsmusic/', - ], - '***', - `Site built: ${BUILD_TIME.toLocaleString('en-US', { - dateStyle: 'long', - timeStyle: 'long', - })}`, - `Latest code commit: ${COMMIT}`, - ] - .filter(Boolean) - .map(line => ` ` + line) - .join('\n') + `\n-->`, - - html.tag('head', [ - html.tag('title', - showWikiNameInTitle - ? language.formatString('misc.pageTitle.withWikiName', { - title, - wikiName: wikiInfo.nameShort, - }) - : language.formatString('misc.pageTitle', {title})), - - html.tag('meta', {charset: 'utf-8'}), - html.tag('meta', { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }), - - ...( - Object.entries(meta) - .filter(([key, value]) => value) - .map(([key, value]) => html.tag('meta', {[key]: value}))), - - canonical && - html.tag('link', { - rel: 'canonical', - href: canonical, - }), - - ...( - localizedCanonical - .map(({lang, href}) => html.tag('link', { - rel: 'alternate', - hreflang: lang, - href, - }))), - - socialEmbedHTML, - - html.tag('link', { - rel: 'stylesheet', - href: to('shared.staticFile', `site2.css?${CACHEBUST}`), - }), - - html.tag('style', - {[html.onlyIfContent]: true}, - [ - theme, - stylesheet, - ]), - - html.tag('script', { - src: to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`), - }), - ]), - - html.tag('body', - {style: body.style || ''}, - [ - html.tag('div', {id: 'page-container'}, [ - mainHTML && - html.tag('div', {id: 'skippers'}, - [ - ['#content', language.$('misc.skippers.skipToContent')], - sidebarLeftHTML && - [ - '#sidebar-left', - sidebarRightHTML - ? language.$('misc.skippers.skipToSidebar.left') - : language.$('misc.skippers.skipToSidebar'), - ], - sidebarRightHTML && - [ - '#sidebar-right', - sidebarLeftHTML - ? language.$('misc.skippers.skipToSidebar.right') - : language.$('misc.skippers.skipToSidebar'), - ], - footerHTML && - ['#footer', language.$('misc.skippers.skipToFooter')], - ] - .filter(Boolean) - .map(([href, title]) => - html.tag('span', {class: 'skipper'}, - html.tag('a', {href}, title)))), - layoutHTML, - ]), - - infoCardHTML, - - html.tag('script', { - type: 'module', - src: to('shared.staticFile', `client.js?${CACHEBUST}`), - }), - ]), - ]); -} - -function generateOEmbedJSON(pageInfo, {language, wikiData}) { - const {socialEmbed} = pageInfo; - const {wikiInfo} = wikiData; - const {canonicalBase, nameShort} = wikiInfo; - - if (!socialEmbed) return ''; - - const entries = [ - socialEmbed.heading && [ - 'author_name', - language.$('misc.socialEmbed.heading', { - wikiName: nameShort, - heading: socialEmbed.heading, - }), - ], - socialEmbed.headingLink && - canonicalBase && [ - 'author_url', - canonicalBase.replace(/\/$/, '') + - '/' + - socialEmbed.headingLink.replace(/^\//, ''), - ], - ].filter(Boolean); - - if (!entries.length) return ''; - - return JSON.stringify(Object.fromEntries(entries)); -} - -async function writePage({ - html, - oEmbedJSON = '', - paths, -}) { - await mkdir(paths.output.directory, {recursive: true}); - - await Promise.all( - [ - writeFile(paths.output.documentHTML, html), - - oEmbedJSON && - writeFile(paths.output.oEmbedJSON, oEmbedJSON), - ].filter(Boolean) - ); -} - -function getPagePaths({ - baseDirectory, - fullKey, - urlArgs, - - file = 'index.html', -}) { - const [groupKey, subKey] = fullKey.split('.'); - - const pathname = - groupKey === 'localized' && baseDirectory - ? urls - .from('shared.root') - .toDevice( - 'localizedWithBaseDirectory.' + subKey, - baseDirectory, - ...urlArgs) - : urls - .from('shared.root') - .toDevice(fullKey, ...urlArgs); - - // Needed for the rare path arguments which themselves contains one or more - // slashes, e.g. for listings, with arguments like 'albums/by-name'. - const subdirectoryPrefix = - '../'.repeat(urlArgs.join('/').split('/').length - 1); - - const outputDirectory = path.join(outputPath, pathname); - - const output = { - directory: outputDirectory, - documentHTML: path.join(outputDirectory, file), - oEmbedJSON: path.join(outputDirectory, OEMBED_JSON_FILE) - }; - return { - urlPath: [fullKey, ...urlArgs], - - output, - pathname, - subdirectoryPrefix, - }; -} - -async function writeFavicon() { - try { - await stat(path.join(mediaPath, FAVICON_FILE)); - } catch (error) { - return; - } - - try { - await copyFile( - path.join(mediaPath, FAVICON_FILE), - path.join(outputPath, FAVICON_FILE) - ); - } catch (error) { - logWarn`Failed to copy favicon! ${error.message}`; - return; - } - - logInfo`Copied favicon to site root.`; -} - -function writeSymlinks() { - return progressPromiseAll('Writing site symlinks.', [ - link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'), - link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'), - link(mediaPath, 'media.root'), - ]); - - async function link(directory, urlKey) { - const pathname = urls.from('shared.root').toDevice(urlKey); - const file = path.join(outputPath, pathname); - try { - await unlink(file); - } catch (error) { - if (error.code !== 'ENOENT') { - throw error; - } - } - try { - await symlink(path.resolve(directory), file); - } catch (error) { - if (error.code === 'EPERM') { - await symlink(path.resolve(directory), file, 'junction'); - } - } - } -} - -function writeSharedFilesAndPages({language, wikiData}) { - const {groupData, wikiInfo} = wikiData; - - const redirect = async (title, from, urlKey, directory) => { - const target = path.relative( - from, - urls.from('shared.root').to(urlKey, directory) - ); - const content = generateRedirectHTML(title, target, {language}); - await mkdir(path.join(outputPath, from), {recursive: true}); - await writeFile(path.join(outputPath, from, 'index.html'), content); - }; - - return progressPromiseAll(`Writing files & pages shared across languages.`, [ - groupData?.some((group) => group.directory === 'fandom') && - redirect( - 'Fandom - Gallery', - 'albums/fandom', - 'localized.groupGallery', - 'fandom' - ), - - groupData?.some((group) => group.directory === 'official') && - redirect( - 'Official - Gallery', - 'albums/official', - 'localized.groupGallery', - 'official' - ), - - wikiInfo.enableListings && - redirect( - 'Album Commentary', - 'list/all-commentary', - 'localized.commentaryIndex', - '' - ), - - writeFile( - path.join(outputPath, 'data.json'), - ( - '{\n' + - [ - `"albumData": ${stringifyThings(wikiData.albumData)},`, - wikiInfo.enableFlashesAndGames && - `"flashData": ${stringifyThings(wikiData.flashData)},`, - `"artistData": ${stringifyThings(wikiData.artistData)}`, - ] - .filter(Boolean) - .map(line => ' ' + line) - .join('\n') + - '\n}')), - ].filter(Boolean)); -} - -function generateRedirectHTML(title, target, {language}) { - return `<!DOCTYPE html>\n` + html.tag('html', [ - html.tag('head', [ - html.tag('title', language.$('redirectPage.title', {title})), - html.tag('meta', {charset: 'utf-8'}), - - html.tag('meta', { - 'http-equiv': 'refresh', - content: `0;url=${target}`, - }), - - // TODO: Is this OK for localized pages? - html.tag('link', { - rel: 'canonical', - href: target, - }), - ]), - - html.tag('body', - html.tag('main', [ - html.tag('h1', - language.$('redirectPage.title', {title})), - html.tag('p', - language.$('redirectPage.infoLine', { - target: html.tag('a', {href: target}, target), - })), - ])), - ]); -} - -// Wrapper function for running a function once for all languages. -async function wrapLanguages(fn, {languages, writeOneLanguage = null}) { - const k = writeOneLanguage; - const languagesToRun = k ? {[k]: languages[k]} : languages; - - const entries = Object.entries(languagesToRun).filter( - ([key]) => key !== 'default' - ); - - for (let i = 0; i < entries.length; i++) { - const [_key, language] = entries[i]; - - await fn(language, i, entries); - } -} - -async function main() { - Error.stackTraceLimit = Infinity; - - const WD = wikiData; - - WD.listingSpec = listingSpec; - WD.listingTargetSpec = listingTargetSpec; - - const miscOptions = await parseOptions(process.argv.slice(2), { // Data files for the site, including flash, artist, and al8um data, // and like a jillion other things too. Pretty much everything which // makes an individual wiki what it is goes here! 'data-path': { + help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building, but may be provided via the HSMUSIC_DATA environment variable instead`, type: 'value', }, @@ -1669,6 +161,7 @@ async function main() { // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants // near the top of this file (upd8.js). 'media-path': { + help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building, but may be provided via the HSMUSIC_MEDIA environment variable instead`, type: 'value', }, @@ -1683,16 +176,7 @@ async function main() { // 8uild with the default (English) strings if this path is left // unspecified. 'lang-path': { - type: 'value', - }, - - // This is the output directory. It's the one you'll upload online with - // rsync or whatever when you're pushing an upd8, and also the one - // you'd archive if you wanted to make a 8ackup of the whole dang - // site. Just keep in mind that the gener8ted result will contain a - // couple symlinked directories, so if you're uploading, you're pro8a8ly - // gonna want to resolve those yourself. - 'out-path': { + help: `Specify path to language directory, including JSON files that mapping internal string keys to localized language content, and various language metadata`, type: 'value', }, @@ -1700,12 +184,14 @@ async function main() { // kinda a pain to run every time, since it does necessit8te reading // every media file at run time. Pass this to skip it. 'skip-thumbs': { + help: `Skip processing and generating thumbnails in media directory (speeds up subsequent builds, but remove this option [or use --thumbs-only] and re-run once when you add or modify media files to ensure thumbnails stay up-to-date!)`, type: 'flag', }, // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can // pass this flag! It exits 8efore 8uilding the rest of the site. 'thumbs-only': { + help: `Skip everything besides processing media directory and generating up-to-date thumbnails (useful when using --skip-thumbs for most runs)`, type: 'flag', }, @@ -1713,20 +199,7 @@ async function main() { // generating site HTML yet? This flag will cut execution off right // 8efore any site 8uilding actually happens. 'no-build': { - type: 'flag', - }, - - // Only want to 8uild one language during testing? This can chop down - // 8uild times a pretty 8ig chunk! Just pass a single language code. - lang: { - type: 'value', - }, - - // Working without a dev server and just using file:// URLs in your we8 - // 8rowser? This will automatically append index.html to links across - // the site. Not recommended for production, since it isn't guaranteed - // 100% error-free (and index.html-style links are less pretty anyway). - 'append-index-html': { + help: `Don't run a build of the site at all; only process data/media and report any errors detected`, type: 'flag', }, @@ -1735,10 +208,12 @@ async function main() { // line) right to your output, 8ut also pro8a8ly give you a headache // 8ecause wow that is a lot of visual noise. 'show-traces': { + help: `Show JavaScript source code paths for reported errors in "aggregate" error displays\n\n(Debugging use only, but please enable this if you're reporting bugs for our issue tracker!)`, type: 'flag', }, 'queue-size': { + help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`, type: 'value', validate(size) { if (parseInt(size) !== parseFloat(size)) return 'an integer'; @@ -1752,6 +227,7 @@ async function main() { // CacheableObject in a mode where every instance is a Proxy which will // keep track of invalid property accesses. 'show-invalid-property-accesses': { + help: `Report accesses at runtime to nonexistant properties on wiki data objects, at a dramatic performance cost\n(Internal/development use only)`, type: 'flag', }, @@ -1765,18 +241,126 @@ async function main() { // efficiency of data calculation or write generation separately instead of // mixed together). 'precache-data': { + help: `Compute all runtime-cached values for wiki data objects before proceeding to site build (optimizes rate of content generation/serving, but waits a lot longer before build actually starts, and may compute data which is never required for this build)`, type: 'flag', }, + }; + + const cliOptions = await parseOptions(process.argv.slice(2), { + // We don't want to error when we receive these options, so specify them + // here, even though we won't be doing anything with them later. + // (This is a bit of a hack.) + ...buildModeFlagOptions, - [parseOptions.handleUnknown]: () => {}, + ...commonOptions, + ...buildOptions, }); - dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA; - mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA; - langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset! - outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT; + if (cliOptions['help']) { + const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)}); + + const showOptions = (msg, options) => { + console.log(color.bright(msg)); + + const entries = Object.entries(options); + const sortedOptions = sortByName(entries + .map(([name, descriptor]) => ({name, descriptor}))); + + if (!sortedOptions.length) { + console.log(`(No options available)`) + } + + let justInsertedPaddingLine = false; + + for (const {name, descriptor} of sortedOptions) { + if (descriptor.alias) { + continue; + } + + const aliases = entries + .filter(([_name, {alias}]) => alias === name) + .map(([name]) => name); + + let wrappedHelp, wrappedHelpLines = 0; + if (descriptor.help) { + wrappedHelp = indentWrap(4, descriptor.help); + wrappedHelpLines = wrappedHelp.split('\n').length; + } + + if (wrappedHelpLines > 0 && !justInsertedPaddingLine) { + console.log(''); + } + + console.log(color.bright(` --` + name) + + (aliases.length + ? ` (or: ${aliases.map(alias => color.bright(`--` + alias)).join(', ')})` + : '') + + (descriptor.help + ? '' + : color.dim(' (no help provided)'))); + + if (wrappedHelp) { + console.log(wrappedHelp); + } + + if (wrappedHelpLines > 1) { + console.log(''); + justInsertedPaddingLine = true; + } else { + justInsertedPaddingLine = false; + } + } + + if (!justInsertedPaddingLine) { + console.log(``); + } + }; + + console.log( + color.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) + + `static wiki software cataloguing collaborative creation\n`); + + console.log(indentWrap(0, + `The \`hsmusic\` command provides basic control over all parts of generating user-visible HTML pages and website content/structure from provided data, media, and language directories.\n` + + `\n` + + `CLI options are divided into three groups:\n`)); + console.log(` 1) ` + indentWrap(4, + `Common options: These are shared by all build modes and always have the same essential behavior`).trim()); + console.log(` 2) ` + indentWrap(4, + `Build mode selection: One build mode may be selected (or else the default, --static-build, is used), and it decides which entire set of behavior to use for providing site content to the user`).trim()); + console.log(` 3) ` + indentWrap(4, + `Build options: Each build mode has a set of unique options which customize behavior for that build mode`).trim()); + console.log(``); - const writeOneLanguage = miscOptions['lang']; + showOptions(`Common options`, commonOptions); + showOptions(`Build mode selection`, buildModeFlagOptions); + + if (buildOptions) { + showOptions(`Build options for --${selectedBuildModeFlag} (${ + usingDefaultBuildMode ? 'default' : 'selected' + })`, buildOptions); + } + + return; + } + + const dataPath = cliOptions['data-path'] || process.env.HSMUSIC_DATA; + const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA; + const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset! + + const skipThumbs = cliOptions['skip-thumbs'] ?? false; + const thumbsOnly = cliOptions['thumbs-only'] ?? false; + const noBuild = cliOptions['no-build'] ?? false; + + const showAggregateTraces = cliOptions['show-traces'] ?? false; + + const precacheData = cliOptions['precache-data'] ?? false; + const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false; + + // Makes writing nicer on the CPU and file I/O parts of the OS, with a + // marginal performance deficit while waiting for file writes to finish + // before proceeding to more page processing. + const queueSize = +(cliOptions['queue-size'] ?? defaultQueueSize); { let errored = false; @@ -1788,46 +372,11 @@ async function main() { }; error(!dataPath, `Expected --data-path option or HSMUSIC_DATA to be set`); error(!mediaPath, `Expected --media-path option or HSMUSIC_MEDIA to be set`); - error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`); if (errored) { return; } } - const appendIndexHTML = miscOptions['append-index-html'] ?? false; - if (appendIndexHTML) { - logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`; - unbound_link.globalOptions.appendIndexHTML = true; - } - - const skipThumbs = miscOptions['skip-thumbs'] ?? false; - const thumbsOnly = miscOptions['thumbs-only'] ?? false; - const noBuild = miscOptions['no-build'] ?? false; - const showAggregateTraces = miscOptions['show-traces'] ?? false; - const precacheData = miscOptions['precache-data'] ?? false; - - // NOT for ena8ling or disa8ling specific features of the site! - // This is only in charge of what general groups of files to 8uild. - // They're here to make development quicker when you're only working - // on some particular area(s) of the site rather than making changes - // across all of them. - const writeFlags = await parseOptions(process.argv.slice(2), { - all: {type: 'flag'}, // Defaults to true if none 8elow specified. - - // Kinda a hack t8h! - ...Object.fromEntries( - Object.keys(pageSpecs).map((key) => [key, {type: 'flag'}]) - ), - - [parseOptions.handleUnknown]: () => {}, - }); - - const writeAll = !Object.keys(writeFlags).length || writeFlags.all; - - logInfo`Writing site pages: ${ - writeAll ? 'all' : Object.keys(writeFlags).join(', ') - }`; - const niceShowAggregate = (error, ...opts) => { showAggregate(error, { showTraces: showAggregateTraces, @@ -1851,9 +400,6 @@ async function main() { if (thumbsOnly) return; } - const showInvalidPropertyAccesses = - miscOptions['show-invalid-property-accesses'] ?? false; - if (showInvalidPropertyAccesses) { CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; } @@ -1912,7 +458,7 @@ async function main() { } } - if (!WD.wikiInfo) { + if (!wikiData.wikiInfo) { logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`; return; } @@ -1985,9 +531,7 @@ async function main() { progressCallAll('Caching all data values', Object.entries(wikiData) .filter(([key]) => key !== 'listingSpec' && - key !== 'listingTargetSpec' && - key !== 'officialAlbumData' && - key !== 'fandomAlbumData') + key !== 'listingTargetSpec') .map(([key, value]) => key === 'wikiInfo' ? [key, [value]] : key === 'homepageLayout' ? [key, [value]] : @@ -1997,8 +541,7 @@ async function main() { } const internalDefaultLanguage = await processLanguageFile( - path.join(__dirname, DEFAULT_STRINGS_FILE) - ); + path.join(__dirname, DEFAULT_STRINGS_FILE)); let languages; if (langPath) { @@ -2006,28 +549,25 @@ async function main() { filter: (f) => path.extname(f) === '.json', }); - const results = await progressPromiseAll( - `Reading & processing language files.`, - languageDataFiles.map((file) => processLanguageFile(file)) - ); + const results = await progressPromiseAll(`Reading & processing language files.`, + languageDataFiles.map((file) => processLanguageFile(file))); languages = Object.fromEntries( - results.map((language) => [language.code, language]) - ); + results.map((language) => [language.code, language])); } else { languages = {}; } const customDefaultLanguage = - languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code]; + languages[wikiData.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code]; let finalDefaultLanguage; if (customDefaultLanguage) { logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`; customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings; finalDefaultLanguage = customDefaultLanguage; - } else if (WD.wikiInfo.defaultLanguage) { - logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`; + } else if (wikiData.wikiInfo.defaultLanguage) { + logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`; if (langPath) { logError`Check if an appropriate file exists in ${langPath}?`; } else { @@ -2051,22 +591,15 @@ async function main() { if (noBuild) { logInfo`Not generating any site or page files this run (--no-build passed).`; - } else if (writeOneLanguage && !(writeOneLanguage in languages)) { - logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`; - return; - } else if (writeOneLanguage) { - logInfo`Writing only language ${writeOneLanguage} this run.`; - } else { - logInfo`Writing all languages.`; } { const tagRefs = new Set( - [...WD.trackData, ...WD.albumData] + [...wikiData.trackData, ...wikiData.albumData] .flatMap((thing) => thing.artTagsByRef ?? [])); for (const ref of tagRefs) { - if (find.artTag(ref, WD.artTagData)) { + if (find.artTag(ref, wikiData.artTagData)) { tagRefs.delete(ref); } } @@ -2079,10 +612,7 @@ async function main() { } } - WD.officialAlbumData = WD.albumData - .filter((album) => album.groups.some((group) => group.directory === OFFICIAL_GROUP_DIRECTORY)); - WD.fandomAlbumData = WD.albumData - .filter((album) => album.groups.every((group) => group.directory !== OFFICIAL_GROUP_DIRECTORY)); + const urls = generateURLs(urlSpec); const fileSizePreloader = new FileSizePreloader(); @@ -2094,7 +624,7 @@ async function main() { // function between them so that when site code requests a site path, // it'll get the size of the file at the corresponding device path. const additionalFilePaths = [ - ...WD.albumData.flatMap((album) => + ...wikiData.albumData.flatMap((album) => [ ...(album.additionalFiles ?? []), ...album.tracks.flatMap((track) => track.additionalFiles ?? []), @@ -2130,503 +660,55 @@ async function main() { if (noBuild) return; - // Makes writing nicer on the CPU and file I/O parts of the OS, with a - // marginal performance deficit while waiting for file writes to finish - // before proceeding to more page processing. - queueSize = +(miscOptions['queue-size'] ?? 500); - - const buildDictionary = pageSpecs; - - await writeFavicon(); - await writeSymlinks(); - await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData}); - - const buildSteps = writeAll - ? Object.entries(buildDictionary) - : Object.entries(buildDictionary).filter(([flag]) => writeFlags[flag]); - - let writes; - { - let error = false; - - const buildStepsWithTargets = buildSteps - .map(([flag, pageSpec]) => { - // Condition not met: skip this build step altogether. - if (pageSpec.condition && !pageSpec.condition({wikiData})) { - return null; - } - - // May still call writeTargetless if present. - if (!pageSpec.targets) { - return {flag, pageSpec, targets: []}; - } - - if (!pageSpec.write) { - logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`; - error = true; - return null; - } - - const targets = pageSpec.targets({wikiData}); - if (!Array.isArray(targets)) { - logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`; - error = true; - return null; - } - - return {flag, pageSpec, targets}; - }) - .filter(Boolean); - - if (error) { - return; - } - - const validateWrites = (writes, fnName) => { - // Do a quick valid8tion! If one of the writeThingPages functions go - // wrong, this will stall out early and tell us which did. - - if (!Array.isArray(writes)) { - logError`${fnName} didn't return an array!`; - error = true; - return false; - } - - if ( - !( - writes.every((obj) => typeof obj === 'object') && - writes.every((obj) => { - const result = validateWriteObject(obj); - if (result.error) { - logError`Validating write object failed: ${result.error}`; - return false; - } else { - return true; - } - }) - ) - ) { - logError`${fnName} returned invalid entries!`; - error = true; - return false; - } - - return true; - }; - - // return; - - writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => { - const writesFns = targets.map(target => () => { - const writes = pageSpec.write(target, {wikiData})?.slice() || []; - return validateWrites(writes, flag + '.write') ? writes : []; - }); - - if (pageSpec.writeTargetless) { - writesFns.push(() => { - const writes = pageSpec.writeTargetless({wikiData}); - return validateWrites(writes, flag + '.writeTargetless') ? writes : []; - }); - } - - return writesFns; - })).flat(); - - if (error) { - return; - } - } - - const pageWrites = writes.filter(({type}) => type === 'page'); - const dataWrites = writes.filter(({type}) => type === 'data'); - const redirectWrites = writes.filter(({type}) => type === 'redirect'); - - if (writes.length) { - logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`; - } else { - logWarn`No writes returned at all, so exiting early. This is probably a bug!`; - return; - } + const developersComment = generateDevelopersCommentHTML({ + buildTime: BUILD_TIME, + commit: COMMIT, + wikiData, + }); - /* - await progressPromiseAll(`Writing data files shared across languages.`, queue( - dataWrites.map(({path, data}) => () => { - const bound = {}; - - bound.serializeLink = bindOpts(serializeLink, {}); - - bound.serializeContribs = bindOpts(serializeContribs, {}); - - bound.serializeImagePaths = bindOpts(serializeImagePaths, { - thumb - }); - - bound.serializeCover = bindOpts(serializeCover, { - [bindOpts.bindIndex]: 2, - serializeImagePaths: bound.serializeImagePaths, - urls - }); - - bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, { - serializeLink - }); - - bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, { - serializeLink - }); - - // TODO: This only supports one <>-style argument. - return writeData(path[0], path[1], data({...bound})); - }), - queueSize - )); - */ - - const perLanguageFn = async (language, i, entries) => { - const baseDirectory = - language === finalDefaultLanguage ? '' : language.code; - - console.log(`\x1b[34;1m${`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) `.padEnd(60, '-')}\x1b[0m`); - - await progressPromiseAll(`Writing ${language.code}`, queue([ - ...pageWrites.map((props) => () => { - const {path, page} = props; - - const pageSubKey = path[0]; - const urlArgs = path.slice(1); - - const localizedPaths = Object.fromEntries( - Object.entries(languages) - .filter(([key, language]) => - key !== 'default' && - !language.hidden) - .map(([_key, language]) => [ - language.code, - getPagePaths({ - baseDirectory: - (language === finalDefaultLanguage - ? '' - : language.code), - fullKey: 'localized.' + pageSubKey, - urlArgs, - }), - ])); - - const paths = getPagePaths({ - baseDirectory, - fullKey: 'localized.' + pageSubKey, - urlArgs, - }); - - const to = getURLsFrom({ - baseDirectory, - pageSubKey, - paths, - }); - - const absoluteTo = (targetFullKey, ...args) => { - const [groupKey, subKey] = targetFullKey.split('.'); - const from = urls.from('shared.root'); - return ( - '/' + - (groupKey === 'localized' && baseDirectory - ? from.to( - 'localizedWithBaseDirectory.' + subKey, - baseDirectory, - ...args - ) - : from.to(targetFullKey, ...args)) - ); - }; - - // TODO: Is there some nicer way to define these, - // may8e without totally re-8inding everything for - // each page? - const bound = {}; - - bound.html = html; - - bound.getColors = bindOpts(getColors, { - chroma, - }); - - bound.getLinkThemeString = bindOpts(unbound_getLinkThemeString, { - getColors: bound.getColors, - }); - - bound.getThemeString = bindOpts(unbound_getThemeString, { - getColors: bound.getColors, - }); - - bound.link = withEntries(unbound_link, (entries) => - entries - .map(([key, fn]) => [key, bindOpts(fn, { - getLinkThemeString: bound.getLinkThemeString, - to, - })])); - - bound.parseAttributes = bindOpts(parseAttributes, { - to, - }); - - bound.find = bindFind(wikiData, {mode: 'warn'}); - - bound.transformInline = bindOpts(transformInline, { - find: bound.find, - link: bound.link, - replacerSpec, - language, - to, - wikiData, - }); - - bound.transformMultiline = bindOpts(transformMultiline, { - transformInline: bound.transformInline, - parseAttributes: bound.parseAttributes, - to, - }); - - bound.transformLyrics = bindOpts(transformLyrics, { - transformInline: bound.transformInline, - transformMultiline: bound.transformMultiline, - }); - - bound.iconifyURL = bindOpts(iconifyURL, { - html, - language, - to, - }); - - bound.fancifyURL = bindOpts(fancifyURL, { - html, - language, - }); - - bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { - [bindOpts.bindIndex]: 2, - html, - language, - - fancifyURL: bound.fancifyURL, - }); - - bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, { - html, - language, - }); - - bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, { - language, - - getRevealStringFromWarnings: bound.getRevealStringFromWarnings, - }); - - bound.getArtistString = bindOpts(getArtistString, { - html, - link: bound.link, - language, - - iconifyURL: bound.iconifyURL, - }); - - bound.getAlbumCover = bindOpts(getAlbumCover, { - to, - }); - - bound.getTrackCover = bindOpts(getTrackCover, { - to, - }); - - bound.getFlashCover = bindOpts(getFlashCover, { - to, - }); - - bound.getArtistAvatar = bindOpts(getArtistAvatar, { - to, - }); - - bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, { - html, - language, - }); - - bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, { - html, - language, - }); - - bound.generateNavigationLinks = bindOpts(generateNavigationLinks, { - link: bound.link, - language, - }); - - bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, { - [bindOpts.bindIndex]: 0, - getRevealStringFromTags: bound.getRevealStringFromTags, - html, - img, - }); - - bound.generateChronologyLinks = bindOpts(generateChronologyLinks, { - html, - language, - link: bound.link, - wikiData, - - generateNavigationLinks: bound.generateNavigationLinks, - }); - - bound.generateCoverLink = bindOpts(generateCoverLink, { - [bindOpts.bindIndex]: 0, - html, - img, - link: bound.link, - language, - to, - wikiData, - - getRevealStringFromTags: bound.getRevealStringFromTags, - }); - - bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, { - [bindOpts.bindIndex]: 2, - link: bound.link, - language, - }); - - bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, { - html, - language, - wikiData, - }); - - bound.getGridHTML = bindOpts(getGridHTML, { - [bindOpts.bindIndex]: 0, - img, - html, - language, - - getRevealStringFromTags: bound.getRevealStringFromTags, - }); - - bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, { - [bindOpts.bindIndex]: 0, - link: bound.link, - language, - - getAlbumCover: bound.getAlbumCover, - getGridHTML: bound.getGridHTML, - }); - - bound.getFlashGridHTML = bindOpts(getFlashGridHTML, { - [bindOpts.bindIndex]: 0, - link: bound.link, - - getFlashCover: bound.getFlashCover, - getGridHTML: bound.getGridHTML, - }); - - bound.getCarouselHTML = bindOpts(getCarouselHTML, { - [bindOpts.bindIndex]: 0, - img, - html, - }) - - bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { - to, - }); - - const pageInfo = page({ - ...bound, - - language, - - absoluteTo, - relativeTo: to, - to, - urls, - - getSizeOfAdditionalFile, - }); - - const oEmbedJSON = generateOEmbedJSON(pageInfo, { - language, - wikiData, - }); - - const oEmbedJSONHref = - oEmbedJSON && - wikiData.wikiInfo.canonicalBase && - wikiData.wikiInfo.canonicalBase + - urls - .from('shared.root') - .to('shared.path', paths.pathname + OEMBED_JSON_FILE); - - const pageHTML = generateDocumentHTML(pageInfo, { - defaultLanguage: finalDefaultLanguage, - getThemeString: bound.getThemeString, - language, - languages, - localizedPaths, - oEmbedJSONHref, - paths, - to, - transformMultiline: bound.transformMultiline, - wikiData, - }); - - return writePage({ - html: pageHTML, - oEmbedJSON, - paths, - }); - }), - ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => { - const title = titleFn({ - language, - }); - - const from = getPagePaths({ - baseDirectory, - fullKey: 'localized.' + fromPath[0], - urlArgs: fromPath.slice(1), - }); - - const to = getURLsFrom({ - baseDirectory, - pageSubKey: fromPath[0], - paths: from, - }); - - const target = to('localized.' + toPath[0], ...toPath.slice(1)); - const html = generateRedirectHTML(title, target, {language}); - return writePage({html, paths: from}); - }), - ], queueSize)); - }; + return selectedBuildMode.go({ + cliOptions, + dataPath, + mediaPath, + queueSize, + srcRootPath: __dirname, - await wrapLanguages(perLanguageFn, { + defaultLanguage: finalDefaultLanguage, languages, - writeOneLanguage, - }); + wikiData, + urls, + urlSpec, - // The single most important step. - logInfo`Written!`; + cachebust: '?' + CACHEBUST, + developersComment, + getSizeOfAdditionalFile, + }); } // TODO: isMain detection isn't consistent across platforms here /* eslint-disable-next-line no-constant-condition */ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') { - main() - .catch((error) => { + (async () => { + let result; + + try { + result = await main(); + } catch (error) { if (error instanceof AggregateError) { showAggregate(error); } else { console.error(error); } - }) - .then(() => { - decorateTime.displayTime(); - CacheableObject.showInvalidAccesses(); - }); + } + + if (result !== true) { + process.exit(1); + return; + } + + decorateTime.displayTime(); + CacheableObject.showInvalidAccesses(); + + process.exit(0); + })(); } diff --git a/src/util/cli.js b/src/util/cli.js index f1a31900..1ddc90e0 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -64,8 +64,10 @@ export async function parseOptions(options, optionDescriptorMap) { // options is the array of options you want to process; // optionDescriptorMap is a mapping of option names to objects that describe // the expected value for their corresponding options. - // Returned is a mapping of any specified option names to their values, or - // a process.exit(1) and error message if there were any issues. + // + // Returned is... + // - a mapping of any specified option names to their values + // - a process.exit(1) and error message if there were any issues // // Here are examples of optionDescriptorMap to cover all the things you can // do with it: @@ -95,11 +97,10 @@ export async function parseOptions(options, optionDescriptorMap) { // ['--directory', 'apple'] -> {'directory': 'apple'} // ['--directory', 'artichoke'] -> (error) // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']} - // - // TODO: Be able to validate the values in a series option. const handleDashless = optionDescriptorMap[parseOptions.handleDashless]; const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown]; + const result = Object.create(null); for (let i = 0; i < options.length; i++) { const option = options[i]; @@ -107,6 +108,7 @@ export async function parseOptions(options, optionDescriptorMap) { // --x can be a flag or expect a value or series of values let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x'] let descriptor = optionDescriptorMap[name]; + if (!descriptor) { if (handleUnknown) { handleUnknown(option); @@ -116,36 +118,49 @@ export async function parseOptions(options, optionDescriptorMap) { } continue; } + if (descriptor.alias) { name = descriptor.alias; descriptor = optionDescriptorMap[name]; } - if (descriptor.type === 'flag') { - result[name] = true; - } else if (descriptor.type === 'value') { - let value = option.slice(2).split('=')[1]; - if (!value) { - value = options[++i]; - if (!value || value.startsWith('-')) { - value = null; - } + + switch (descriptor.type) { + case 'flag': { + result[name] = true; + break; } - if (!value) { - console.error(`Expected a value for --${name}`); - process.exit(1); + + case 'value': { + let value = option.slice(2).split('=')[1]; + if (!value) { + value = options[++i]; + if (!value || value.startsWith('-')) { + value = null; + } + } + + if (!value) { + console.error(`Expected a value for --${name}`); + process.exit(1); + } + + result[name] = value; + break; } - result[name] = value; - } else if (descriptor.type === 'series') { - if (!options.slice(i).includes(';')) { - console.error( - `Expected a series of values concluding with ; (\\;) for --${name}` - ); - process.exit(1); + + case 'series': { + if (!options.slice(i).includes(';')) { + console.error(`Expected a series of values concluding with ; (\\;) for --${name}`); + process.exit(1); + } + + const endIndex = i + options.slice(i).indexOf(';'); + result[name] = options.slice(i + 1, endIndex); + i = endIndex; + break; } - const endIndex = i + options.slice(i).indexOf(';'); - result[name] = options.slice(i + 1, endIndex); - i = endIndex; } + if (descriptor.validate) { const validation = await descriptor.validate(result[name]); if (validation !== true) { @@ -167,10 +182,12 @@ export async function parseOptions(options, optionDescriptorMap) { } continue; } + if (descriptor.alias) { name = descriptor.alias; descriptor = optionDescriptorMap[name]; } + if (descriptor.type === 'flag') { result[name] = true; } else { diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js index 73fdbc6d..83dd7db5 100644 --- a/src/util/magic-constants.js +++ b/src/util/magic-constants.js @@ -7,4 +7,3 @@ // (TM). export const OFFICIAL_GROUP_DIRECTORY = 'official'; -export const FANDOM_GROUP_DIRECTORY = 'fandom'; diff --git a/src/util/transform-content.js b/src/util/transform-content.js new file mode 100644 index 00000000..d1d0f51a --- /dev/null +++ b/src/util/transform-content.js @@ -0,0 +1,453 @@ +// See also replacer.js, which covers the actual syntax parser and node +// interpreter. This file works with replacer.js to provide higher-level +// interfaces for converting various content found in wiki data to HTML for +// display on the site. + +import * as html from './html.js'; +export {transformInline} from './replacer.js'; + +export const replacerSpec = { + album: { + find: 'album', + link: 'album', + }, + 'album-commentary': { + find: 'album', + link: 'albumCommentary', + }, + 'album-gallery': { + find: 'album', + link: 'albumGallery', + }, + artist: { + find: 'artist', + link: 'artist', + }, + 'artist-gallery': { + find: 'artist', + link: 'artistGallery', + }, + 'commentary-index': { + find: null, + link: 'commentaryIndex', + }, + date: { + find: null, + value: (ref) => new Date(ref), + html: (date, {language}) => + html.tag('time', + {datetime: date.toString()}, + language.formatDate(date)), + }, + 'flash-index': { + find: null, + link: 'flashIndex', + }, + flash: { + find: 'flash', + link: 'flash', + transformName(name, node, input) { + const nextCharacter = input[node.iEnd]; + const lastCharacter = name[name.length - 1]; + if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') { + return name.slice(0, -1); + } else { + return name; + } + }, + }, + group: { + find: 'group', + link: 'groupInfo', + }, + 'group-gallery': { + find: 'group', + link: 'groupGallery', + }, + home: { + find: null, + link: 'home', + }, + 'listing-index': { + find: null, + link: 'listingIndex', + }, + listing: { + find: 'listing', + link: 'listing', + }, + media: { + find: null, + link: 'media', + }, + 'news-index': { + find: null, + link: 'newsIndex', + }, + 'news-entry': { + find: 'newsEntry', + link: 'newsEntry', + }, + root: { + find: null, + link: 'root', + }, + site: { + find: null, + link: 'site', + }, + static: { + find: 'staticPage', + link: 'staticPage', + }, + string: { + find: null, + value: (ref) => ref, + html: (ref, {language, args}) => language.$(ref, args), + }, + tag: { + find: 'artTag', + link: 'tag', + }, + track: { + find: 'track', + link: 'track', + }, +}; + +function splitLines(text) { + return text.split(/\r\n|\r|\n/); +} + +function joinLineBreaks(sourceLines) { + const outLines = []; + + let lineSoFar = ''; + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i]; + lineSoFar += line; + if (!line.endsWith('<br>')) { + outLines.push(lineSoFar); + lineSoFar = ''; + } + } + + if (lineSoFar) { + outLines.push(lineSoFar); + } + + return outLines; +} + +function parseAttributes(string, {to}) { + const attributes = Object.create(null); + const skipWhitespace = (i) => { + const ws = /\s/; + if (ws.test(string[i])) { + const match = string.slice(i).match(/[^\s]/); + if (match) { + return i + match.index; + } else { + return string.length; + } + } else { + return i; + } + }; + + for (let i = 0; i < string.length; ) { + i = skipWhitespace(i); + const aStart = i; + const aEnd = i + string.slice(i).match(/[\s=]|$/).index; + const attribute = string.slice(aStart, aEnd); + i = skipWhitespace(aEnd); + if (string[i] === '=') { + i = skipWhitespace(i + 1); + let end, endOffset; + if (string[i] === '"' || string[i] === "'") { + end = string[i]; + endOffset = 1; + i++; + } else { + end = '\\s'; + endOffset = 0; + } + const vStart = i; + const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; + const value = string.slice(vStart, vEnd); + i = vEnd + endOffset; + if (attribute === 'src' && value.startsWith('media/')) { + attributes[attribute] = to('media.path', value.slice('media/'.length)); + } else { + attributes[attribute] = value; + } + } else { + attributes[attribute] = attribute; + } + } + return Object.fromEntries( + Object.entries(attributes).map(([key, val]) => [ + key, + val === 'true' + ? true + : val === 'false' + ? false + : val === key + ? true + : val, + ]) + ); +} + +function unbound_transformMultiline(text, { + img, + to, + transformInline, + + thumb = null, +}) { + // Heck yes, HTML magics. + + text = transformInline(text.trim()); + + const outLines = []; + + const indentString = ' '.repeat(4); + + let levelIndents = []; + const openLevel = (indent) => { + // opening a sublist is a pain: to be semantically *and* visually + // correct, we have to append the <ul> at the end of the existing + // previous <li> + const previousLine = outLines[outLines.length - 1]; + if (previousLine?.endsWith('</li>')) { + // we will re-close the <li> later + outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>'; + } else { + // if the previous line isn't a list item, this is the opening of + // the first list level, so no need for indent + outLines.push('<ul>'); + } + levelIndents.push(indent); + }; + const closeLevel = () => { + levelIndents.pop(); + if (levelIndents.length) { + // closing a sublist, so close the list item containing it too + outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>'); + } else { + // closing the final list level! no need for indent here + outLines.push('</ul>'); + } + }; + + // okay yes we should support nested formatting, more than one blockquote + // layer, etc, but hear me out here: making all that work would basically + // be the same as implementing an entire markdown converter, which im not + // interested in doing lol. sorry!!! + let inBlockquote = false; + + let lines = splitLines(text); + lines = joinLineBreaks(lines); + for (let line of lines) { + const imageLine = line.startsWith('<img'); + line = line.replace(/<img (.*?)>/g, (match, attributes) => + img({ + lazy: true, + link: true, + thumb, + ...parseAttributes(attributes, {to}), + }) + ); + + let indentThisLine = 0; + let lineContent = line; + let lineTag = 'p'; + + const listMatch = line.match(/^( *)- *(.*)$/); + if (listMatch) { + // is a list item! + if (!levelIndents.length) { + // first level is always indent = 0, regardless of actual line + // content (this is to avoid going to a lesser indent than the + // initial level) + openLevel(0); + } else { + // find level corresponding to indent + const indent = listMatch[1].length; + let i; + for (i = levelIndents.length - 1; i >= 0; i--) { + if (levelIndents[i] <= indent) break; + } + // note: i cannot equal -1 because the first indentation level + // is always 0, and the minimum indentation is also 0 + if (levelIndents[i] === indent) { + // same indent! return to that level + while (levelIndents.length - 1 > i) closeLevel(); + // (if this is already the current level, the above loop + // will do nothing) + } else if (levelIndents[i] < indent) { + // lesser indent! branch based on index + if (i === levelIndents.length - 1) { + // top level is lesser: add a new level + openLevel(indent); + } else { + // lower level is lesser: return to that level + while (levelIndents.length - 1 > i) closeLevel(); + } + } + } + // finally, set variables for appending content line + indentThisLine = levelIndents.length; + lineContent = listMatch[2]; + lineTag = 'li'; + } else { + // not a list item! close any existing list levels + while (levelIndents.length) closeLevel(); + + // like i said, no nested shenanigans - quotes only appear outside + // of lists. sorry! + const quoteMatch = line.match(/^> *(.*)$/); + if (quoteMatch) { + // is a quote! open a blockquote tag if it doesnt already exist + if (!inBlockquote) { + inBlockquote = true; + outLines.push('<blockquote>'); + } + indentThisLine = 1; + lineContent = quoteMatch[1]; + } else if (inBlockquote) { + // not a quote! close a blockquote tag if it exists + inBlockquote = false; + outLines.push('</blockquote>'); + } + + // let some escaped symbols display as the normal symbol, since the + // point of escaping them is just to avoid having them be treated as + // syntax markers! + if (lineContent.match(/( *)\\-/)) { + lineContent = lineContent.replace('\\-', '-'); + } else if (lineContent.match(/( *)\\>/)) { + lineContent = lineContent.replace('\\>', '>'); + } + } + + if (lineTag === 'p') { + // certain inline element tags should still be postioned within a + // paragraph; other elements (e.g. headings) should be added as-is + const elementMatch = line.match(/^<(.*?)[ >]/); + if ( + elementMatch && + !imageLine && + ![ + 'a', + 'abbr', + 'b', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'datalist', + 'del', + 'dfn', + 'em', + 'i', + 'img', + 'ins', + 'kbd', + 'mark', + 'output', + 'picture', + 'q', + 'ruby', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'svg', + 'time', + 'var', + 'wbr', + ].includes(elementMatch[1]) + ) { + lineTag = ''; + } + + // for sticky headings! + if (elementMatch && elementMatch[1] === 'h2') { + lineContent = lineContent.replace(/<h2(.*?)>/g, (match, attributes) => { + const parsedAttributes = parseAttributes(attributes, {to}); + return `<h2 ${html.attributes({ + ...parsedAttributes, + class: [...parsedAttributes.class?.split(' ') ?? [], 'content-heading'], + })}>`; + }); + } + } + + let pushString = indentString.repeat(indentThisLine); + if (lineTag) { + pushString += `<${lineTag}>${lineContent}</${lineTag}>`; + } else { + pushString += lineContent; + } + outLines.push(pushString); + } + + // after processing all lines... + + // if still in a list, close all levels + while (levelIndents.length) closeLevel(); + + // if still in a blockquote, close its tag + if (inBlockquote) { + inBlockquote = false; + outLines.push('</blockquote>'); + } + + return outLines.join('\n'); +} + +function unbound_transformLyrics(text, { + transformInline, + transformMultiline, +}) { + // Different from transformMultiline 'cuz it joins multiple lines together + // with line 8reaks (<br>); transformMultiline treats each line as its own + // complete paragraph (or list, etc). + + // If it looks like old data, then like, oh god. + // Use the normal transformMultiline tool. + if (text.includes('<br')) { + return transformMultiline(text); + } + + text = transformInline(text.trim()); + + let buildLine = ''; + const addLine = () => outLines.push(`<p>${buildLine}</p>`); + const outLines = []; + for (const line of text.split('\n')) { + if (line.length) { + if (buildLine.length) { + buildLine += '<br>'; + } + buildLine += line; + } else if (buildLine.length) { + addLine(); + buildLine = ''; + } + } + if (buildLine.length) { + addLine(); + } + return outLines.join('\n'); +} + +export { + unbound_transformLyrics as transformLyrics, + unbound_transformMultiline as transformMultiline +} diff --git a/src/util/urls.js b/src/util/urls.js index 1f9cd9c0..c2119b8d 100644 --- a/src/util/urls.js +++ b/src/util/urls.js @@ -136,3 +136,121 @@ export const thumb = { medium: thumbnailHelper('.medium'), small: thumbnailHelper('.small'), }; + +// Makes the generally-used and wiki-specialized "to" page utility. +// "to" returns a relative path from the current page to the target. +export function getURLsFrom({ + baseDirectory, + pagePath, + urls, +}) { + const pageSubKey = pagePath[0]; + const subdirectoryPrefix = getPageSubdirectoryPrefix({pagePath}); + + return (targetFullKey, ...args) => { + const [groupKey, subKey] = targetFullKey.split('.'); + let from, to; + + // When linking to *outside* the localized area of the site, we need to + // make sure the result is correctly relative to the 8ase directory. + if ( + groupKey !== 'localized' && + groupKey !== 'localizedDefaultLanguage' && + baseDirectory + ) { + from = 'localizedWithBaseDirectory.' + pageSubKey; + to = targetFullKey; + } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) { + // Special case for specifically linking *from* a page with base + // directory *to* a page without! Used for the language switcher and + // hopefully nothing else oh god. + from = 'localizedWithBaseDirectory.' + pageSubKey; + to = 'localized.' + subKey; + } else if (groupKey === 'localizedDefaultLanguage') { + // Linking to the default, except surprise, we're already IN the default + // (no baseDirectory set). + from = 'localized.' + pageSubKey; + to = 'localized.' + subKey; + } else { + // If we're linking inside the localized area (or there just is no + // 8ase directory), the 8ase directory doesn't matter. + from = 'localized.' + pageSubKey; + to = targetFullKey; + } + + return ( + subdirectoryPrefix + + urls.from(from).to(to, ...args)); + }; +} + +// Makes the generally-used and wiki-specialized "absoluteTo" page utility. +// "absoluteTo" returns an absolute path, starting at site root (/) leading +// to the target. +export function getURLsFromRoot({ + baseDirectory, + urls, +}) { + const {to} = urls.from('shared.root'); + + return (targetFullKey, ...args) => { + const [groupKey, subKey] = targetFullKey.split('.'); + return ( + '/' + + (groupKey === 'localized' && baseDirectory + ? to( + 'localizedWithBaseDirectory.' + subKey, + baseDirectory, + ...args + ) + : to(targetFullKey, ...args)) + ); + }; +} + +export function getPagePathname({ + baseDirectory, + device = false, + pagePath, + urls, +}) { + const {[device ? 'toDevice' : 'to']: to} = urls.from('shared.root'); + + return (baseDirectory + ? to('localizedWithBaseDirectory.' + pagePath[0], baseDirectory, ...pagePath.slice(1)) + : to('localized.' + pagePath[0], ...pagePath.slice(1))); +} + +export function getPagePathnameAcrossLanguages({ + defaultLanguage, + languages, + pagePath, + urls, +}) { + return withEntries(languages, entries => entries + .filter(([key, language]) => key !== 'default' && !language.hidden) + .map(([_key, language]) => [ + language.code, + getPagePathname({ + baseDirectory: + (language === defaultLanguage + ? '' + : language.code), + pagePath, + urls, + }), + ])); +} + +// Needed for the rare path arguments which themselves contains one or more +// slashes, e.g. for listings, with arguments like 'albums/by-name'. +export function getPageSubdirectoryPrefix({ + pagePath, +}) { + const timesNestedDeeply = (pagePath + .slice(1) // skip URL key, only check arguments + .join('/') + .split('/') + .length - 1); + return '../'.repeat(timesNestedDeeply); +} diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js new file mode 100644 index 00000000..127afe2c --- /dev/null +++ b/src/write/bind-utilities.js @@ -0,0 +1,272 @@ +// Ties lots and lots of functions together in a convenient package accessible +// to page write functions. This is kept in a separate file from other write +// areas to keep imports neat and isolated. + +import chroma from 'chroma-js'; + +import { + fancifyFlashURL, + fancifyURL, + getAlbumGridHTML, + getAlbumStylesheet, + getArtistString, + getCarouselHTML, + getFlashGridHTML, + getGridHTML, + getRevealStringFromArtTags, + getRevealStringFromContentWarningMessage, + getThemeString, + generateAdditionalFilesList, + generateAdditionalFilesShortcut, + generateChronologyLinks, + generateCoverLink, + generateInfoGalleryLinks, + generateTrackListDividedByGroups, + generateNavigationLinks, + generateStickyHeadingContainer, + iconifyURL, + img, +} from '../misc-templates.js'; + +import { + replacerSpec, + transformInline, + transformLyrics, + transformMultiline, +} from '../util/transform-content.js'; + +import * as html from '../util/html.js'; + +import {bindOpts, withEntries} from '../util/sugar.js'; +import {getColors} from '../util/colors.js'; +import {bindFind} from '../util/find.js'; + +import link, {getLinkThemeString} from '../util/link.js'; + +import { + getAlbumCover, + getArtistAvatar, + getFlashCover, + getTrackCover, +} from '../util/wiki-data.js'; + +export function bindUtilities({ + absoluteTo, + defaultLanguage, + getSizeOfAdditionalFile, + language, + languages, + to, + urls, + wikiData, +}) { + // TODO: Is there some nicer way to define these, + // may8e without totally re-8inding everything for + // each page? + const bound = {}; + + Object.assign(bound, { + absoluteTo, + defaultLanguage, + getSizeOfAdditionalFile, + html, + language, + languages, + to, + urls, + wikiData, + }) + + bound.img = bindOpts(img, { + [bindOpts.bindIndex]: 0, + html, + }); + + bound.getColors = bindOpts(getColors, { + chroma, + }); + + bound.getLinkThemeString = bindOpts(getLinkThemeString, { + getColors: bound.getColors, + }); + + bound.getThemeString = bindOpts(getThemeString, { + getColors: bound.getColors, + }); + + bound.link = withEntries(link, (entries) => + entries + .map(([key, fn]) => [key, bindOpts(fn, { + getLinkThemeString: bound.getLinkThemeString, + to, + })])); + + bound.find = bindFind(wikiData, {mode: 'warn'}); + + bound.transformInline = bindOpts(transformInline, { + find: bound.find, + link: bound.link, + replacerSpec, + language, + to, + wikiData, + }); + + bound.transformMultiline = bindOpts(transformMultiline, { + img: bound.img, + to, + transformInline: bound.transformInline, + }); + + bound.transformLyrics = bindOpts(transformLyrics, { + transformInline: bound.transformInline, + transformMultiline: bound.transformMultiline, + }); + + bound.iconifyURL = bindOpts(iconifyURL, { + html, + language, + to, + }); + + bound.fancifyURL = bindOpts(fancifyURL, { + html, + language, + }); + + bound.fancifyFlashURL = bindOpts(fancifyFlashURL, { + [bindOpts.bindIndex]: 2, + html, + language, + + fancifyURL: bound.fancifyURL, + }); + + bound.getRevealStringFromContentWarningMessage = bindOpts(getRevealStringFromContentWarningMessage, { + html, + language, + }); + + bound.getRevealStringFromArtTags = bindOpts(getRevealStringFromArtTags, { + language, + + getRevealStringFromContentWarningMessage: bound.getRevealStringFromContentWarningMessage, + }); + + bound.getArtistString = bindOpts(getArtistString, { + html, + link: bound.link, + language, + + iconifyURL: bound.iconifyURL, + }); + + bound.getAlbumCover = bindOpts(getAlbumCover, { + to, + }); + + bound.getTrackCover = bindOpts(getTrackCover, { + to, + }); + + bound.getFlashCover = bindOpts(getFlashCover, { + to, + }); + + bound.getArtistAvatar = bindOpts(getArtistAvatar, { + to, + }); + + bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, { + html, + language, + }); + + bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, { + html, + language, + }); + + bound.generateNavigationLinks = bindOpts(generateNavigationLinks, { + link: bound.link, + language, + }); + + bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, { + [bindOpts.bindIndex]: 0, + getRevealStringFromArtTags: bound.getRevealStringFromArtTags, + html, + img: bound.img, + }); + + bound.generateChronologyLinks = bindOpts(generateChronologyLinks, { + html, + language, + link: bound.link, + wikiData, + + generateNavigationLinks: bound.generateNavigationLinks, + }); + + bound.generateCoverLink = bindOpts(generateCoverLink, { + [bindOpts.bindIndex]: 0, + html, + img: bound.img, + link: bound.link, + language, + to, + wikiData, + + getRevealStringFromArtTags: bound.getRevealStringFromArtTags, + }); + + bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, { + [bindOpts.bindIndex]: 2, + link: bound.link, + language, + }); + + bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, { + html, + language, + wikiData, + }); + + bound.getGridHTML = bindOpts(getGridHTML, { + [bindOpts.bindIndex]: 0, + img: bound.img, + html, + language, + + getRevealStringFromArtTags: bound.getRevealStringFromArtTags, + }); + + bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, { + [bindOpts.bindIndex]: 0, + link: bound.link, + language, + + getAlbumCover: bound.getAlbumCover, + getGridHTML: bound.getGridHTML, + }); + + bound.getFlashGridHTML = bindOpts(getFlashGridHTML, { + [bindOpts.bindIndex]: 0, + link: bound.link, + + getFlashCover: bound.getFlashCover, + getGridHTML: bound.getGridHTML, + }); + + bound.getCarouselHTML = bindOpts(getCarouselHTML, { + [bindOpts.bindIndex]: 0, + img: bound.img, + html, + }) + + bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, { + to, + }); + + return bound; +} diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js new file mode 100644 index 00000000..91e39009 --- /dev/null +++ b/src/write/build-modes/index.js @@ -0,0 +1,2 @@ +export * as 'live-dev-server' from './live-dev-server.js'; +export * as 'static-build' from './static-build.js'; diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js new file mode 100644 index 00000000..39229a9a --- /dev/null +++ b/src/write/build-modes/live-dev-server.js @@ -0,0 +1,380 @@ +import * as http from 'http'; +import {createReadStream} from 'fs'; +import {stat} from 'fs/promises'; +import * as path from 'path'; +import {pipeline} from 'stream/promises' + +import {bindUtilities} from '../bind-utilities.js'; + +import {serializeThings} from '../../data/serialize.js'; + +import * as pageSpecs from '../../page/index.js'; + +import {logInfo, logWarn, progressCallAll} from '../../util/cli.js'; + +import { + getPagePathname, + getPagePathnameAcrossLanguages, + getURLsFrom, + getURLsFromRoot, +} from '../../util/urls.js'; + +import { + generateDocumentHTML, + generateGlobalWikiDataJSON, + generateRedirectHTML, +} from '../page-template.js'; + +const defaultHost = '0.0.0.0'; +const defaultPort = 8002; + +export const description = `Hosts a local HTTP server which generates page content as it is requested, instead of all at once; reacts to changes in data files, so new reloads will be up-to-date with on-disk YAML data (<- not implemented yet, check back soon!)\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`; + +export function getCLIOptions() { + return { + host: { + help: `Hostname to which HTTP server is bound\nDefaults to ${defaultHost}`, + type: 'value', + }, + + port: { + help: `Port to which HTTP server is bound\nDefaults to ${defaultPort}`, + type: 'value', + validate(size) { + if (parseInt(size) !== parseFloat(size)) return 'an integer'; + if (parseInt(size) < 1024 || parseInt(size) > 49151) return 'a user/registered port (1024-49151)'; + return true; + }, + }, + }; +} + +export async function go({ + cliOptions, + _dataPath, + mediaPath, + + defaultLanguage, + languages, + srcRootPath, + urls, + wikiData, + + cachebust, + developersComment, + getSizeOfAdditionalFile, +}) { + const host = cliOptions['host'] ?? defaultHost; + const port = parseInt(cliOptions['port'] ?? defaultPort); + + let targetSpecPairs = getPageSpecsWithTargets({wikiData}); + const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`, + targetSpecPairs.map(({ + pageSpec, + target, + targetless, + }) => () => + targetless + ? pageSpec.writeTargetless({wikiData}) + : pageSpec.write(target, {wikiData}))).flat(); + + logInfo`Will be serving a total of ${pages.length} pages.`; + + const urlToPageMap = Object.fromEntries(pages + .filter(page => page.type === 'page' || page.type === 'redirect') + .flatMap(page => { + let servePath; + if (page.type === 'page') + servePath = page.path; + else if (page.type === 'redirect') + servePath = page.fromPath; + + return Object.values(languages).map(language => { + const baseDirectory = + language === defaultLanguage ? '' : language.code; + + const pathname = getPagePathname({ + baseDirectory, + pagePath: servePath, + urls, + }); + + return [pathname, { + baseDirectory, + language, + page, + servePath, + }]; + }); + })); + + const server = http.createServer(async (request, response) => { + const contentTypeHTML = {'Content-Type': 'text/html; charset=utf-8'}; + const contentTypeJSON = {'Content-Type': 'application/json; charset=utf-8'}; + const contentTypePlain = {'Content-Type': 'text/plain; charset=utf-8'}; + + const requestTime = new Date().toLocaleDateString('en-US', {hour: '2-digit', minute: '2-digit', second: '2-digit'}); + const requestHead = `${requestTime} - ${request.socket.remoteAddress}`; + + let url; + try { + url = new URL(request.url, `http://${request.headers.host}`); + } catch (error) { + response.writeHead(500, contentTypePlain); + response.end('Failed to parse request URL\n'); + return; + } + + const {pathname} = url; + + // Specialized routes + + if (pathname === '/data.json') { + try { + const json = generateGlobalWikiDataJSON({ + serializeThings, + wikiData, + }); + response.writeHead(200, contentTypeJSON); + response.end(json); + console.log(`${requestHead} [200] /data.json`); + } catch (error) { + response.writeHead(500, contentTypeJSON); + response.end({error: `Internal error serializing wiki JSON`}); + console.error(`${requestHead} [500] /data.json`); + console.error(error); + } + return; + } + + const { + area: localFileArea, + path: localFilePath + } = pathname.match(/^\/(?<area>static|util|media)\/(?<path>.*)/)?.groups ?? {}; + + if (localFileArea) { + // Not security tested, man, this is a dev server!! + const safePath = path.posix.resolve('/', localFilePath).replace(/^\//, ''); + + let localDirectory; + if (localFileArea === 'static' || localFileArea === 'util') { + localDirectory = path.join(srcRootPath, localFileArea); + } else if (localFileArea === 'media') { + localDirectory = mediaPath; + } + + const filePath = path.resolve(localDirectory, safePath.split('/').join(path.sep)); + + try { + await stat(filePath); + } catch (error) { + if (error.code === 'ENOENT') { + response.writeHead(404, contentTypePlain); + response.end(`No ${localFileArea} file found for: ${safePath}`); + console.log(`${requestHead} [404] ${pathname}`); + console.log(`ENOENT for stat: ${filePath}`); + } else { + response.writeHead(500, contentTypePlain); + response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`); + console.error(`${requestHead} [500] ${pathname}`); + console.error(error); + } + return; + } + + const extname = path.extname(safePath).slice(1).toLowerCase(); + + const contentType = { + // BRB covering all my bases + 'aac': 'audio/aac', + 'bmp': 'image/bmp', + 'css': 'text/css', + 'csv': 'text/csv', + 'gif': 'image/gif', + 'ico': 'image/vnd.microsoft.icon', + 'jpg': 'image/jpeg', + 'jpeg:': 'image/jpeg', + 'js': 'text/javascript', + 'mjs': 'text/javascript', + 'mp3': 'audio/mpeg', + 'mp4': 'video/mp4', + 'oga': 'audio/ogg', + 'ogg': 'audio/ogg', + 'ogv': 'video/ogg', + 'opus': 'audio/opus', + 'png': 'image/png', + 'pdf': 'application/pdf', + 'svg': 'image/svg+xml', + 'ttf': 'font/ttf', + 'txt': 'text/plain', + 'wav': 'audio/wav', + 'weba': 'audio/webm', + 'webm': 'video/webm', + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'xml': 'application/xml', + 'zip': 'application/zip', + }[extname]; + + try { + response.writeHead(200, contentType ? {'Content-Type': contentType} : {}); + await pipeline( + createReadStream(filePath), + response); + console.log(`${requestHead} [200] ${pathname}`); + } catch (error) { + response.writeHead(500, contentTypePlain); + response.end(`Failed during file-to-response pipeline`); + console.error(`${requestHead} [500] ${pathname}`); + console.error(error); + } + return; + } + + // Other routes determined by page and URL specs + + // URL to page map expects trailing slash but no leading slash. + const pathnameKey = pathname.replace(/^\//, '') + (pathname.endsWith('/') ? '' : '/'); + + if (!Object.hasOwn(urlToPageMap, pathnameKey)) { + response.writeHead(404, contentTypePlain); + response.end(`No page found for: ${pathnameKey}\n`); + console.log(`${requestHead} [404] ${pathname}`); + return; + } + + // All pages expect to be served at a URL with a trailing slash, which must + // be fulfilled for relative URLs (ex. href="../lofam5/") to work. Redirect + // if there is no trailing slash in the request URL. + if (!pathname.endsWith('/')) { + const target = pathname + '/'; + response.writeHead(301, { + ...contentTypePlain, + 'Location': target, + }); + response.end(`Redirecting to: ${target}\n`); + console.log(`${requestHead} [301] (trl. slash) ${pathname}`); + return; + } + + const { + baseDirectory, + language, + page, + servePath, + } = urlToPageMap[pathnameKey]; + + const to = getURLsFrom({ + baseDirectory, + pagePath: servePath, + urls, + }); + + const absoluteTo = getURLsFromRoot({ + baseDirectory, + urls, + }); + + try { + if (page.type === 'redirect') { + const target = to('localized.' + page.toPath[0], ...page.toPath.slice(1)); + + response.writeHead(301, { + ...contentTypeHTML, + 'Location': target, + }); + + const redirectHTML = generateRedirectHTML(page.title, target, {language}); + + response.end(redirectHTML); + + console.log(`${requestHead} [301] (redirect) ${pathname}`); + return; + } + + response.writeHead(200, contentTypeHTML); + + const localizedPathnames = getPagePathnameAcrossLanguages({ + defaultLanguage, + languages, + pagePath: servePath, + urls, + }); + + const bound = bindUtilities({ + absoluteTo, + defaultLanguage, + getSizeOfAdditionalFile, + language, + languages, + to, + urls, + wikiData, + }); + + const pageInfo = page.page(bound); + + const pageHTML = generateDocumentHTML(pageInfo, { + ...bound, + cachebust, + developersComment, + localizedPathnames, + oEmbedJSONHref: null, // No oEmbed support for live dev server + pagePath: servePath, + pathname, + }); + + console.log(`${requestHead} [200] ${pathname}`); + response.end(pageHTML); + } catch (error) { + response.writeHead(500, contentTypePlain); + response.end(`Error generating page, view server log for details\n`); + console.error(`${requestHead} [500] ${pathname}`); + console.error(error); + } + }); + + const address = `http://${host}:${port}/`; + + server.on('error', error => { + if (error.code === 'EADDRINUSE') { + logWarn`Port ${port} is already in use - will (continually) retry after 10 seconds.`; + logWarn`Press ^C here (control+C) to exit and change ${'--port'} number, or stop the server currently running on port ${port}.`; + setTimeout(() => { + server.close(); + server.listen(port, host); + }, 10_000); + } else { + console.error(`Server error detected (code: ${error.code})`); + console.error(error); + } + }); + + server.on('listening', () => { + logInfo`${'All done!'} Listening at: ${address}`; + logInfo`Press ^C here (control+C) to stop the server and exit.`; + }); + + server.listen(port, host); + + // Just keep going... forever!!! + await new Promise(() => {}); + + return true; +} + +function getPageSpecsWithTargets({ + wikiData, +}) { + return Object.values(pageSpecs) + .filter(pageSpec => pageSpec.condition?.({wikiData}) ?? true) + .flatMap(pageSpec => [ + ...pageSpec.targets + ? pageSpec.targets({wikiData}) + .map(target => ({pageSpec, target})) + : [], + Object.hasOwn(pageSpec, 'writeTargetless') && + {pageSpec, targetless: true}, + ]) + .filter(Boolean); +} diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js new file mode 100644 index 00000000..fa724536 --- /dev/null +++ b/src/write/build-modes/static-build.js @@ -0,0 +1,537 @@ +import * as path from 'path'; + +import {bindUtilities} from '../bind-utilities.js'; +import {validateWrites} from '../validate-writes.js'; + +import { + generateDocumentHTML, + generateGlobalWikiDataJSON, + generateOEmbedJSON, + generateRedirectHTML, +} from '../page-template.js'; + +import {serializeThings} from '../../data/serialize.js'; + +import * as pageSpecs from '../../page/index.js'; + +import link from '../../util/link.js'; +import {empty, queue, withEntries} from '../../util/sugar.js'; + +import { + logError, + logInfo, + logWarn, + progressCallAll, + progressPromiseAll, +} from '../../util/cli.js'; + +import { + getPagePathname, + getPagePathnameAcrossLanguages, + getURLsFrom, + getURLsFromRoot, +} from '../../util/urls.js'; + +const pageFlags = Object.keys(pageSpecs); + +export const description = `Generates all page content in one build (according to the contents of data files at build time) and writes them to disk, preparing the output folder for upload and serving by any static web host\n\nIntended for any production or public-facing release of a wiki; serviceable for local development, but can be a bit unwieldy and time/CPU-expensive`; + +export function getCLIOptions() { + return { + // This is the output directory. It's the one you'll upload online with + // rsync or whatever when you're pushing an upd8, and also the one + // you'd archive if you wanted to make a 8ackup of the whole dang + // site. Just keep in mind that the gener8ted result will contain a + // couple symlinked directories, so if you're uploading, you're pro8a8ly + // gonna want to resolve those yourself. + 'out-path': { + help: `Specify path to output directory, into which HTML page files and other output are written and other directories are linked\n\nAlways required alongside --static-build mode, but may be provided via the HSMUSIC_OUT environment variable instead`, + type: 'value', + }, + + // Working without a dev server and just using file:// URLs in your we8 + // 8rowser? This will automatically append index.html to links across + // the site. Not recommended for production, since it isn't guaranteed + // 100% error-free (and index.html-style links are less pretty anyway). + 'append-index-html': { + help: `Apply "index.html" to the end of page links, instead of just linking to the directory (ex. "/track/ng2yu/"); useful when no local server hosting option is available and browsing build output directly off the disk drive\n\nDefinitely not intended for production: this option isn't extensively tested and may include conspicuous oddities`, + type: 'flag', + }, + + // Only want to 8uild one language during testing? This can chop down + // 8uild times a pretty 8ig chunk! Just pass a single language code. + 'lang': { + help: `Skip rest and build only pages for this locale language (specify a language code)`, + type: 'value', + }, + + // NOT for neatly ena8ling or disa8ling specific features of the site! + // This is only in charge of what general groups of files to write. + // They're here to make development quicker when you're only working + // on some particular area(s) of the site rather than making changes + // across all of them. + ...withEntries(pageSpecs, entries => entries.map( + ([key, spec]) => [key, { + help: spec.description && + `Skip rest and build only:\n${spec.description}`, + type: 'flag', + }])), + }; +} + +export async function go({ + cliOptions, + _dataPath, + mediaPath, + queueSize, + + defaultLanguage, + languages, + srcRootPath, + urls, + urlSpec, + wikiData, + + cachebust, + developersComment, + getSizeOfAdditionalFile, +}) { + const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT; + const appendIndexHTML = cliOptions['append-index-html'] ?? false; + const writeOneLanguage = cliOptions['lang'] ?? null; + + if (!outputPath) { + logError`Expected ${'--out-path'} option or ${'HSMUSIC_OUT'} to be set`; + return false; + } + + if (appendIndexHTML) { + logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`; + link.globalOptions.appendIndexHTML = true; + } + + if (writeOneLanguage && !(writeOneLanguage in languages)) { + logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`; + return false; + } else if (writeOneLanguage) { + logInfo`Writing only language ${writeOneLanguage} this run.`; + } else { + logInfo`Writing all languages.`; + } + + const selectedPageFlags = Object.keys(cliOptions) + .filter(key => pageFlags.includes(key)); + + const writeAll = empty(selectedPageFlags) || selectedPageFlags.includes('all'); + logInfo`Writing site pages: ${writeAll ? 'all' : selectedPageFlags.join(', ')}`; + + await writeSymlinks({ + srcRootPath, + mediaPath, + outputPath, + urls, + }); + + await writeFavicon({ + mediaPath, + outputPath, + }); + + await writeSharedFilesAndPages({ + language: defaultLanguage, + outputPath, + urls, + wikiData, + wikiDataJSON: generateGlobalWikiDataJSON({ + serializeThings, + wikiData, + }) + }); + + const buildSteps = writeAll + ? Object.entries(pageSpecs) + : Object.entries(pageSpecs) + .filter(([flag]) => selectedPageFlags.includes(flag)); + + let writes; + { + let error = false; + + const buildStepsWithTargets = buildSteps + .map(([flag, pageSpec]) => { + // Condition not met: skip this build step altogether. + if (pageSpec.condition && !pageSpec.condition({wikiData})) { + return null; + } + + // May still call writeTargetless if present. + if (!pageSpec.targets) { + return {flag, pageSpec, targets: []}; + } + + if (!pageSpec.write) { + logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`; + error = true; + return null; + } + + const targets = pageSpec.targets({wikiData}); + if (!Array.isArray(targets)) { + logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`; + error = true; + return null; + } + + return {flag, pageSpec, targets}; + }) + .filter(Boolean); + + if (error) { + return false; + } + + writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => { + const writesFns = targets.map(target => () => { + const writes = pageSpec.write(target, {wikiData})?.slice() || []; + const valid = validateWrites(writes, { + functionName: flag + '.write', + urlSpec, + }); + error ||=! valid; + return valid ? writes : []; + }); + + if (pageSpec.writeTargetless) { + writesFns.push(() => { + const writes = pageSpec.writeTargetless({wikiData}); + const valid = validateWrites(writes, { + functionName: flag + '.writeTargetless', + urlSpec, + }); + error ||=! valid; + return valid ? writes : []; + }); + } + + return writesFns; + })).flat(); + + if (error) { + return false; + } + } + + const pageWrites = writes.filter(({type}) => type === 'page'); + const dataWrites = writes.filter(({type}) => type === 'data'); + const redirectWrites = writes.filter(({type}) => type === 'redirect'); + + if (writes.length) { + logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`; + } else { + logWarn`No writes returned at all, so exiting early. This is probably a bug!`; + return false; + } + + /* + await progressPromiseAll(`Writing data files shared across languages.`, queue( + dataWrites.map(({path, data}) => () => { + const bound = {}; + + bound.serializeLink = bindOpts(serializeLink, {}); + + bound.serializeContribs = bindOpts(serializeContribs, {}); + + bound.serializeImagePaths = bindOpts(serializeImagePaths, { + thumb + }); + + bound.serializeCover = bindOpts(serializeCover, { + [bindOpts.bindIndex]: 2, + serializeImagePaths: bound.serializeImagePaths, + urls + }); + + bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, { + serializeLink + }); + + bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, { + serializeLink + }); + + // TODO: This only supports one <>-style argument. + return writeData(path[0], path[1], data({...bound})); + }), + queueSize + )); + */ + + const perLanguageFn = async (language, i, entries) => { + const baseDirectory = + language === defaultLanguage ? '' : language.code; + + console.log(`\x1b[34;1m${`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) `.padEnd(60, '-')}\x1b[0m`); + + await progressPromiseAll(`Writing ${language.code}`, queue([ + ...pageWrites.map(page => () => { + const pagePath = page.path; + + const localizedPathnames = getPagePathnameAcrossLanguages({ + defaultLanguage, + languages, + pagePath, + urls, + }); + + const pathname = getPagePathname({ + baseDirectory, + pagePath, + urls, + }); + + const to = getURLsFrom({ + baseDirectory, + pagePath, + urls, + }); + + const absoluteTo = getURLsFromRoot({ + baseDirectory, + urls, + }); + + const bound = bindUtilities({ + absoluteTo, + defaultLanguage, + getSizeOfAdditionalFile, + language, + languages, + to, + urls, + wikiData, + }); + + const pageInfo = page.page(bound); + + const oEmbedJSON = generateOEmbedJSON(pageInfo, { + language, + wikiData, + }); + + const oEmbedJSONHref = + oEmbedJSON && + wikiData.wikiInfo.canonicalBase && + wikiData.wikiInfo.canonicalBase + + urls + .from('shared.root') + .to('shared.path', pathname + 'oembed.json'); + + const pageHTML = generateDocumentHTML(pageInfo, { + ...bound, + cachebust, + developersComment, + localizedPathnames, + oEmbedJSONHref, + pagePath, + pathname, + }); + + return writePage({ + html: pageHTML, + oEmbedJSON, + outputDirectory: path.join(outputPath, getPagePathname({ + baseDirectory, + device: true, + pagePath, + urls, + })), + }); + }), + ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => { + const title = titleFn({ + language, + }); + + const to = getURLsFrom({ + baseDirectory, + pagePath: fromPath, + urls, + }); + + const target = to('localized.' + toPath[0], ...toPath.slice(1)); + const html = generateRedirectHTML(title, target, {language}); + + return writePage({ + html, + outputDirectory: path.join(outputPath, getPagePathname({ + baseDirectory, + device: true, + pagePath: fromPath, + urls, + })), + }); + }), + ], queueSize)); + }; + + await wrapLanguages(perLanguageFn, { + languages, + writeOneLanguage, + }); + + // The single most important step. + logInfo`Written!`; + return true; +} + +// Wrapper function for running a function once for all languages. +async function wrapLanguages(fn, { + languages, + writeOneLanguage = null, +}) { + const k = writeOneLanguage; + const languagesToRun = k ? {[k]: languages[k]} : languages; + + const entries = Object.entries(languagesToRun).filter( + ([key]) => key !== 'default' + ); + + for (let i = 0; i < entries.length; i++) { + const [_key, language] = entries[i]; + + await fn(language, i, entries); + } +} + +import { + copyFile, + mkdir, + stat, + symlink, + writeFile, + unlink, +} from 'fs/promises'; + +async function writePage({ + html, + oEmbedJSON = '', + outputDirectory, +}) { + await mkdir(outputDirectory, {recursive: true}); + + await Promise.all([ + writeFile(path.join(outputDirectory, 'index.html'), html), + + oEmbedJSON && + writeFile(path.join(outputDirectory, 'oembed.json'), oEmbedJSON), + ].filter(Boolean)); +} + +function writeSymlinks({ + srcRootPath, + mediaPath, + outputPath, + urls, +}) { + return progressPromiseAll('Writing site symlinks.', [ + link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'), + link(path.join(srcRootPath, 'static'), 'shared.staticRoot'), + link(mediaPath, 'media.root'), + ]); + + async function link(directory, urlKey) { + const pathname = urls.from('shared.root').toDevice(urlKey); + const file = path.join(outputPath, pathname); + + try { + await unlink(file); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + try { + await symlink(path.resolve(directory), file); + } catch (error) { + if (error.code === 'EPERM') { + await symlink(path.resolve(directory), file, 'junction'); + } + } + } +} + +async function writeFavicon({ + mediaPath, + outputPath, +}) { + const faviconFile = 'favicon.ico'; + + try { + await stat(path.join(mediaPath, faviconFile)); + } catch (error) { + return; + } + + try { + await copyFile( + path.join(mediaPath, faviconFile), + path.join(outputPath, faviconFile)); + } catch (error) { + logWarn`Failed to copy favicon! ${error.message}`; + return; + } + + logInfo`Copied favicon to site root.`; +} + +async function writeSharedFilesAndPages({ + language, + outputPath, + urls, + wikiData, + wikiDataJSON, +}) { + const {groupData, wikiInfo} = wikiData; + + return progressPromiseAll(`Writing files & pages shared across languages.`, [ + groupData?.some((group) => group.directory === 'fandom') && + redirect( + 'Fandom - Gallery', + 'albums/fandom', + 'localized.groupGallery', + 'fandom' + ), + + groupData?.some((group) => group.directory === 'official') && + redirect( + 'Official - Gallery', + 'albums/official', + 'localized.groupGallery', + 'official' + ), + + wikiInfo.enableListings && + redirect( + 'Album Commentary', + 'list/all-commentary', + 'localized.commentaryIndex', + '' + ), + + wikiDataJSON && + writeFile( + path.join(outputPath, 'data.json'), + wikiDataJSON), + ].filter(Boolean)); + + async function redirect(title, from, urlKey, directory) { + const target = path.relative( + from, + urls.from('shared.root').to(urlKey, directory) + ); + const content = generateRedirectHTML(title, target, {language}); + await mkdir(path.join(outputPath, from), {recursive: true}); + await writeFile(path.join(outputPath, from, 'index.html'), content); + } +} diff --git a/src/write/page-template.js b/src/write/page-template.js new file mode 100644 index 00000000..de369018 --- /dev/null +++ b/src/write/page-template.js @@ -0,0 +1,672 @@ +import chroma from 'chroma-js'; + +import * as html from '../util/html.js'; +import {getColors} from '../util/colors.js'; + +import { + getFooterLocalizationLinks, + getRevealStringFromContentWarningMessage, + img, +} from '../misc-templates.js'; + +export function generateDevelopersCommentHTML({ + buildTime, + commit, + wikiData, +}) { + const {name, canonicalBase} = wikiData.wikiInfo; + return `<!--\n` + [ + canonicalBase + ? `hsmusic.wiki - ${name}, ${canonicalBase}` + : `hsmusic.wiki - ${name}`, + 'Code copyright 2019-2023 Quasar Nebula et al (MIT License)', + ...canonicalBase === 'https://hsmusic.wiki/' ? [ + 'Data avidly compiled and localization brought to you', + 'by our awesome team and community of wiki contributors', + '***', + 'Want to contribute? Join our Discord or leave feedback!', + '- https://hsmusic.wiki/discord/', + '- https://hsmusic.wiki/feedback/', + '- https://github.com/hsmusic/', + ] : [ + 'https://github.com/hsmusic/', + ], + '***', + buildTime && + `Site built: ${buildTime.toLocaleString('en-US', { + dateStyle: 'long', + timeStyle: 'long', + })}`, + commit && + `Latest code commit: ${commit}`, + ] + .filter(Boolean) + .map(line => ` ` + line) + .join('\n') + `\n-->`; +} + +export function generateDocumentHTML(pageInfo, { + cachebust, + defaultLanguage, + developersComment, + generateCoverLink, + generateStickyHeadingContainer, + getThemeString, + language, + languages, + localizedPathnames, + oEmbedJSONHref, + pagePath, + pathname, + to, + transformMultiline, + wikiData, +}) { + const {wikiInfo} = wikiData; + + let { + title = '', + meta = {}, + theme = '', + stylesheet = '', + + showWikiNameInTitle = true, + themeColor = '', + + // missing properties are auto-filled, see below! + body = {}, + banner = {}, + cover = {}, + main = {}, + sidebarLeft = {}, + sidebarRight = {}, + nav = {}, + secondaryNav = {}, + footer = {}, + socialEmbed = {}, + } = pageInfo; + + body ||= {}; + body.style ??= ''; + + theme ||= getThemeString(wikiInfo.color); + + banner ||= {}; + banner.classes ??= []; + banner.src ??= ''; + banner.position ??= ''; + banner.dimensions ??= [0, 0]; + + main ||= {}; + main.classes ??= []; + main.content ??= ''; + main.headingMode ??= 'none'; + + cover ||= {}; + cover.src ??= ''; + cover.alt ??= ''; + cover.artTags ??= []; + + sidebarLeft ||= {}; + sidebarRight ||= {}; + + for (const sidebar of [sidebarLeft, sidebarRight]) { + sidebar.classes ??= []; + sidebar.content ??= ''; + sidebar.collapse ??= true; + } + + nav ||= {}; + nav.classes ??= []; + nav.content ??= ''; + nav.bottomRowContent ??= ''; + nav.links ??= []; + nav.linkContainerClasses ??= []; + + secondaryNav ||= {}; + secondaryNav.content ??= ''; + secondaryNav.content ??= ''; + + footer ||= {}; + footer.classes ??= []; + footer.content ??= wikiInfo.footerContent + ? transformMultiline(wikiInfo.footerContent) + : ''; + + socialEmbed ||= {}; + + const colors = themeColor + ? getColors(themeColor, {chroma}) + : null; + + const canonical = wikiInfo.canonicalBase + ? wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname) + : ''; + + const localizedCanonical = wikiInfo.canonicalBase + ? Object.entries(localizedPathnames).map(([code, pathname]) => ({ + lang: code, + href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname), + })) + : []; + + const collapseSidebars = + sidebarLeft.collapse !== false && sidebarRight.collapse !== false; + + const mainHTML = + html.tag('main', { + id: 'content', + class: main.classes, + }, [ + ...html.fragment( + !title ? + null + : main.headingMode === 'sticky' ? + generateStickyHeadingContainer({ + coverSrc: cover.src, + coverAlt: cover.alt, + coverArtTags: cover.artTags, + title, + }) + : main.headingMode === 'static' ? + html.tag('h1', title) + : null), + + ...html.fragment( + cover.src && + generateCoverLink({ + src: cover.src, + alt: cover.alt, + tags: cover.artTags, + })), + + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'main-content-container', + }, + main.content), + ]); + + const footerHTML = + html.tag('footer', + { + [html.onlyIfContent]: true, + id: 'footer', + class: footer.classes, + }, + [ + html.tag('div', + { + [html.onlyIfContent]: true, + class: 'footer-content', + }, + footer.content), + + getFooterLocalizationLinks({ + defaultLanguage, + html, + language, + languages, + pagePath, + to, + }), + ]); + + const generateSidebarHTML = (id, { + content, + multiple, + classes, + collapse = true, + wide = false, + + // 'last' - last or only sidebar box is sticky + // 'column' - entire column, incl. multiple boxes from top, is sticky + // 'none' - sidebar not sticky at all, stays at top of page + stickyMode = 'last', + }) => + content + ? html.tag('div', + { + id, + class: [ + 'sidebar-column', + 'sidebar', + wide && 'wide', + !collapse && 'no-hide', + stickyMode !== 'none' && 'sticky-' + stickyMode, + ...classes, + ], + }, + content) + : multiple + ? html.tag('div', + { + id, + class: [ + 'sidebar-column', + 'sidebar-multiple', + wide && 'wide', + !collapse && 'no-hide', + stickyMode !== 'none' && 'sticky-' + stickyMode, + ], + }, + multiple + .map((infoOrContent) => + (typeof infoOrContent === 'object' && !Array.isArray(infoOrContent)) + ? infoOrContent + : {content: infoOrContent}) + .filter(({content}) => content) + .map(({ + content, + classes: classes2 = [], + }) => + html.tag('div', + { + class: ['sidebar', ...classes, ...classes2], + }, + html.fragment(content)))) + : ''; + + const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft); + const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight); + + if (nav.simple) { + nav.linkContainerClasses = ['nav-links-hierarchy']; + nav.links = [{toHome: true}, {toCurrentPage: true}]; + } + + const links = (nav.links || []).filter(Boolean); + + const navLinkParts = []; + for (let i = 0; i < links.length; i++) { + let cur = links[i]; + + let {title: linkTitle} = cur; + + if (cur.toHome) { + linkTitle ??= wikiInfo.nameShort; + } else if (cur.toCurrentPage) { + linkTitle ??= title; + } + + let partContent; + + if (typeof cur.html === 'string') { + partContent = cur.html; + } else { + const attributes = { + class: (cur.toCurrentPage || i === links.length - 1) && 'current', + href: cur.toCurrentPage + ? '' + : cur.toHome + ? to('localized.home') + : cur.path + ? to(...cur.path) + : null, + }; + if (attributes.href === null) { + throw new Error( + `Expected some href specifier for link to ${linkTitle} (${JSON.stringify( + cur + )})` + ); + } + partContent = html.tag('a', attributes, linkTitle); + } + + if (!partContent) continue; + + const part = html.tag('span', + {class: cur.divider === false && 'no-divider'}, + partContent); + + navLinkParts.push(part); + } + + const navHTML = html.tag('nav', + { + [html.onlyIfContent]: true, + id: 'header', + class: [ + ...nav.classes, + links.length && 'nav-has-main-links', + nav.content && 'nav-has-content', + nav.bottomRowContent && 'nav-has-bottom-row', + ], + }, + [ + links.length && + html.tag( + 'div', + {class: ['nav-main-links', ...nav.linkContainerClasses]}, + navLinkParts + ), + nav.bottomRowContent && + html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent), + nav.content && html.tag('div', {class: 'nav-content'}, nav.content), + ]); + + const secondaryNavHTML = html.tag('nav', + { + [html.onlyIfContent]: true, + id: 'secondary-nav', + class: secondaryNav.classes, + }, + secondaryNav.content); + + const bannerSrc = banner.src + ? banner.src + : banner.path + ? to(...banner.path) + : null; + + const bannerHTML = + banner.position && + bannerSrc && + html.tag('div', + { + id: 'banner', + class: banner.classes, + }, + html.tag('img', { + src: bannerSrc, + alt: banner.alt, + width: banner.dimensions[0] || 1100, + height: banner.dimensions[1] || 200, + })); + + const layoutHTML = [ + navHTML, + banner.position === 'top' && bannerHTML, + secondaryNavHTML, + html.tag('div', + { + class: [ + 'layout-columns', + !collapseSidebars && 'vertical-when-thin', + (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar', + (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars', + !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars', + sidebarLeftHTML && 'has-sidebar-left', + sidebarRightHTML && 'has-sidebar-right', + ], + }, + [ + sidebarLeftHTML, + mainHTML, + sidebarRightHTML, + ]), + banner.position === 'bottom' && bannerHTML, + footerHTML, + ].filter(Boolean).join('\n'); + + const infoCardHTML = html.tag('div', {id: 'info-card-container'}, + html.tag('div', {id: 'info-card-decor'}, + html.tag('div', {id: 'info-card'}, [ + html.tag('div', {class: ['info-card-art-container', 'no-reveal']}, + img({ + html, + class: 'info-card-art', + src: '', + link: true, + square: true, + })), + html.tag('div', {class: ['info-card-art-container', 'reveal']}, + img({ + html, + class: 'info-card-art', + src: '', + link: true, + square: true, + reveal: getRevealStringFromContentWarningMessage( + html.tag('span', {class: 'info-card-art-warnings'}), + {html, language}), + })), + html.tag('h1', {class: 'info-card-name'}, + html.tag('a')), + html.tag('p', {class: 'info-card-album'}, + language.$('releaseInfo.from', { + album: html.tag('a'), + })), + html.tag('p', {class: 'info-card-artists'}, + language.$('releaseInfo.by', { + artists: html.tag('span'), + })), + html.tag('p', {class: 'info-card-cover-artists'}, + language.$('releaseInfo.coverArtBy', { + artists: html.tag('span'), + })), + ]))); + + const socialEmbedHTML = [ + socialEmbed.title && + html.tag('meta', {property: 'og:title', content: socialEmbed.title}), + + socialEmbed.description && + html.tag('meta', { + property: 'og:description', + content: socialEmbed.description, + }), + + socialEmbed.image && + html.tag('meta', {property: 'og:image', content: socialEmbed.image}), + + ...html.fragment( + colors && [ + html.tag('meta', { + name: 'theme-color', + content: colors.dark, + media: '(prefers-color-scheme: dark)', + }), + + html.tag('meta', { + name: 'theme-color', + content: colors.light, + media: '(prefers-color-scheme: light)', + }), + + html.tag('meta', { + name: 'theme-color', + content: colors.primary, + }), + ]), + + oEmbedJSONHref && + html.tag('link', { + type: 'application/json+oembed', + href: oEmbedJSONHref, + }), + ].filter(Boolean).join('\n'); + + return `<!DOCTYPE html>\n` + html.tag('html', + { + lang: language.intlCode, + 'data-language-code': language.code, + 'data-url-key': 'localized.' + pagePath[0], + ...Object.fromEntries( + pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])), + 'data-rebase-localized': to('localized.root'), + 'data-rebase-shared': to('shared.root'), + 'data-rebase-media': to('media.root'), + 'data-rebase-data': to('data.root'), + }, + [ + developersComment, + + html.tag('head', [ + html.tag('title', + showWikiNameInTitle + ? language.formatString('misc.pageTitle.withWikiName', { + title, + wikiName: wikiInfo.nameShort, + }) + : language.formatString('misc.pageTitle', {title})), + + html.tag('meta', {charset: 'utf-8'}), + html.tag('meta', { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }), + + ...( + Object.entries(meta) + .filter(([key, value]) => value) + .map(([key, value]) => html.tag('meta', {[key]: value}))), + + canonical && + html.tag('link', { + rel: 'canonical', + href: canonical, + }), + + ...( + localizedCanonical + .map(({lang, href}) => html.tag('link', { + rel: 'alternate', + hreflang: lang, + href, + }))), + + socialEmbedHTML, + + html.tag('link', { + rel: 'stylesheet', + href: to('shared.staticFile', `site3.css?${cachebust}`), + }), + + html.tag('style', + {[html.onlyIfContent]: true}, + [ + theme, + stylesheet, + ]), + + html.tag('script', { + src: to('shared.staticFile', `lazy-loading.js?${cachebust}`), + }), + ]), + + html.tag('body', + {style: body.style || ''}, + [ + html.tag('div', {id: 'page-container'}, [ + mainHTML && + html.tag('div', {id: 'skippers'}, + [ + ['#content', language.$('misc.skippers.skipToContent')], + sidebarLeftHTML && + [ + '#sidebar-left', + sidebarRightHTML + ? language.$('misc.skippers.skipToSidebar.left') + : language.$('misc.skippers.skipToSidebar'), + ], + sidebarRightHTML && + [ + '#sidebar-right', + sidebarLeftHTML + ? language.$('misc.skippers.skipToSidebar.right') + : language.$('misc.skippers.skipToSidebar'), + ], + footerHTML && + ['#footer', language.$('misc.skippers.skipToFooter')], + ] + .filter(Boolean) + .map(([href, title]) => + html.tag('span', {class: 'skipper'}, + html.tag('a', {href}, title)))), + layoutHTML, + ]), + + infoCardHTML, + + html.tag('script', { + type: 'module', + src: to('shared.staticFile', `client.js?${cachebust}`), + }), + ]), + ]); +} + +export function generateOEmbedJSON(pageInfo, {language, wikiData}) { + const {socialEmbed} = pageInfo; + const {wikiInfo} = wikiData; + const {canonicalBase, nameShort} = wikiInfo; + + if (!socialEmbed) return ''; + + const entries = [ + socialEmbed.heading && [ + 'author_name', + language.$('misc.socialEmbed.heading', { + wikiName: nameShort, + heading: socialEmbed.heading, + }), + ], + socialEmbed.headingLink && + canonicalBase && [ + 'author_url', + canonicalBase.replace(/\/$/, '') + + '/' + + socialEmbed.headingLink.replace(/^\//, ''), + ], + ].filter(Boolean); + + if (!entries.length) return ''; + + return JSON.stringify(Object.fromEntries(entries)); +} + +export function generateRedirectHTML(title, target, { + language, +}) { + return `<!DOCTYPE html>\n` + html.tag('html', [ + html.tag('head', [ + html.tag('title', language.$('redirectPage.title', {title})), + html.tag('meta', {charset: 'utf-8'}), + + html.tag('meta', { + 'http-equiv': 'refresh', + content: `0;url=${target}`, + }), + + // TODO: Is this OK for localized pages? + html.tag('link', { + rel: 'canonical', + href: target, + }), + ]), + + html.tag('body', + html.tag('main', [ + html.tag('h1', + language.$('redirectPage.title', {title})), + html.tag('p', + language.$('redirectPage.infoLine', { + target: html.tag('a', {href: target}, target), + })), + ])), + ]); +} + +export function generateGlobalWikiDataJSON({ + serializeThings, + wikiData, +}) { + return '{\n' + + ([ + `"albumData": ${stringifyThings(wikiData.albumData)},`, + wikiData.wikiInfo.enableFlashesAndGames && + `"flashData": ${stringifyThings(wikiData.flashData)},`, + `"artistData": ${stringifyThings(wikiData.artistData)}`, + ] + .filter(Boolean) + .map(line => ' ' + line) + .join('\n')) + + '\n}'; + + function stringifyThings(thingData) { + return JSON.stringify(serializeThings(thingData)); + } +} diff --git a/src/write/validate-writes.js b/src/write/validate-writes.js new file mode 100644 index 00000000..5d61d0e7 --- /dev/null +++ b/src/write/validate-writes.js @@ -0,0 +1,134 @@ +import {logError} from '../util/cli.js'; + +function validateWritePath(path, urlGroup) { + if (!Array.isArray(path)) { + return {error: `Expected array, got ${path}`}; + } + + const {paths} = urlGroup; + + const definedKeys = Object.keys(paths); + const specifiedKey = path[0]; + + if (!definedKeys.includes(specifiedKey)) { + return {error: `Specified key ${specifiedKey} isn't defined`}; + } + + const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0; + const specifiedArgs = path.length - 1; + + if (specifiedArgs !== expectedArgs) { + return { + error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`, + }; + } + + return {success: true}; +} + +function validateWriteObject(obj, { + urlSpec, +}) { + if (typeof obj !== 'object') { + return {error: `Expected object, got ${typeof obj}`}; + } + + if (typeof obj.type !== 'string') { + return {error: `Expected type to be string, got ${obj.type}`}; + } + + switch (obj.type) { + case 'legacy': { + if (typeof obj.write !== 'function') { + return {error: `Expected write to be string, got ${obj.write}`}; + } + + break; + } + + case 'page': { + const path = validateWritePath(obj.path, urlSpec.localized); + if (path.error) { + return {error: `Path validation failed: ${path.error}`}; + } + + if (typeof obj.page !== 'function') { + return {error: `Expected page to be function, got ${obj.content}`}; + } + + break; + } + + case 'data': { + const path = validateWritePath(obj.path, urlSpec.data); + if (path.error) { + return {error: `Path validation failed: ${path.error}`}; + } + + if (typeof obj.data !== 'function') { + return {error: `Expected data to be function, got ${obj.data}`}; + } + + break; + } + + case 'redirect': { + const fromPath = validateWritePath(obj.fromPath, urlSpec.localized); + if (fromPath.error) { + return { + error: `Path (fromPath) validation failed: ${fromPath.error}`, + }; + } + + const toPath = validateWritePath(obj.toPath, urlSpec.localized); + if (toPath.error) { + return {error: `Path (toPath) validation failed: ${toPath.error}`}; + } + + if (typeof obj.title !== 'function') { + return {error: `Expected title to be function, got ${obj.title}`}; + } + + break; + } + + default: { + return {error: `Unknown type: ${obj.type}`}; + } + } + + return {success: true}; +} + +export function validateWrites(writes, { + functionName, + urlSpec, +}) { + // Do a quick valid8tion! If one of the writeThingPages functions go + // wrong, this will stall out early and tell us which did. + + if (!Array.isArray(writes)) { + logError`${functionName} didn't return an array!`; + return false; + } + + if (!( + writes.every((obj) => typeof obj === 'object') && + writes.every((obj) => { + const result = validateWriteObject(obj, { + urlSpec, + }); + if (result.error) { + logError`Validating write object failed: ${result.error}`; + return false; + } else { + return true; + } + }) + )) { + logError`${functionName} returned invalid entries!`; + return false; + } + + return true; +} diff --git a/test/things.js b/test/things.js index f36a4995..0d74b60d 100644 --- a/test/things.js +++ b/test/things.js @@ -9,9 +9,11 @@ import { function stubAlbum(tracks) { const album = new Album(); - const trackGroup = new TrackGroup(); - trackGroup.tracksByRef = tracks.map(t => Thing.getReference(t)); - album.trackGroups = [trackGroup]; + album.trackSections = [ + { + tracksByRef: tracks.map(t => Thing.getReference(t)), + }, + ]; album.trackData = tracks; return album; } |