« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js48
-rw-r--r--src/content/dependencies/generatePageLayout.js38
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js8
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js13
-rw-r--r--src/data/composite/wiki-properties/index.js1
-rw-r--r--src/data/things/track.js2
-rw-r--r--src/data/things/validators.js22
-rw-r--r--src/data/yaml.js50
-rw-r--r--src/static/client3.js107
-rw-r--r--src/static/site5.css66
-rw-r--r--src/strings-default.yaml15
11 files changed, 334 insertions, 36 deletions
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
new file mode 100644
index 00000000..f7fa3b00
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -0,0 +1,48 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, additionalNames) => ({
+    names:
+      additionalNames.map(({name}) =>
+        relation('transformContent', name)),
+
+    annotations:
+      additionalNames.map(({annotation}) =>
+        (annotation
+          ? relation('transformContent', annotation)
+          : null)),
+  }),
+
+  generate: (relations, {html, language}) => {
+    const names =
+      relations.names.map(name =>
+        html.tag('span', {class: 'additional-name'},
+          name.slot('mode', 'inline')));
+
+    const annotations =
+      relations.annotations.map(annotation =>
+        (annotation
+          ? html.tag('span', {class: 'annotation'},
+              language.$('misc.additionalNames.item.annotation', {
+                annotation:
+                  annotation.slot('mode', 'inline'),
+              }))
+          : null));
+
+    return html.tag('div', {id: 'additional-names-box'}, [
+      html.tag('p',
+        language.$('misc.additionalNames.title')),
+
+      html.tag('ul',
+        stitchArrays({name: names, annotation: annotations})
+          .map(({name, annotation}) =>
+            html.tag('li',
+              (annotation
+                ? language.$('misc.additionalNames.item.withAnnotation', {name, annotation})
+                : language.$('misc.additionalNames.item', {name}))))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 95551f3e..7dee8cc3 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -108,6 +108,8 @@ export default {
     title: {type: 'html'},
     showWikiNameInTitle: {type: 'boolean', default: true},
 
+    additionalNames: {type: 'html'},
+
     cover: {type: 'html'},
 
     socialEmbed: {type: 'html'},
@@ -222,22 +224,25 @@ export default {
     const colors = getColors(slots.color ?? data.wikiColor);
     const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
 
-    let titleHTML = null;
-
-    if (!html.isBlank(slots.title)) {
-      switch (slots.headingMode) {
-        case 'sticky':
-          titleHTML =
-            relations.stickyHeadingContainer.slots({
-              title: slots.title,
-              cover: slots.cover,
-            });
-          break;
-        case 'static':
-          titleHTML = html.tag('h1', slots.title);
-          break;
-      }
-    }
+    const titleContentsHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : html.isBlank(slots.additionalNames)
+        ? language.sanitize(slots.title)
+        : html.tag('a', {
+            href: '#additional-names-box',
+            title: language.$('misc.additionalNames.tooltip').toString(),
+          }, language.sanitize(slots.title)));
+
+    const titleHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : slots.headingMode === 'sticky'
+        ? relations.stickyHeadingContainer.slots({
+            title: titleContentsHTML,
+            cover: slots.cover,
+          })
+        : html.tag('h1', titleContentsHTML));
 
     let footerContent = slots.footerContent;
 
@@ -254,6 +259,7 @@ export default {
         titleHTML,
 
         slots.cover,
+        slots.additionalNames,
 
         html.tag('div',
           {
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 93334948..180e5c29 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -6,6 +6,7 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 export default {
   contentDependencies: [
     'generateAdditionalFilesShortcut',
+    'generateAdditionalNamesBox',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
@@ -106,6 +107,11 @@ export default {
       list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
     });
 
+    if (!empty(track.additionalNames)) {
+      relations.additionalNamesBox =
+        relation('generateAdditionalNamesBox', track.additionalNames);
+    }
+
     if (track.hasUniqueCoverArt || album.hasCoverArt) {
       relations.cover =
         relation('generateTrackCoverArtwork', track);
@@ -300,6 +306,8 @@ export default {
         title: language.$('trackPage.title', {track: data.name}),
         headingMode: 'sticky',
 
+        additionalNames: relations.additionalNamesBox ?? null,
+
         color: data.color,
         styleRules: [relations.albumStyleRules],
 
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
new file mode 100644
index 00000000..d1302224
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -0,0 +1,13 @@
+// A list of additional names! These can be used for a variety of purposes,
+// e.g. providing extra searchable titles, localizations, romanizations or
+// original titles, and so on. Each item has a name and, optionally, a
+// descriptive annotation.
+
+import {isAdditionalNameList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalNameList},
+  };
+}
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 2462b047..7607e361 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -1,4 +1,5 @@
 export {default as additionalFiles} from './additionalFiles.js';
+export {default as additionalNameList} from './additionalNameList.js';
 export {default as color} from './color.js';
 export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8d310611..f6320677 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -24,6 +24,7 @@ import {
 
 import {
   additionalFiles,
+  additionalNameList,
   commentary,
   commentatorArtists,
   contributionList,
@@ -63,6 +64,7 @@ export class Track extends Thing {
 
     name: name('Unnamed Track'),
     directory: directory(),
+    additionalNames: additionalNameList(),
 
     duration: duration(),
     urls: urls(),
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index f60c363c..71570c5a 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -96,7 +96,10 @@ export function isStringNonEmpty(value) {
 }
 
 export function optional(validator) {
-  return value => value === null || value === undefined || validator(value);
+  return value =>
+    value === null ||
+    value === undefined ||
+    validator(value);
 }
 
 // Complex types (non-primitives)
@@ -285,20 +288,14 @@ export function validateProperties(spec) {
 
 export const isContribution = validateProperties({
   who: isArtistRef,
-  what: (value) =>
-    value === undefined ||
-    value === null ||
-    isStringNonEmpty(value),
+  what: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
   title: isString,
-  description: (value) =>
-    value === undefined ||
-    value === null ||
-    isString(value),
+  description: optional(isStringNonEmpty),
   files: validateArrayItems(isString),
 });
 
@@ -376,6 +373,13 @@ export function isURL(string) {
   return true;
 }
 
+export const isAdditionalName = validateProperties({
+  name: isName,
+  annotation: optional(isStringNonEmpty),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
 export function validateReference(type = 'track') {
   return (ref) => {
     isStringNonEmpty(ref);
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 1d35bae8..5da66c93 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -436,6 +436,7 @@ export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHel
 
 export const processTrackDocument = makeProcessDocument(T.Track, {
   fieldTransformations: {
+    'Additional Names': parseAdditionalNames,
     'Duration': parseDuration,
 
     'Date First Released': (value) => new Date(value),
@@ -457,6 +458,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
   propertyFieldMapping: {
     name: 'Track',
     directory: 'Directory',
+    additionalNames: 'Additional Names',
     duration: 'Duration',
     color: 'Color',
     urls: 'URLs',
@@ -717,26 +719,52 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-export function parseContributors(contributors) {
+const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
+
+export function parseContributors(contributionStrings) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
   // than we're able to here.
-  if (!Array.isArray(contributors)) {
-    return contributors;
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
   }
 
-  contributors = contributors.map((contrib) => {
-    if (typeof contrib !== 'string') return contrib;
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
 
-    const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-    if (!match) return contrib;
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
 
-    const who = match[1];
-    const what = match[3] || null;
-    return {who, what};
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
   });
+}
+
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
+  }
 
-  return contributors;
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
 }
 
 function parseDimensions(string) {
diff --git a/src/static/client3.js b/src/static/client3.js
index 9db9fc6c..d0973e46 100644
--- a/src/static/client3.js
+++ b/src/static/client3.js
@@ -1033,6 +1033,7 @@ const hashLinkInfo = clientInfo.hashLinkInfo = {
   },
 
   event: {
+    beforeHashLinkScrolls: [],
     whenHashLinkClicked: [],
   },
 };
@@ -1095,6 +1096,21 @@ function addHashLinkListeners() {
         return;
       }
 
+      // Don't do anything if the target element isn't actually visible!
+      if (target.offsetParent === null) {
+        return;
+      }
+
+      // Allow event handlers to prevent scrolling.
+      for (const handler of event.beforeHashLinkScrolls) {
+        if (handler({
+          link: hashLink,
+          target,
+        }) === false) {
+          return;
+        }
+      }
+
       // Hide skipper box right away, so the layout is updated on time for the
       // math operations coming up next.
       const skipper = document.getElementById('skippers');
@@ -1134,6 +1150,7 @@ function addHashLinkListeners() {
       for (const handler of event.whenHashLinkClicked) {
         handler({
           link: hashLink,
+          target,
         });
       }
     });
@@ -1704,6 +1721,96 @@ function loadImage(imageUrl, onprogress) {
   });
 }
 
+// "Additional names" box ---------------------------------
+
+const additionalNamesBoxInfo = clientInfo.additionalNamesBox = {
+  box: null,
+  links: null,
+  mainContentContainer: null,
+
+  state: {
+    visible: false,
+  },
+};
+
+function getAdditionalNamesBoxReferences() {
+  const info = additionalNamesBoxInfo;
+
+  info.box =
+    document.getElementById('additional-names-box');
+
+  info.links =
+    document.querySelectorAll('a[href="#additional-names-box"]');
+
+  info.mainContentContainer =
+    document.querySelector('#content .main-content-container');
+}
+
+function addAdditionalNamesBoxInternalListeners() {
+  const info = additionalNamesBoxInfo;
+
+  hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => {
+    if (target === info.box) {
+      return false;
+    }
+  });
+}
+
+function addAdditionalNamesBoxListeners() {
+  const info = additionalNamesBoxInfo;
+
+  for (const link of info.links) {
+    link.addEventListener('click', domEvent => {
+      handleAdditionalNamesBoxLinkClicked(domEvent);
+    });
+  }
+}
+
+function handleAdditionalNamesBoxLinkClicked(domEvent) {
+  const info = additionalNamesBoxInfo;
+  const {state} = info;
+
+  domEvent.preventDefault();
+
+  if (!info.box || !info.mainContentContainer) return;
+
+  const margin =
+    +(cssProp(info.box, 'scroll-margin-top').replace('px', ''));
+
+  const {top} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : info.mainContentContainer.getBoundingClientRect());
+
+  if (top + 20 < margin || top > 0.4 * window.innerHeight) {
+    if (!state.visible) {
+      toggleAdditionalNamesBox();
+    }
+
+    window.scrollTo({
+      top: window.scrollY + top - margin,
+      behavior: 'smooth',
+    });
+  } else {
+    toggleAdditionalNamesBox();
+  }
+}
+
+function toggleAdditionalNamesBox() {
+  const info = additionalNamesBoxInfo;
+  const {state} = info;
+
+  state.visible = !state.visible;
+  info.box.style.display =
+    (state.visible
+      ? 'block'
+      : 'none');
+}
+
+clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
+clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
+clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
+
 // Group contributions table ------------------------------
 
 // TODO: Update to clientSteps style.
diff --git a/src/static/site5.css b/src/static/site5.css
index 5a769545..bf2eea11 100644
--- a/src/static/site5.css
+++ b/src/static/site5.css
@@ -872,6 +872,68 @@ html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not(
   opacity: 0.7;
 }
 
+/* Additional names (heading and box) */
+
+h1 a[href="#additional-names-box"] {
+  color: inherit;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+h1 a[href="#additional-names-box"]:hover {
+  text-decoration-style: solid;
+}
+
+#additional-names-box {
+  --custom-scroll-offset: calc(0.5em - 2px);
+
+  margin: 1em 0 1em -10px;
+  padding: 15px 20px 10px 20px;
+  width: max-content;
+  max-width: min(60vw, 600px);
+
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  background:
+    linear-gradient(var(--bg-color), var(--bg-color)),
+    linear-gradient(#000000bb, #000000bb),
+    var(--primary-color);
+
+  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+
+  display: none;
+}
+
+#additional-names-box > :first-child { margin-top: 0; }
+#additional-names-box > :last-child { margin-bottom: 0; }
+
+#additional-names-box p {
+  padding-left: 10px;
+  padding-right: 10px;
+  margin-bottom: 0;
+  font-style: oblique;
+}
+
+#additional-names-box ul {
+  padding-left: 10px;
+  margin-top: 0.5em;
+}
+
+#additional-names-box li .additional-name {
+  margin-right: 0.25em;
+}
+
+#additional-names-box li .additional-name .content-image {
+  margin-bottom: 0.25em;
+  margin-top: 0.5em;
+}
+
+#additional-names-box li .annotation {
+  opacity: 0.8;
+  display: inline-block;
+}
+
 /* Images */
 
 .image-container {
@@ -1830,6 +1892,10 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     max-width: unset;
   }
 
+  #additional-names-box {
+    max-width: unset;
+  }
+
   /* Show sticky heading above cover art */
 
   .content-sticky-heading-container {
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index d0d46998..72883e7c 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -338,6 +338,21 @@ trackList:
 #
 misc:
 
+  # additionalNames:
+  #   "Drop"-styled box that catalogues a variety of additional or
+  #   alternate names for the current thing; toggled by clicking on the
+  #   thing's title, which is styled interactively and gets a tooltip
+  #   (hover text), since it isn't usually an interactive element.
+
+  additionalNames:
+    title: "Additional or alternate names:"
+    tooltip: "Click to view additional or alternate names"
+
+    item:
+      _: "{NAME}"
+      withAnnotation: "{NAME} {ANNOTATION}"
+      annotation: "({ANNOTATION})"
+
   # alt:
   #   Fallback text for the alt text of images and artworks - these
   #   are read aloud by screen readers.