From 38e4c4d60c411a19dbbf229bdece1833bb4eec1e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Jan 2025 09:15:50 -0400 Subject: urls, upd8: load internal url spec from yaml file --- src/upd8.js | 48 ++++++- src/url-spec-default.yaml | 143 ++++++++++++++++++++ src/url-spec.js | 336 ++++++++++++++++++++++++++-------------------- src/urls.js | 243 +++++++++++++++++++++++++++++++++ src/util/aggregate.js | 1 - src/util/urls.js | 251 ---------------------------------- 6 files changed, 622 insertions(+), 400 deletions(-) create mode 100644 src/url-spec-default.yaml create mode 100644 src/urls.js delete mode 100644 src/util/urls.js (limited to 'src') diff --git a/src/upd8.js b/src/upd8.js index 6097f21f..b83b5171 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -50,7 +50,8 @@ import {isMain, traverse} from '#node-utils'; import {bindReverse} from '#reverse'; import {writeSearchData} from '#search'; import {sortByName} from '#sort'; -import {generateURLs, urlSpec} from '#urls'; +import {internalDefaultURLSpecFile, generateURLs, processURLSpecFromFile} + from '#urls'; import {identifyAllWebRoutes} from '#web-routes'; import { @@ -180,6 +181,10 @@ async function main() { {...defaultStepStatus, name: `precache nearly all data`, for: ['build']}, + loadURLFiles: + {...defaultStepStatus, name: `load internal & custom url spec files`, + for: ['build']}, + // TODO: This should be split into load/watch steps. loadInternalDefaultLanguage: {...defaultStepStatus, name: `load internal default language`, @@ -1627,8 +1632,6 @@ async function main() { }); } - const urls = generateURLs(urlSpec); - // Filter out any things with duplicate directories throughout the data, // warning about them too. @@ -1801,6 +1804,45 @@ async function main() { } } + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + let internalURLSpec = {}; + + try { + let aggregate; + ({aggregate, result: internalURLSpec} = + await processURLSpecFromFile(internalDefaultURLSpecFile)); + + aggregate.close(); + } catch (error) { + niceShowAggregate(error); + logError`Couldn't load internal default URL spec.`; + logError`This is required to build the wiki, so stopping here.`; + fileIssue(); + + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_FATAL_ERROR, + annotation: `see log for details`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + return false; + } + + const urlSpec = internalURLSpec; + + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + const urls = generateURLs(urlSpec); + const languageReloading = stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED; diff --git a/src/url-spec-default.yaml b/src/url-spec-default.yaml new file mode 100644 index 00000000..10bc0d23 --- /dev/null +++ b/src/url-spec-default.yaml @@ -0,0 +1,143 @@ +# These are variables which are used to make expressing this +# YAML file more convenient. They are not exposed externally. +# (Stuff which uses this YAML file can't even see the names +# for each variable!) +yamlAliases: + - &genericPaths + root: '' + path: '<>' + + # Static files are all grouped under a `static-${STATIC_VERSION}` folder as + # part of a build. This is so that multiple builds of a wiki can coexist + # served from the same server / file system root: older builds' HTML files + # refer to earlier values of STATIC_VERSION, avoiding name collisions. + - &staticVersion 3p4 + +data: + prefix: 'data/' + + paths: + - *genericPaths + + - album: 'album/<>' + artist: 'artist/<>' + track: 'track/<>' + +localized: + paths: + - *genericPaths + - page: '<>/' + + home: '' + + album: 'album/<>/' + albumCommentary: 'commentary/album/<>/' + albumGallery: 'album/<>/gallery/' + albumReferencedArtworks: 'album/<>/referenced-art/' + albumReferencingArtworks: 'album/<>/referencing-art/' + + artist: 'artist/<>/' + artistGallery: 'artist/<>/gallery/' + + commentaryIndex: 'commentary/' + + flashIndex: 'flash/' + + flash: 'flash/<>/' + + flashActGallery: 'flash-act/<>/' + + groupInfo: 'group/<>/' + groupGallery: 'group/<>/gallery/' + + listingIndex: 'list/' + + listing: 'list/<>/' + + newsIndex: 'news/' + + newsEntry: 'news/<>/' + + staticPage: '<>/' + + tag: 'tag/<>/' + + track: 'track/<>/' + trackReferencedArtworks: 'track/<>/referenced-art/' + trackReferencingArtworks: 'track/<>/referencing-art/' + +# This gets automatically switched in place when working from +# a baseDirectory, so it should never be referenced manually. +# It's also filled in externally to this YAML spec. +localizedWithBaseDirectory: '' + +shared: + paths: *genericPaths + +staticCSS: + prefix: + - 'static-' + - *staticVersion + - '/css/' + + paths: *genericPaths + +staticJS: + prefix: + - 'static-' + - *staticVersion + - '/js/' + + paths: *genericPaths + +staticLib: + prefix: + - 'static-' + - *staticVersion + - '/lib/' + + paths: *genericPaths + +staticMisc: + prefix: + - 'static-' + - *staticVersion + - '/misc/' + + paths: + - *genericPaths + - icon: 'icons.svg#icon-<>' + +staticSharedUtil: + prefix: + - 'static-' + - *staticVersion + - '/shared-util/' + + paths: *genericPaths + +media: + prefix: 'media/' + + paths: + - *genericPaths + + - albumAdditionalFile: 'album-additional/<>/<>' + albumBanner: 'album-art/<>/banner.<>' + albumCover: 'album-art/<>/cover.<>' + albumWallpaper: 'album-art/<>/bg.<>' + albumWallpaperPart: 'album-art/<>/<>' + + artistAvatar: 'artist-avatar/<>.<>' + + flashArt: 'flash-art/<>.<>' + + trackCover: 'album-art/<>/<>.<>' + +thumb: + prefix: 'thumb/' + paths: *genericPaths + +searchData: + prefix: 'search-data/' + paths: *genericPaths diff --git a/src/url-spec.js b/src/url-spec.js index 6ca75e7d..42e3e45c 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,145 +1,191 @@ -import {withEntries} from '#sugar'; - -// Static files are all grouped under a `static-${STATIC_VERSION}` folder as -// part of a build. This is so that multiple builds of a wiki can coexist -// served from the same server / file system root: older builds' HTML files -// refer to earlier values of STATIC_VERSION, avoiding name collisions. -const STATIC_VERSION = '3p3'; - -const genericPaths = { - root: '', - path: '<>', -}; - -const urlSpec = { - data: { - prefix: 'data/', - - paths: { - ...genericPaths, - - album: 'album/<>', - artist: 'artist/<>', - track: 'track/<>', - }, - }, - - localized: { - // TODO: Implement this. - // prefix: '_languageCode', - - paths: { - ...genericPaths, - page: '<>/', - - home: '', - - album: 'album/<>/', - albumCommentary: 'commentary/album/<>/', - albumGallery: 'album/<>/gallery/', - albumReferencedArtworks: 'album/<>/referenced-art/', - albumReferencingArtworks: 'album/<>/referencing-art/', - - artist: 'artist/<>/', - artistGallery: 'artist/<>/gallery/', - - commentaryIndex: 'commentary/', - - flashIndex: 'flash/', - - flash: 'flash/<>/', - - flashActGallery: 'flash-act/<>/', - - groupInfo: 'group/<>/', - groupGallery: 'group/<>/gallery/', - - listingIndex: 'list/', - - listing: 'list/<>/', - - newsIndex: 'news/', - - newsEntry: 'news/<>/', - - staticPage: '<>/', - - tag: 'tag/<>/', - - track: 'track/<>/', - trackReferencedArtworks: 'track/<>/referenced-art/', - trackReferencingArtworks: 'track/<>/referencing-art/', - }, - }, - - shared: { - paths: genericPaths, - }, - - staticCSS: { - prefix: `static-${STATIC_VERSION}/css/`, - paths: genericPaths, - }, - - staticJS: { - prefix: `static-${STATIC_VERSION}/js/`, - paths: genericPaths, - }, - - staticLib: { - prefix: `static-${STATIC_VERSION}/lib/`, - paths: genericPaths, - }, - - staticMisc: { - prefix: `static-${STATIC_VERSION}/misc/`, - paths: { - ...genericPaths, - icon: 'icons.svg#icon-<>', - }, - }, - - staticSharedUtil: { - prefix: `static-${STATIC_VERSION}/shared-util/`, - paths: genericPaths, - }, - - media: { - prefix: 'media/', - - paths: { - ...genericPaths, - - albumAdditionalFile: 'album-additional/<>/<>', - albumBanner: 'album-art/<>/banner.<>', - albumCover: 'album-art/<>/cover.<>', - albumWallpaper: 'album-art/<>/bg.<>', - albumWallpaperPart: 'album-art/<>/<>', - - artistAvatar: 'artist-avatar/<>.<>', - - flashArt: 'flash-art/<>.<>', - - trackCover: 'album-art/<>/<>.<>', - }, - }, - - thumb: { - prefix: 'thumb/', - paths: genericPaths, - }, - - searchData: { - prefix: 'search-data/', - paths: genericPaths, - }, -}; - -// 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 = 'url-spec-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)); + } +} + +// Mutates, so don't even think about reusing the original representation. +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 procsesing group "${groupKey}"`}); + + const processToken = makeProcessToken(aggregate); + + processToken(groupSpec, 'prefix', processStringToken); + processToken(groupSpec, 'paths', processObjectToken); + + return {aggregate, result: groupSpec}; +} + +export function processURLSpec(sourceSpec) { + const aggregate = + openAggregate({message: `Errors processing URL spec`}); + + 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 '': { + if (!urlSpec.localized) { + aggregate.push(new Error( + `Couldn't prepare 'localizedWithBaseDirectory' group, ` + + `'localized' not available`)); + + break; + } + + if (!urlSpec.localized.paths) { + aggregate.push(new Error( + `Couldn't prepare 'localizedWithBaseDirectory' group, ` + + `'localized' group's paths not available`)); + + break; + } + + const paths = + withEntries(urlSpec.localized.paths, entries => + entries.map(([key, path]) => [key, '<>/' + path])); + + urlSpec.localizedWithBaseDirectory = + Object.assign( + structuredClone(urlSpec.localized), + {paths}); + + break; + } + + case undefined: + break; + + default: + aggregate.push(new Error( + `Expected 'localizedWithBaseDirectory' group to have value '' ` + + `or not be set`)); + + break; + } + + return {aggregate, result: urlSpec}; +} + +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); + } +} diff --git a/src/urls.js b/src/urls.js new file mode 100644 index 00000000..1a471b30 --- /dev/null +++ b/src/urls.js @@ -0,0 +1,243 @@ +// Code that deals with URLs (really the pathnames that get referenced all +// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which +// is in charge of pre-gener8ting a complete network of template strings +// which can really quickly take su8stitute parameters to link from any one +// place to another; 8ut there are also a few other utilities, too. + +import * as path from 'node:path'; + +import {withEntries} from '#sugar'; + +export * from './url-spec.js'; + +export function generateURLs(urlSpec) { + const getValueForFullKey = (obj, fullKey) => { + const [groupKey, subKey] = fullKey.split('.'); + if (!groupKey || !subKey) { + throw new Error(`Expected group key and subkey (got ${fullKey})`); + } + + if (!Object.hasOwn(obj, groupKey)) { + throw new Error(`Expected valid group key (got ${groupKey})`); + } + + const group = obj[groupKey]; + + if (!Object.hasOwn(group, subKey)) { + throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); + } + + return { + value: group[subKey], + group, + }; + }; + + // This should be called on values which are going to be passed to + // path.relative, because relative will resolve a leading slash as the root + // directory of the working device, which we aren't looking for here. + const trimLeadingSlash = (P) => (P.startsWith('/') ? P.slice(1) : P); + + const generateTo = (fromPath, fromGroup) => { + const A = trimLeadingSlash(fromPath); + + const rebasePrefix = '../' + .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + + const pathHelper = (toPath, toGroup) => { + let B = trimLeadingSlash(toPath); + + let argIndex = 0; + B = B.replaceAll('<>', () => `<${argIndex++}>`); + + if (toGroup.prefix !== fromGroup.prefix) { + // TODO: Handle differing domains in prefixes. + B = rebasePrefix + (toGroup.prefix || '') + B; + } + + const suffix = toPath.endsWith('/') ? '/' : ''; + + return { + posix: path.posix.relative(A, B) + suffix, + device: path.relative(A, B) + suffix, + }; + }; + + const groupSymbol = Symbol(); + + const groupHelper = (urlGroup) => ({ + [groupSymbol]: urlGroup, + ...withEntries(urlGroup.paths, (entries) => + entries.map(([key, path]) => [key, pathHelper(path, urlGroup)]) + ), + }); + + const relative = withEntries(urlSpec, (entries) => + entries.map(([key, urlGroup]) => [key, groupHelper(urlGroup)]) + ); + + const toHelper = + ({device}) => + (key, ...args) => { + const { + value: { + [device ? 'device' : 'posix']: template, + }, + } = getValueForFullKey(relative, key); + + let missing = 0; + let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => { + if (n < args.length) { + const value = args[n]; + if (device) { + return value; + } else { + let encoded = encodeURIComponent(value); + encoded = encoded.replaceAll('%2F', '/'); + return encoded; + } + } else { + missing++; + } + }); + + if (missing) { + throw new Error( + `Expected ${missing + args.length} arguments, got ${ + args.length + } (key ${key}, args [${args}])` + ); + } + + return result; + }; + + return { + to: toHelper({device: false}), + toDevice: toHelper({device: true}), + }; + }; + + const generateFrom = () => { + const map = withEntries( + urlSpec, + (entries) => entries.map(([key, group]) => [ + key, + withEntries(group.paths, (entries) => + entries.map(([key, path]) => [key, generateTo(path, group)]) + ), + ])); + + const from = (key) => getValueForFullKey(map, key).value; + + return {from, map}; + }; + + return generateFrom(); +} + +const thumbnailHelper = (name) => (file) => + file.replace(/\.(jpg|png)$/, name + '.jpg'); + +export const thumb = { + large: thumbnailHelper('.large'), + 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))); +} + +// 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/util/aggregate.js b/src/util/aggregate.js index e8f45f3b..c7648c4c 100644 --- a/src/util/aggregate.js +++ b/src/util/aggregate.js @@ -110,7 +110,6 @@ export function openAggregate({ return results.map(({aggregate, result}) => { if (!aggregate) { - console.log('nope:', results); throw new Error(`Expected an array of {aggregate, result} objects`); } diff --git a/src/util/urls.js b/src/util/urls.js deleted file mode 100644 index 11b9b8b0..00000000 --- a/src/util/urls.js +++ /dev/null @@ -1,251 +0,0 @@ -// Code that deals with URLs (really the pathnames that get referenced all -// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which -// is in charge of pre-gener8ting a complete network of template strings -// which can really quickly take su8stitute parameters to link from any one -// place to another; 8ut there are also a few other utilities, too. - -import * as path from 'node:path'; - -import {withEntries} from '#sugar'; - -// This export is only provided for convenience, i.e. to enable the following: -// -// import {urlSpec} from '#urls'; -// -// It's not actually defined in this module's variable scope, and functions -// exported here require a urlSpec (whether this default one or another) to be -// passed directly. -// -export {default as urlSpec} from '../url-spec.js'; - -export function generateURLs(urlSpec) { - const getValueForFullKey = (obj, fullKey) => { - const [groupKey, subKey] = fullKey.split('.'); - if (!groupKey || !subKey) { - throw new Error(`Expected group key and subkey (got ${fullKey})`); - } - - if (!Object.hasOwn(obj, groupKey)) { - throw new Error(`Expected valid group key (got ${groupKey})`); - } - - const group = obj[groupKey]; - - if (!Object.hasOwn(group, subKey)) { - throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); - } - - return { - value: group[subKey], - group, - }; - }; - - // This should be called on values which are going to be passed to - // path.relative, because relative will resolve a leading slash as the root - // directory of the working device, which we aren't looking for here. - const trimLeadingSlash = (P) => (P.startsWith('/') ? P.slice(1) : P); - - const generateTo = (fromPath, fromGroup) => { - const A = trimLeadingSlash(fromPath); - - const rebasePrefix = '../' - .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); - - const pathHelper = (toPath, toGroup) => { - let B = trimLeadingSlash(toPath); - - let argIndex = 0; - B = B.replaceAll('<>', () => `<${argIndex++}>`); - - if (toGroup.prefix !== fromGroup.prefix) { - // TODO: Handle differing domains in prefixes. - B = rebasePrefix + (toGroup.prefix || '') + B; - } - - const suffix = toPath.endsWith('/') ? '/' : ''; - - return { - posix: path.posix.relative(A, B) + suffix, - device: path.relative(A, B) + suffix, - }; - }; - - const groupSymbol = Symbol(); - - const groupHelper = (urlGroup) => ({ - [groupSymbol]: urlGroup, - ...withEntries(urlGroup.paths, (entries) => - entries.map(([key, path]) => [key, pathHelper(path, urlGroup)]) - ), - }); - - const relative = withEntries(urlSpec, (entries) => - entries.map(([key, urlGroup]) => [key, groupHelper(urlGroup)]) - ); - - const toHelper = - ({device}) => - (key, ...args) => { - const { - value: { - [device ? 'device' : 'posix']: template, - }, - } = getValueForFullKey(relative, key); - - let missing = 0; - let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => { - if (n < args.length) { - const value = args[n]; - if (device) { - return value; - } else { - let encoded = encodeURIComponent(value); - encoded = encoded.replaceAll('%2F', '/'); - return encoded; - } - } else { - missing++; - } - }); - - if (missing) { - throw new Error( - `Expected ${missing + args.length} arguments, got ${ - args.length - } (key ${key}, args [${args}])` - ); - } - - return result; - }; - - return { - to: toHelper({device: false}), - toDevice: toHelper({device: true}), - }; - }; - - const generateFrom = () => { - const map = withEntries( - urlSpec, - (entries) => entries.map(([key, group]) => [ - key, - withEntries(group.paths, (entries) => - entries.map(([key, path]) => [key, generateTo(path, group)]) - ), - ])); - - const from = (key) => getValueForFullKey(map, key).value; - - return {from, map}; - }; - - return generateFrom(); -} - -const thumbnailHelper = (name) => (file) => - file.replace(/\.(jpg|png)$/, name + '.jpg'); - -export const thumb = { - large: thumbnailHelper('.large'), - 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))); -} - -// 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); -} -- cgit 1.3.0-6-gf8a5