diff options
Diffstat (limited to 'src/url-spec.js')
-rw-r--r-- | src/url-spec.js | 311 |
1 files changed, 220 insertions, 91 deletions
diff --git a/src/url-spec.js b/src/url-spec.js index ce479267..75cd8006 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,91 +1,220 @@ -import {withEntries} from './util/sugar.js'; - -const urlSpec = { - data: { - prefix: 'data/', - - paths: { - root: '', - path: '<>', - - album: 'album/<>', - artist: 'artist/<>', - track: 'track/<>', - }, - }, - - localized: { - // TODO: Implement this. - // prefix: '_languageCode', - - paths: { - root: '', - path: '<>', - - home: '', - - album: 'album/<>/', - albumCommentary: 'commentary/album/<>/', - - artist: 'artist/<>/', - artistGallery: 'artist/<>/gallery/', - - commentaryIndex: 'commentary/', - - flashIndex: 'flash/', - flash: 'flash/<>/', - - groupInfo: 'group/<>/', - groupGallery: 'group/<>/gallery/', - - listingIndex: 'list/', - listing: 'list/<>/', - - newsIndex: 'news/', - newsEntry: 'news/<>/', - - staticPage: '<>/', - tag: 'tag/<>/', - track: 'track/<>/', - }, - }, - - shared: { - paths: { - root: '', - path: '<>', - - utilityRoot: 'util', - staticRoot: 'static', - - utilityFile: 'util/<>', - staticFile: 'static/<>', - }, - }, - - media: { - prefix: 'media/', - - paths: { - root: '', - path: '<>', - - albumCover: 'album-art/<>/cover.<>', - albumWallpaper: 'album-art/<>/bg.<>', - albumBanner: 'album-art/<>/banner.<>', - trackCover: 'album-art/<>/<>.<>', - artistAvatar: 'artist-avatar/<>.<>', - flashArt: 'flash-art/<>.<>', - albumAdditionalFile: 'album-additional/<>/<>', - }, - }, -}; - -// This gets automatically switched in place when working from a baseDirectory, -// so it should never be referenced manually. -urlSpec.localizedWithBaseDirectory = { - paths: withEntries(urlSpec.localized.paths, (entries) => - entries.map(([key, path]) => [key, '<>/' + path])), -}; - -export default urlSpec; +// Exports defined here are re-exported through urls.js, +// so they're generally imported from '#urls'. + +import {readFile} from 'node:fs/promises'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import yaml from 'js-yaml'; + +import {annotateError, annotateErrorWithFile, openAggregate} from '#aggregate'; +import {empty, typeAppearance, withEntries} from '#sugar'; + +export const DEFAULT_URL_SPEC_FILE = 'urls-default.yaml'; + +export const internalDefaultURLSpecFile = + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + DEFAULT_URL_SPEC_FILE); + +function processStringToken(key, token) { + const oops = appearance => + new Error( + `Expected ${key} to be a string or an array of strings, ` + + `got ${appearance}`); + + if (typeof token === 'string') { + return token; + } else if (Array.isArray(token)) { + if (empty(token)) { + throw oops(`empty array`); + } else if (token.every(item => typeof item !== 'string')) { + throw oops(`array of non-strings`); + } else if (token.some(item => typeof item !== 'string')) { + throw oops(`array of mixed strings and non-strings`); + } else { + return token.join(''); + } + } else { + throw oops(typeAppearance(token)); + } +} + +function processObjectToken(key, token) { + const oops = appearance => + new Error( + `Expected ${key} to be an object or an array of objects, ` + + `got ${appearance}`); + + const looksLikeObject = value => + typeof value === 'object' && + value !== null && + !Array.isArray(value); + + if (looksLikeObject(token)) { + return {...token}; + } else if (Array.isArray(token)) { + if (empty(token)) { + throw oops(`empty array`); + } else if (token.every(item => !looksLikeObject(item))) { + throw oops(`array of non-objects`); + } else if (token.some(item => !looksLikeObject(item))) { + throw oops(`array of mixed objects and non-objects`); + } else { + return Object.assign({}, ...token); + } + } +} + +function makeProcessToken(aggregate) { + return (object, key, processFn) => { + if (key in object) { + const value = aggregate.call(processFn, key, object[key]); + if (value === null) { + delete object[key]; + } else { + object[key] = value; + } + } + }; +} + +export function processGroupSpec(groupKey, groupSpec) { + const aggregate = + openAggregate({message: `Errors processing group "${groupKey}"`}); + + const processToken = makeProcessToken(aggregate); + + groupSpec.key = groupKey; + + processToken(groupSpec, 'prefix', processStringToken); + processToken(groupSpec, 'paths', processObjectToken); + + return {aggregate, result: groupSpec}; +} + +export function processURLSpec(sourceSpec) { + const aggregate = + openAggregate({message: `Errors processing URL spec`}); + + sourceSpec ??= {}; + + const urlSpec = structuredClone(sourceSpec); + + delete urlSpec.yamlAliases; + delete urlSpec.localizedWithBaseDirectory; + + aggregate.nest({message: `Errors processing groups`}, groupsAggregate => { + Object.assign(urlSpec, + withEntries(urlSpec, entries => + entries.map(([groupKey, groupSpec]) => [ + groupKey, + groupsAggregate.receive( + processGroupSpec(groupKey, groupSpec)), + ]))); + }); + + switch (sourceSpec.localizedWithBaseDirectory) { + case '<auto>': { + if (!urlSpec.localized) { + aggregate.push(new Error( + `Not ready for 'localizedWithBaseDirectory' group, ` + + `'localized' not available`)); + } else if (!urlSpec.localized.paths) { + aggregate.push(new Error( + `Not ready for 'localizedWithBaseDirectory' group, ` + + `'localized' group's paths not available`)); + } + + break; + } + + case undefined: + break; + + default: + aggregate.push(new Error( + `Expected 'localizedWithBaseDirectory' group to have value '<auto>' ` + + `or not be set`)); + + break; + } + + return {aggregate, result: urlSpec}; +} + +export function applyURLSpecOverriding(overrideSpec, baseSpec) { + const aggregate = openAggregate({message: `Errors applying URL spec`}); + + for (const [groupKey, overrideGroupSpec] of Object.entries(overrideSpec)) { + const baseGroupSpec = baseSpec[groupKey]; + + if (!baseGroupSpec) { + aggregate.push(new Error(`Group key "${groupKey}" not available on base spec`)); + continue; + } + + if (overrideGroupSpec.prefix) { + baseGroupSpec.prefix = overrideGroupSpec.prefix; + } + + if (overrideGroupSpec.paths) { + for (const [pathKey, overridePathValue] of Object.entries(overrideGroupSpec.paths)) { + if (!baseGroupSpec.paths[pathKey]) { + aggregate.push(new Error(`Path key "${groupKey}.${pathKey}" not available on base spec`)); + continue; + } + + baseGroupSpec.paths[pathKey] = overridePathValue; + } + } + } + + return {aggregate}; +} + +export function applyLocalizedWithBaseDirectory(urlSpec) { + const paths = + withEntries(urlSpec.localized.paths, entries => + entries.map(([key, path]) => [key, '<>/' + path])); + + urlSpec.localizedWithBaseDirectory = + Object.assign( + structuredClone(urlSpec.localized), + {paths}); +} + +export async function processURLSpecFromFile(file) { + let contents; + + try { + contents = await readFile(file, 'utf-8'); + } catch (caughtError) { + throw annotateError( + new Error(`Failed to read URL spec file`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); + } + + let sourceSpec; + let parseLanguage; + + try { + if (path.extname(file) === '.yaml') { + parseLanguage = 'YAML'; + sourceSpec = yaml.load(contents); + } else { + parseLanguage = 'JSON'; + sourceSpec = JSON.parse(contents); + } + } catch (caughtError) { + throw annotateError( + new Error(`Failed to parse URL spec file as valid ${parseLanguage}`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); + } + + try { + return processURLSpec(sourceSpec); + } catch (caughtError) { + throw annotateErrorWithFile(caughtError, file); + } +} |