« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js45
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js3
-rw-r--r--src/content/dependencies/generatePageLayout.js4
-rw-r--r--src/content/dependencies/generateTrackListItem.js3
-rw-r--r--src/data/composite/control-flow/flipFilter.js36
-rw-r--r--src/data/composite/control-flow/index.js1
-rw-r--r--src/data/composite/control-flow/withAvailabilityFilter.js1
-rw-r--r--src/data/composite/data/withFilteredList.js16
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js52
-rw-r--r--src/data/composite/things/artwork/index.js5
-rw-r--r--src/data/composite/things/artwork/withAttachedArtwork.js43
-rw-r--r--src/data/composite/things/artwork/withContribsFromAttachedArtwork.js (renamed from src/data/composite/things/artwork/withContribsFromMainArtwork.js)15
-rw-r--r--src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js65
-rw-r--r--src/data/composite/things/artwork/withPropertyFromMainArtwork.js100
-rw-r--r--src/data/things/artwork.js61
-rw-r--r--src/html.js94
-rw-r--r--src/static/css/site.css57
-rw-r--r--src/strings-default.yaml14
18 files changed, 394 insertions, 221 deletions
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 3eb7c664..3908414f 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -15,8 +15,8 @@ export default {
       artwork.thing.constructor[Thing.referenceType],
 
     attachedArtistContribs:
-      (!artwork.isMainArtwork && artwork.mainArtwork && artwork.attachAbove
-        ? artwork.mainArtwork.artistContribs
+      (artwork.attachedArtwork
+        ? artwork.attachedArtwork.artistContribs
         : null)
   }),
 
@@ -58,6 +58,11 @@ export default {
         {class: 'origin-details'},
 
         (() => {
+          relations.datetimestamp?.setSlots({
+            style: 'year',
+            tooltip: true,
+          });
+
           const artworkBy =
             language.encapsulate(capsule, 'artworkBy', workingCapsule => {
               const workingOptions = {};
@@ -69,11 +74,7 @@ export default {
 
               if (relations.datetimestamp) {
                 workingCapsule += '.withYear';
-                workingOptions.year =
-                  relations.datetimestamp.slots({
-                    style: 'year',
-                    tooltip: true,
-                  });
+                workingOptions.year = relations.datetimestamp;
               }
 
               return relations.credit.slots({
@@ -111,15 +112,38 @@ export default {
                 workingOptions.label = data.label;
               }
 
+              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
               return language.$(workingCapsule, workingOptions);
             });
 
           const label =
             html.isBlank(artworkBy) &&
             html.isBlank(source) &&
-            language.$(capsule, 'customLabelAlone', {
-              [language.onlyIfOptions]: ['label'],
-              label: data.label,
+            language.encapsulate(capsule, 'customLabel', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['label'],
+                label: data.label,
+              };
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const year =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            html.isBlank(label) &&
+            language.$(capsule, 'year', {
+              [language.onlyIfOptions]: ['year'],
+              year: relations.datetimestamp,
             });
 
           return [
@@ -127,6 +151,7 @@ export default {
             trackArtFromAlbum,
             source,
             label,
+            year,
           ];
         })())),
 };
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 99e7e8ff..4680cb46 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,7 +127,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(artistCredit)));
           }
 
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 8dad97e0..89fefb23 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -268,12 +268,16 @@ export default {
       const maybeTemplate =
         apparentFirst(slots.artworkColumnContent);
 
+      if (!maybeTemplate) return null;
+
       const maybeTemplateContent =
         html.resolve(maybeTemplate, {normalize: 'tag'});
 
       const maybeCoverArtwork =
         apparentFirst(maybeTemplateContent);
 
+      if (!maybeCoverArtwork) return null;
+
       if (maybeCoverArtwork.attributes.has('class', 'cover-artwork')) {
         return maybeTemplate;
       } else {
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 887b6f03..3c850a18 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,7 +97,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(relations.credit)));
           }
 
diff --git a/src/data/composite/control-flow/flipFilter.js b/src/data/composite/control-flow/flipFilter.js
new file mode 100644
index 00000000..995bacad
--- /dev/null
+++ b/src/data/composite/control-flow/flipFilter.js
@@ -0,0 +1,36 @@
+// Flips a filter, so that each true item becomes false, and vice versa.
+// Overwrites the provided dependency.
+//
+// See also:
+//  - withAvailabilityFilter
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `flipFilter`,
+
+  inputs: {
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('filter')]: filterDependency,
+  }) => [filterDependency ?? '#flippedFilter'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('filter'),
+        input.staticDependency('filter'),
+      ],
+
+      compute: (continuation, {
+        [input('filter')]: filter,
+        [input.staticDependency('filter')]: filterDependency,
+      }) => continuation({
+        [filterDependency ?? '#flippedFilter']:
+          filter.map(item => !item),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index 7e137a14..778dc66b 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -10,6 +10,7 @@ export {default as exposeDependency} from './exposeDependency.js';
 export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js';
 export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js';
 export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js';
+export {default as flipFilter} from './flipFilter.js';
 export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
 export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
 export {default as withAvailabilityFilter} from './withAvailabilityFilter.js';
diff --git a/src/data/composite/control-flow/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js
index cfea998e..fd93af71 100644
--- a/src/data/composite/control-flow/withAvailabilityFilter.js
+++ b/src/data/composite/control-flow/withAvailabilityFilter.js
@@ -4,6 +4,7 @@
 // Accepts the same mode options as withResultOfAvailabilityCheck.
 //
 // See also:
+//  - flipFilter
 //  - withFilteredList
 //  - withResultOfAvailabilityCheck
 //
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
index 44c1661d..15ee3373 100644
--- a/src/data/composite/data/withFilteredList.js
+++ b/src/data/composite/data/withFilteredList.js
@@ -2,9 +2,6 @@
 // corresponding items in a list. Items which correspond to a truthy value
 // are kept, and the rest are excluded from the output list.
 //
-// If the flip option is set, only items corresponding with a *falsy* value in
-// the filter are kept.
-//
 // TODO: There should be two outputs - one for the items included according to
 // the filter, and one for the items excluded.
 //
@@ -22,28 +19,19 @@ export default templateCompositeFrom({
   inputs: {
     list: input({type: 'array'}),
     filter: input({type: 'array'}),
-
-    flip: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#filteredList'],
 
   steps: () => [
     {
-      dependencies: [input('list'), input('filter'), input('flip')],
+      dependencies: [input('list'), input('filter')],
       compute: (continuation, {
         [input('list')]: list,
         [input('filter')]: filter,
-        [input('flip')]: flip,
       }) => continuation({
         '#filteredList':
-          list.filter((_item, index) =>
-            (flip
-              ? !filter[index]
-              :  filter[index])),
+          list.filter((_item, index) => filter[index]),
       }),
     },
   ],
diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js
index 83a8cc21..5e165219 100644
--- a/src/data/composite/data/withNearbyItemFromList.js
+++ b/src/data/composite/data/withNearbyItemFromList.js
@@ -9,6 +9,10 @@
 //  - If the 'valuePastEdge' input is provided, that value will be output
 //    instead of null.
 //
+//  - If the 'filter' input is provided, corresponding items will be skipped,
+//    and only (repeating `offset`) the next included in the filter will be
+//    returned.
+//
 // Both the list and item must be provided.
 //
 // See also:
@@ -16,7 +20,6 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {atOffset} from '#sugar';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
@@ -28,9 +31,12 @@ export default templateCompositeFrom({
   inputs: {
     list: input({acceptsNull: false, type: 'array'}),
     item: input({acceptsNull: false}),
-
     offset: input({type: 'number'}),
+
     wrap: input({type: 'boolean', defaultValue: false}),
+    valuePastEdge: input({defaultValue: null}),
+
+    filter: input({defaultValue: null, type: 'array'}),
   },
 
   outputs: ['#nearbyItem'],
@@ -45,29 +51,55 @@ export default templateCompositeFrom({
       dependency: '#index',
       mode: input.value('index'),
 
-      output: input.value({
-        ['#nearbyItem']:
-          null,
-      }),
+      output: input.value({'#nearbyItem': null}),
     }),
 
     {
       dependencies: [
         input('list'),
         input('offset'),
+
         input('wrap'),
+        input('valuePastEdge'),
+
+        input('filter'),
+
         '#index',
       ],
 
       compute: (continuation, {
         [input('list')]: list,
         [input('offset')]: offset,
+
         [input('wrap')]: wrap,
+        [input('valuePastEdge')]: valuePastEdge,
+
+        [input('filter')]: filter,
+
         ['#index']: index,
-      }) => continuation({
-        ['#nearbyItem']:
-          atOffset(list, index, offset, {wrap}),
-      }),
+      }) => {
+        const startIndex = index;
+
+        do {
+          index += offset;
+
+          if (wrap) {
+            index = index % list.length;
+          } else if (index < 0) {
+            return continuation({'#nearbyItem': valuePastEdge});
+          } else if (index >= list.length) {
+            return continuation({'#nearbyItem': valuePastEdge});
+          }
+
+          if (filter && !filter[index]) {
+            continue;
+          }
+
+          return continuation({'#nearbyItem': list[index]});
+        } while (index !== startIndex);
+
+        return continuation({'#nearbyItem': null});
+      },
     },
   ],
 });
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
index 5a592777..3693c10f 100644
--- a/src/data/composite/things/artwork/index.js
+++ b/src/data/composite/things/artwork/index.js
@@ -1,4 +1,5 @@
+export {default as withAttachedArtwork} from './withAttachedArtwork.js';
 export {default as withContainingArtworkList} from './withContainingArtworkList.js';
-export {default as withContribsFromMainArtwork} from './withContribsFromMainArtwork.js';
+export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js';
 export {default as withDate} from './withDate.js';
-export {default as withPropertyFromMainArtwork} from './withPropertyFromMainArtwork.js';
+export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js';
diff --git a/src/data/composite/things/artwork/withAttachedArtwork.js b/src/data/composite/things/artwork/withAttachedArtwork.js
new file mode 100644
index 00000000..d7c0d87b
--- /dev/null
+++ b/src/data/composite/things/artwork/withAttachedArtwork.js
@@ -0,0 +1,43 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {flipFilter, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromList} from '#composite/data';
+
+import withContainingArtworkList from './withContainingArtworkList.js';
+
+export default templateCompositeFrom({
+  annotaion: `withContribsFromMainArtwork`,
+
+  outputs: ['#attachedArtwork'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'attachAbove',
+      mode: input.value('falsy'),
+      output: input.value({'#attachedArtwork': null}),
+    }),
+
+    withContainingArtworkList(),
+
+    withPropertyFromList({
+      list: '#containingArtworkList',
+      property: input.value('attachAbove'),
+    }),
+
+    flipFilter({
+      filter: '#containingArtworkList.attachAbove',
+    }).outputs({
+      '#containingArtworkList.attachAbove': '#filterNotAttached',
+    }),
+
+    withNearbyItemFromList({
+      list: '#containingArtworkList',
+      item: input.myself(),
+      offset: input.value(-1),
+      filter: '#filterNotAttached',
+    }).outputs({
+      '#nearbyItem': '#attachedArtwork',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artwork/withContribsFromMainArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
index 25616ad6..36abb3fe 100644
--- a/src/data/composite/things/artwork/withContribsFromMainArtwork.js
+++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
@@ -4,26 +4,25 @@ import {raiseOutputWithoutDependency} from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 import {withRecontextualizedContributionList} from '#composite/wiki-data';
 
-import withPropertyFromMainArtwork from './withPropertyFromMainArtwork.js';
+import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js';
 
 export default templateCompositeFrom({
-  annotaion: `withContribsFromMainArtwork`,
+  annotaion: `withContribsFromAttachedArtwork`,
 
-  outputs: ['#mainArtwork.artistContribs'],
+  outputs: ['#attachedArtwork.artistContribs'],
 
   steps: () => [
-    withPropertyFromMainArtwork({
+    withPropertyFromAttachedArtwork({
       property: input.value('artistContribs'),
-      onlyIfAttached: input.value(true),
     }),
 
     raiseOutputWithoutDependency({
-      dependency: '#mainArtwork.artistContribs',
-      output: input.value({'#mainArtwork.artistContribs': null}),
+      dependency: '#attachedArtwork.artistContribs',
+      output: input.value({'#attachedArtwork.artistContribs': null}),
     }),
 
     withRecontextualizedContributionList({
-      list: '#mainArtwork.artistContribs',
+      list: '#attachedArtwork.artistContribs',
     }),
   ],
 });
diff --git a/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js
new file mode 100644
index 00000000..a2f954b9
--- /dev/null
+++ b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js
@@ -0,0 +1,65 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withAttachedArtwork from './withAttachedArtwork.js';
+
+function getOutputName({
+  [input.staticValue('property')]: property,
+}) {
+  if (property) {
+    return `#attachedArtwork.${property}`;
+  } else {
+    return '#value';
+  }
+}
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAttachedArtwork`,
+
+  inputs: {
+    property: input({type: 'string'}),
+  },
+
+  outputs: inputs => [getOutputName(inputs)],
+
+  steps: () => [
+    {
+      dependencies: [input.staticValue('property')],
+      compute: (continuation, inputs) =>
+        continuation({'#output': getOutputName(inputs)}),
+    },
+
+    withAttachedArtwork(),
+
+    withResultOfAvailabilityCheck({
+      from: '#attachedArtwork',
+    }),
+
+    {
+      dependencies: ['#availability', '#output'],
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['#output']: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput({[output]: null})),
+    },
+
+    withPropertyFromObject({
+      object: '#attachedArtwork',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#value', '#output'],
+      compute: (continuation, {
+        ['#value']: value,
+        ['#output']: output,
+      }) =>
+        continuation.raiseOutput({[output]: value}),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artwork/withPropertyFromMainArtwork.js b/src/data/composite/things/artwork/withPropertyFromMainArtwork.js
deleted file mode 100644
index a0233119..00000000
--- a/src/data/composite/things/artwork/withPropertyFromMainArtwork.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {withResultOfAvailabilityCheck} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import withContainingArtworkList from './withContainingArtworkList.js';
-
-function getOutputName({
-  [input.staticValue('property')]: property,
-}) {
-  if (property) {
-    return `#mainArtwork.${property}`;
-  } else {
-    return '#value';
-  }
-}
-
-export default templateCompositeFrom({
-  annotation: `withPropertyFromMainArtwork`,
-
-  inputs: {
-    property: input({type: 'string'}),
-    onlyIfAttached: input({type: 'boolean', defaultValue: false}),
-  },
-
-  outputs: inputs => [getOutputName(inputs)],
-
-  steps: () => [
-    {
-      dependencies: [input.staticValue('property')],
-      compute: (continuation, inputs) =>
-        continuation({'#output': getOutputName(inputs)}),
-    },
-
-    {
-      dependencies: [input('onlyIfAttached'), 'attachAbove', '#output'],
-      compute: (continuation, {
-        [input('onlyIfAttached')]: onlyIfAttached,
-        ['attachAbove']: attachAbove,
-        ['#output']: output,
-      }) =>
-        (onlyIfAttached && attachAbove
-          ? continuation()
-       : onlyIfAttached
-          ? continuation.raiseOutput({[output]: null})
-          : continuation()),
-    },
-
-    withContainingArtworkList(),
-
-    withResultOfAvailabilityCheck({
-      from: '#containingArtworkList',
-    }),
-
-    {
-      dependencies: ['#availability', '#output'],
-      compute: (continuation, {
-        ['#availability']: availability,
-        ['#output']: output,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.raiseOutput({[output]: null})),
-    },
-
-    {
-      dependencies: ['#containingArtworkList'],
-      compute: (continuation, {
-        ['#containingArtworkList']: list,
-      }) =>
-        continuation({'#mainArtwork': list[0]}),
-    },
-
-    {
-      dependencies: [input.myself(), '#mainArtwork', '#output'],
-      compute: (continuation, {
-        [input.myself()]: myself,
-        ['#mainArtwork']: mainArtwork,
-        ['#output']: output,
-      }) =>
-        (myself === mainArtwork
-          ? continuation.raiseOutput({[output]: null})
-          : continuation()),
-    },
-
-    withPropertyFromObject({
-      object: '#mainArtwork',
-      property: input('property'),
-    }),
-
-    {
-      dependencies: ['#value', '#output'],
-      compute: (continuation, {
-        ['#value']: value,
-        ['#output']: output,
-      }) =>
-        continuation.raiseOutput({[output]: value}),
-    },
-  ],
-});
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
index 8c88dea7..3cdb07d0 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -54,9 +54,10 @@ import {
 } from '#composite/wiki-properties';
 
 import {
+  withAttachedArtwork,
   withContainingArtworkList,
-  withContribsFromMainArtwork,
-  withPropertyFromMainArtwork,
+  withContribsFromAttachedArtwork,
+  withPropertyFromAttachedArtwork,
   withDate,
 } from '#composite/things/artwork';
 
@@ -178,10 +179,10 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withContribsFromMainArtwork(),
+      withContribsFromAttachedArtwork(),
 
       exposeDependencyOrContinue({
-        dependency: '#mainArtwork.artistContribs',
+        dependency: '#attachedArtwork.artistContribs',
       }),
 
       exitWithoutDependency({
@@ -222,13 +223,12 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withPropertyFromMainArtwork({
+      withPropertyFromAttachedArtwork({
         property: input.value('artTags'),
-        onlyIfAttached: input.value(true),
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#mainArtwork.artTags',
+        dependency: '#attachedArtwork.artTags',
       }),
 
       exitWithoutDependency({
@@ -360,36 +360,17 @@ export class Artwork extends Thing {
       },
     ],
 
-    siblingArtworks: [
-      withContainingArtworkList(),
-
-      exitWithoutDependency({
-        dependency: '#containingArtworkList',
-        value: input.value(null),
-      }),
+    attachedArtwork: [
+      withAttachedArtwork(),
 
-      withIndexInList({
-        list: '#containingArtworkList',
-        item: input.myself(),
-      }),
-
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(null),
+      exposeDependency({
+        dependency: '#attachedArtwork',
       }),
-
-      {
-        dependencies: ['#containingArtworkList', '#index'],
-        compute: ({
-          ['#containingArtworkList']: list,
-          ['#index']: index,
-        }) => [
-          ...list.slice(0, index),
-          ...list.slice(index + 1),
-        ],
-      },
     ],
+
+    attachingArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichAttach'),
+    }),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -448,6 +429,18 @@ export class Artwork extends Thing {
       date: ({artwork}) => artwork.date,
     },
 
+    artworksWhichAttach: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        (referencingArtwork.attachAbove
+          ? [referencingArtwork]
+          : []),
+
+      referenced: referencingArtwork =>
+        [referencingArtwork.attachedArtwork],
+    },
+
     artworksWhichFeature: {
       bindTo: 'artworkData',
 
diff --git a/src/html.js b/src/html.js
index 0fe424df..9e4c39ab 100644
--- a/src/html.js
+++ b/src/html.js
@@ -512,6 +512,10 @@ export class Tag {
     }
   }
 
+  #getAttributeRaw(attribute) {
+    return this.attributes.get(attribute);
+  }
+
   set onlyIfContent(value) {
     this.#setAttributeFlag(onlyIfContent, value);
   }
@@ -662,7 +666,7 @@ export class Tag {
 
     const chunkwrapSplitter =
       (this.chunkwrap
-        ? this.#getAttributeString('split')
+        ? this.#getAttributeRaw('split')
         : null);
 
     let seenChunkwrapSplitter =
@@ -727,7 +731,7 @@ export class Tag {
 
       const chunkwrapChunks =
         (typeof nonTemplateItem === 'string' && chunkwrapSplitter
-          ? itemContent.split(chunkwrapSplitter)
+          ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter))
           : null);
 
       const itemIncludesChunkwrapSplit =
@@ -773,7 +777,7 @@ export class Tag {
 
       appendItemContent: {
         if (itemIncludesChunkwrapSplit) {
-          for (const [index, chunk] of chunkwrapChunks.entries()) {
+          for (const [index, {chunk, following}] of chunkwrapChunks.entries()) {
             if (index === 0) {
               // The first chunk isn't actually a chunk all on its own, it's
               // text that should be appended to the previous chunk. We will
@@ -781,12 +785,27 @@ export class Tag {
               // the next chunk.
               content += chunk;
             } else {
-              const whitespace = chunk.match(/^\s+/) ?? '';
-              content += chunkwrapSplitter;
+              const followingWhitespace = following.match(/\s+$/) ?? '';
+              const chunkWhitespace = chunk.match(/^\s+/) ?? '';
+
+              if (followingWhitespace) {
+                content += following.slice(0, -followingWhitespace.length);
+              } else {
+                content += following;
+              }
+
               content += '</span>';
-              content += whitespace;
+
+              content += followingWhitespace;
+              content += chunkWhitespace;
+
               content += '<span class="chunkwrap">';
-              content += chunk.slice(whitespace.length);
+
+              if (chunkWhitespace) {
+                content += chunk.slice(chunkWhitespace.length);
+              } else {
+                content += chunk;
+              }
             }
           }
 
@@ -1009,6 +1028,49 @@ export class Tag {
   }
 }
 
+export function* getChunkwrapChunks(content, splitter) {
+  const splitString =
+    (typeof splitter === 'string'
+      ? splitter
+      : null);
+
+  if (splitString) {
+    let following = '';
+    for (const chunk of content.split(splitString)) {
+      yield {chunk, following};
+      following = splitString;
+    }
+
+    return;
+  }
+
+  const splitRegExp =
+    (splitter instanceof RegExp
+      ? new RegExp(
+          splitter.source,
+          (splitter.flags.includes('g')
+            ? splitter.flags
+            : splitter.flags + 'g'))
+      : null);
+
+  if (splitRegExp) {
+    let following = '';
+    let prevIndex = 0;
+    for (const match of content.matchAll(splitRegExp)) {
+      const chunk = content.slice(prevIndex, match.index);
+      yield {chunk, following};
+
+      following = match[0];
+      prevIndex = match.index + match[0].length;
+    }
+
+    const chunk = content.slice(prevIndex);
+    yield {chunk, following};
+
+    return;
+  }
+}
+
 export function attributes(attributes) {
   return new Attributes(attributes);
 }
@@ -1254,6 +1316,9 @@ export class Attributes {
           return value.some(Boolean);
         } else if (value === null) {
           return false;
+        } else if (value instanceof RegExp) {
+          // Oooooooo.
+          return true;
         } else {
           // Other objects are an error.
           break;
@@ -1285,13 +1350,16 @@ export class Attributes {
       case 'number':
         return value.toString();
 
-      // If it's a kept object, it's an array.
       case 'object': {
-        const joiner =
-          (descriptor?.arraylike && descriptor?.join)
-            ?? ' ';
+        if (Array.isArray(value)) {
+          const joiner =
+            (descriptor?.arraylike && descriptor?.join)
+              ?? ' ';
 
-        return value.filter(Boolean).join(joiner);
+          return value.filter(Boolean).join(joiner);
+        } else {
+          return value;
+        }
       }
 
       default:
@@ -1963,6 +2031,8 @@ export const isAttributeValue =
   anyOf(
     isString, isNumber, isBoolean, isArray,
     isTag, isTemplate,
+    // Evil. Ooooo
+    validateInstanceOf(RegExp),
     validateArrayItems(item => isAttributeValue(item)));
 
 export const isAttributesAdditionPair = pair => {
diff --git a/src/static/css/site.css b/src/static/css/site.css
index b087582a..ca05a42e 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -161,10 +161,9 @@ body::before, .wallpaper-part {
 }
 
 .sidebar-column {
-  flex: 1 1 20%;
+  flex: 1 1 35%;
   min-width: 150px;
   max-width: 250px;
-  flex-basis: 250px;
   align-self: flex-start;
 }
 
@@ -1632,6 +1631,10 @@ p.image-details.origin-details {
   margin-bottom: 2px;
 }
 
+.cover-artwork-joiner {
+  z-index: -2;
+}
+
 .cover-artwork-joiner::after {
   content: "";
   display: block;
@@ -1669,35 +1672,26 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 }
 
 .commentary-entry-heading {
-  display: flex;
-  flex-direction: row;
-
   margin-left: 15px;
-  padding-left: 5px;
-  max-width: 625px;
+  padding-left: calc(5px + 1.25ch);
+  text-indent: -1.25ch;
+  margin-right: min(calc(8vw - 35px), 45px);
   padding-bottom: 0.2em;
 
   border-bottom: 1px solid var(--dim-color);
 }
 
-.commentary-entry-heading-text {
-  flex-grow: 1;
-  padding-left: 1.25ch;
-  text-indent: -1.25ch;
-}
-
 .commentary-entry-accent {
   font-style: oblique;
 }
 
 .commentary-entry-heading .commentary-date {
-  flex-shrink: 0;
-
-  margin-left: 0.75ch;
-  align-self: flex-end;
+  display: inline-block;
+  text-indent: 0;
+}
 
-  padding-left: 0.5ch;
-  padding-right: 0.25ch;
+.commentary-entry-heading.dated .commentary-entry-heading-text {
+  margin-right: 0.75ch;
 }
 
 .commentary-entry-heading .hoverable {
@@ -1737,7 +1731,6 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 
 .lyrics-entry {
   padding-left: 40px;
-  max-width: 600px;
 }
 
 .js-hide,
@@ -1957,7 +1950,6 @@ ul.quick-info li:not(:last-child)::after {
 
 li .by {
   font-style: oblique;
-  max-width: 600px;
 }
 
 li .by a {
@@ -1973,8 +1965,8 @@ p code {
 
 #content blockquote {
   margin-left: 40px;
-  max-width: 600px;
-  margin-right: 0;
+  margin-right: min(8vw, 75px);
+  width: auto;
 }
 
 #content blockquote blockquote {
@@ -2021,7 +2013,6 @@ main.long-content > h1 {
 
 dl dt {
   padding-left: 40px;
-  max-width: 600px;
 }
 
 dl dt {
@@ -2061,6 +2052,15 @@ ul > li.has-details {
   margin-left: 0;
 }
 
+.album-group-list li {
+  padding-left: 1.5ch;
+  text-indent: -1.5ch;
+}
+
+.album-group-list li > * {
+  text-indent: 0;
+}
+
 .album-group-list blockquote {
   max-width: 540px;
   margin-bottom: 9px;
@@ -3550,7 +3550,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Layout - Wide (most computers) */
 
-@media (min-width: 900px) {
+@media (min-width: 850px) {
   #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible),
   #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) {
     display: none;
@@ -3564,7 +3564,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
  * if so desired.
  */
 
-@media (min-width: 600px) and (max-width: 899.98px) {
+@media (min-width: 600px) and (max-width: 849.98px) {
   /* Medium layout is mainly defined (to the user) by hiding the sidebar, so
    * don't apply the similar layout change of widening the long-content area
    * if this page doesn't have a sidebar to hide in the first place.
@@ -3595,7 +3595,8 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   #artwork-column {
     float: right;
     width: 40%;
-    max-width: 400px;
+    min-width: 220px;
+    max-width: 280px;
     margin: -60px 0 10px 20px;
 
     position: relative;
@@ -3624,7 +3625,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Layout - Medium or Thin */
 
-@media (max-width: 899.98px) {
+@media (max-width: 849.98px) {
   .sidebar.collapsible,
   .sidebar-box-joiner.collapsible,
   .sidebar-column.all-boxes-collapsible {
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index fe40e767..ef885e00 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -931,9 +931,21 @@ misc:
     source.customLabel: >-
       {LABEL} via {SOURCE}
 
-    customLabelAlone: >-
+    source.withYear: >-
+      Via {SOURCE} ({YEAR})
+
+    source.customLabel.withYear: >-
+      {LABEL} ({YEAR}) via {SOURCE}
+
+    customLabel: >-
       {LABEL}
 
+    customLabel.withYear: >-
+      {LABEL} ({YEAR})
+
+    year: >-
+      Released {YEAR}
+
     trackArtFromAlbum: "Album cover for {ALBUM}"
 
     sameTagsAsMainArtwork: "Same tags as main artwork"