« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/url-spec.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/url-spec.js')
-rw-r--r--src/url-spec.js365
1 files changed, 220 insertions, 145 deletions
diff --git a/src/url-spec.js b/src/url-spec.js
index 6ca75e7d..75cd8006 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,145 +1,220 @@
-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 = '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);
+  }
+}