diff options
Diffstat (limited to 'src/url-spec.js')
-rw-r--r-- | src/url-spec.js | 275 |
1 files changed, 201 insertions, 74 deletions
diff --git a/src/url-spec.js b/src/url-spec.js index a36bb0ee..75cd8006 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,93 +1,220 @@ -import {withEntries} from './util/sugar.js'; +// 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; + } + } + }; +} -const urlSpec = { - data: { - prefix: 'data/', +export function processGroupSpec(groupKey, groupSpec) { + const aggregate = + openAggregate({message: `Errors processing group "${groupKey}"`}); - paths: { - root: '', - path: '<>', + const processToken = makeProcessToken(aggregate); - album: 'album/<>', - artist: 'artist/<>', - track: 'track/<>' - } - }, + groupSpec.key = groupKey; - localized: { - // TODO: Implement this. - // prefix: '_languageCode', + processToken(groupSpec, 'prefix', processStringToken); + processToken(groupSpec, 'paths', processObjectToken); - paths: { - root: '', - path: '<>', + return {aggregate, result: groupSpec}; +} - home: '', +export function processURLSpec(sourceSpec) { + const aggregate = + openAggregate({message: `Errors processing URL spec`}); - album: 'album/<>/', - albumCommentary: 'commentary/album/<>/', + sourceSpec ??= {}; - artist: 'artist/<>/', - artistGallery: 'artist/<>/gallery/', + const urlSpec = structuredClone(sourceSpec); - commentaryIndex: 'commentary/', + delete urlSpec.yamlAliases; + delete urlSpec.localizedWithBaseDirectory; - flashIndex: 'flash/', - flash: 'flash/<>/', + aggregate.nest({message: `Errors processing groups`}, groupsAggregate => { + Object.assign(urlSpec, + withEntries(urlSpec, entries => + entries.map(([groupKey, groupSpec]) => [ + groupKey, + groupsAggregate.receive( + processGroupSpec(groupKey, groupSpec)), + ]))); + }); - groupInfo: 'group/<>/', - groupGallery: 'group/<>/gallery/', + 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`)); + } - listingIndex: 'list/', - listing: 'list/<>/', + break; + } - newsIndex: 'news/', - newsEntry: 'news/<>/', + case undefined: + break; - staticPage: '<>/', - tag: 'tag/<>/', - track: 'track/<>/' - } - }, + default: + aggregate.push(new Error( + `Expected 'localizedWithBaseDirectory' group to have value '<auto>' ` + + `or not be set`)); - shared: { - paths: { - root: '', - path: '<>', + break; + } - utilityRoot: 'util', - staticRoot: 'static', + return {aggregate, result: urlSpec}; +} - utilityFile: 'util/<>', - staticFile: 'static/<>' - } - }, - - media: { - prefix: 'media/', - - paths: { - root: '', - path: '<>', - - albumCover: 'album-art/<>/cover.jpg', - albumWallpaper: 'album-art/<>/bg.jpg', - albumBanner: 'album-art/<>/banner.jpg', - trackCover: 'album-art/<>/<>.jpg', - artistAvatar: 'artist-avatar/<>.jpg', - flashArt: 'flash-art/<>.jpg', - flashArtGif: 'flash-art/<>.gif' // Hack! Sorry not sorry. ::::) +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); } -}; - -// 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; + } 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); + } +} |