« 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/data/things/thing.js4
-rw-r--r--src/data/things/track.js2
-rw-r--r--src/data/yaml.js4
-rw-r--r--src/listing-spec.js68
-rw-r--r--src/misc-templates.js81
-rw-r--r--src/page/album.js57
-rw-r--r--src/page/group.js7
-rw-r--r--src/page/track.js157
-rw-r--r--src/static/client.js77
-rw-r--r--src/static/site3.css58
-rw-r--r--src/strings-default.json39
-rwxr-xr-xsrc/upd8.js6
-rw-r--r--src/write/bind-utilities.js6
-rw-r--r--src/write/page-template.js106
14 files changed, 552 insertions, 120 deletions
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index b9fa60c6..5ab15c0e 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -148,6 +148,10 @@ export default class Thing extends CacheableObject {
     additionalFiles: () => ({
       flags: {update: true, expose: true},
       update: {validate: isAdditionalFileList},
+      expose: {
+        transform: (additionalFiles) =>
+          additionalFiles ?? [],
+      },
     }),
 
     // A reference list! Keep in mind this is for general references to wiki
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 1778ed27..0751b2d0 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -96,6 +96,8 @@ export class Track extends Thing {
     commentary: Thing.common.commentary(),
     lyrics: Thing.common.simpleString(),
     additionalFiles: Thing.common.additionalFiles(),
+    sheetMusicFiles: Thing.common.additionalFiles(),
+    midiProjectFiles: Thing.common.additionalFiles(),
 
     // Update only
 
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 6350588d..7cd23cfc 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -248,6 +248,8 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
     'Cover Artists': parseContributors,
 
     'Additional Files': parseAdditionalFiles,
+    'Sheet Music Files': parseAdditionalFiles,
+    'MIDI Project Files': parseAdditionalFiles,
   },
 
   propertyFieldMapping: {
@@ -264,6 +266,8 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
     lyrics: 'Lyrics',
     commentary: 'Commentary',
     additionalFiles: 'Additional Files',
+    sheetMusicFiles: 'Sheet Music Files',
+    midiProjectFiles: 'MIDI Project Files',
 
     originalReleaseTrackByRef: 'Originally Released As',
     referencedTracksByRef: 'Referenced Tracks',
diff --git a/src/listing-spec.js b/src/listing-spec.js
index ef51fe90..29b7645c 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -826,6 +826,74 @@ const listingSpec = [
   },
 
   {
+    directory: 'tracks/with-sheet-music-files',
+    stringsKey: 'listTracks.withSheetMusicFiles',
+
+    data: ({wikiData: {albumData}}) =>
+      albumData
+        .map(album => ({
+          album,
+          tracks: album.tracks.filter(t => !empty(t.sheetMusicFiles)),
+        }))
+        .filter(({tracks}) => !empty(tracks)),
+
+    html: (data, {html, language, link}) =>
+      html.tag('dl',
+        data.flatMap(({album, tracks}) => [
+          html.tag('dt',
+            {class: 'content-heading'},
+            language.$('listingPage.listTracks.withSheetMusicFiles.album', {
+              album: link.album(album),
+              date: language.formatDate(album.date),
+            })),
+
+          html.tag('dd',
+            html.tag('ul',
+              tracks.map(track =>
+                html.tag('li',
+                  language.$('listingPage.listTracks.withSheetMusicFiles.track', {
+                    track: link.track(track, {
+                      hash: 'sheet-music-files',
+                    }),
+                  }))))),
+        ])),
+  },
+
+  {
+    directory: 'tracks/with-midi-project-files',
+    stringsKey: 'listTracks.withMidiProjectFiles',
+
+    data: ({wikiData: {albumData}}) =>
+      albumData
+        .map(album => ({
+          album,
+          tracks: album.tracks.filter(t => !empty(t.midiProjectFiles)),
+        }))
+        .filter(({tracks}) => !empty(tracks)),
+
+    html: (data, {html, language, link}) =>
+      html.tag('dl',
+        data.flatMap(({album, tracks}) => [
+          html.tag('dt',
+            {class: 'content-heading'},
+            language.$('listingPage.listTracks.withMidiProjectFiles.album', {
+              album: link.album(album),
+              date: language.formatDate(album.date),
+            })),
+
+          html.tag('dd',
+            html.tag('ul',
+              tracks.map(track =>
+                html.tag('li',
+                  language.$('listingPage.listTracks.withMidiProjectFiles.track', {
+                    track: link.track(track, {
+                      hash: 'midi-project-files',
+                    }),
+                  }))))),
+        ])),
+  },
+
+  {
     directory: 'tags/by-name',
     stringsKey: 'listTags.byName',
 
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 21dca90e..171b4825 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -49,46 +49,31 @@ function unbound_generateAdditionalFilesList(additionalFiles, {
 }) {
   if (empty(additionalFiles)) return [];
 
-  const fileCount = additionalFiles.flatMap((g) => g.files).length;
-
-  return html.fragment([
-    html.tag('p',
-      {
-        id: 'additional-files',
-        class: ['content-heading'],
-      },
-      language.$('releaseInfo.additionalFiles.heading', {
-        additionalFiles: language.countAdditionalFiles(fileCount, {
-          unit: true,
-        }),
-      })),
-
-    html.tag('dl',
-      additionalFiles.flatMap(({title, description, files}) => [
-        html.tag('dt',
-          (description
-            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-                title,
-                description,
-              })
-            : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-        html.tag('dd',
-          html.tag('ul',
-            files.map((file) => {
-              const size = getFileSize(file);
-              return html.tag('li',
-                (size
-                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                      file: linkFile(file),
-                      size: language.formatFileSize(size),
-                    })
-                  : language.$('releaseInfo.additionalFiles.file', {
-                      file: linkFile(file),
-                    })));
-            }))),
-      ])),
-  ]);
+  return html.tag('dl',
+    additionalFiles.flatMap(({title, description, files}) => [
+      html.tag('dt',
+        (description
+          ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
+              title,
+              description,
+            })
+          : language.$('releaseInfo.additionalFiles.entry', {title}))),
+
+      html.tag('dd',
+        html.tag('ul',
+          files.map((file) => {
+            const size = (getFileSize && getFileSize(file));
+            return html.tag('li',
+              (size
+                ? language.$('releaseInfo.additionalFiles.file.withSize', {
+                    file: linkFile(file),
+                    size: language.formatFileSize(size),
+                  })
+                : language.$('releaseInfo.additionalFiles.file', {
+                    file: linkFile(file),
+                  })))
+          }))),
+    ]));
 }
 
 // Artist strings
@@ -933,6 +918,21 @@ function unbound_generateNavigationLinks(current, {
 
 // Sticky heading, ooooo
 
+function unbound_generateContentHeading({
+  html,
+
+  id,
+  title,
+}) {
+  return html.tag('p',
+    {
+      class: 'content-heading',
+      id,
+      tabindex: '0',
+    },
+    title);
+}
+
 function unbound_generateStickyHeadingContainer({
   getRevealStringFromArtTags,
   html,
@@ -1042,6 +1042,7 @@ export {
   unbound_generateInfoGalleryLinks as generateInfoGalleryLinks,
   unbound_generateNavigationLinks as generateNavigationLinks,
 
+  unbound_generateContentHeading as generateContentHeading,
   unbound_generateStickyHeadingContainer as generateStickyHeadingContainer,
 
   unbound_getFooterLocalizationLinks as getFooterLocalizationLinks,
diff --git a/src/page/album.js b/src/page/album.js
index 897e5110..24033b1d 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -50,6 +50,8 @@ export function write(album, {wikiData}) {
   };
 
   const hasAdditionalFiles = !empty(album.additionalFiles);
+  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
+
   const albumDuration = getTotalDuration(album.tracks);
 
   const displayTrackSections =
@@ -336,18 +338,22 @@ export function write(album, {wikiData}) {
               ]),
 
             ...html.fragment(
-              hasAdditionalFiles &&
-                generateAdditionalFilesList(album.additionalFiles, {
-                  // TODO: Kinda near the metal here...
-                  getFileSize: (file) =>
-                    getSizeOfAdditionalFile(
-                      urls.from('media.root').to(
-                        'media.albumAdditionalFile',
-                        album.directory,
-                        file)),
-                  linkFile: (file) =>
-                    link.albumAdditionalFile({album, file}),
-                })),
+              hasAdditionalFiles && [
+                html.tag('p',
+                  {id: 'additional-files', class: ['content-heading']},
+                  language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
+                      unit: true,
+                    }),
+                  })),
+
+                generateAlbumAdditionalFilesList(album, album.additionalFiles, {
+                  generateAdditionalFilesList,
+                  getSizeOfAdditionalFile,
+                  link,
+                  urls,
+                }),
+              ]),
 
             ...html.fragment(
               album.commentary && [
@@ -543,7 +549,7 @@ export function generateAlbumSidebar(album, currentTrack, {
           html.tag(
             'summary',
             {style: getLinkThemeString(color)},
-            [
+            html.tag('span', [
               listTag === 'ol' &&
                 language.$('albumSidebar.trackList.group.withRange', {
                   group: groupName,
@@ -555,7 +561,7 @@ export function generateAlbumSidebar(album, currentTrack, {
                 language.$('albumSidebar.trackList.group', {
                   group: groupName,
                 }),
-            ]),
+            ])),
           html.tag(listTag,
             listTag === 'ol' ? {start: startIndex + 1} : {},
             tracks.map(trackToListItem)),
@@ -834,3 +840,26 @@ export function generateAlbumChronologyLinks(album, currentTrack, {
         })),
     ]);
 }
+
+export function generateAlbumAdditionalFilesList(album, additionalFiles, {
+  fileSize = true,
+
+  generateAdditionalFilesList,
+  getSizeOfAdditionalFile,
+  link,
+  urls,
+}) {
+  return generateAdditionalFilesList(additionalFiles, {
+    getFileSize:
+      (fileSize
+        ? (file) =>
+            // TODO: Kinda near the metal here...
+            getSizeOfAdditionalFile(
+              urls
+                .from('media.root')
+                .to('media.albumAdditionalFile', album.directory, file))
+        : () => null),
+    linkFile: (file) =>
+      link.albumAdditionalFile({album, file}),
+  });
+}
diff --git a/src/page/group.js b/src/page/group.js
index 9a48c1d8..81e1728d 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -247,9 +247,10 @@ function generateGroupSidebar(currentGroup, isGallery, {
           [
             html.tag('summary',
               {style: getLinkThemeString(category.color)},
-              language.$('groupSidebar.groupList.category', {
-                category: `<span class="group-name">${category.name}</span>`,
-              })),
+              html.tag('span',
+                language.$('groupSidebar.groupList.category', {
+                  category: `<span class="group-name">${category.name}</span>`,
+                }))),
             html.tag('ul',
               category.groups.map((group) => {
                 const linkKey = (
diff --git a/src/page/track.js b/src/page/track.js
index caba3668..7f0d1cf2 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -5,6 +5,7 @@ import {
   generateAlbumNavLinks,
   generateAlbumSecondaryNav,
   generateAlbumSidebar,
+  generateAlbumAdditionalFilesList as unbound_generateAlbumAdditionalFilesList,
 } from './album.js';
 
 import {
@@ -73,6 +74,11 @@ export function write(track, {wikiData}) {
   const hasCommentary =
     track.commentary || otherReleases.some((t) => t.commentary);
 
+  const hasAdditionalFiles = !empty(track.additionalFiles);
+  const hasSheetMusicFiles = !empty(track.sheetMusicFiles);
+  const hasMidiProjectFiles = !empty(track.midiProjectFiles);
+  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
+
   const generateCommentary = ({language, link, transformMultiline}) =>
     transformMultiline([
       track.commentary,
@@ -161,12 +167,16 @@ export function write(track, {wikiData}) {
     page: ({
       absoluteTo,
       fancifyURL,
+      generateAdditionalFilesList,
+      generateAdditionalFilesShortcut,
       generateChronologyLinks,
+      generateContentHeading,
       generateNavigationLinks,
       generateTrackListDividedByGroups,
       getAlbumStylesheet,
       getArtistString,
       getLinkThemeString,
+      getSizeOfAdditionalFile,
       getThemeString,
       getTrackCover,
       html,
@@ -184,6 +194,14 @@ export function write(track, {wikiData}) {
         link,
       });
 
+      const generateAlbumAdditionalFilesList = bindOpts(unbound_generateAlbumAdditionalFilesList, {
+        [bindOpts.bindIndex]: 2,
+        generateAdditionalFilesList,
+        getSizeOfAdditionalFile,
+        link,
+        urls,
+      });
+
       return {
         title: language.$('trackPage.title', {track: track.name}),
         stylesheet: getAlbumStylesheet(album, {to}),
@@ -274,6 +292,30 @@ export function write(track, {wikiData}) {
               ]),
 
             html.tag('p',
+              {
+                [html.onlyIfContent]: true,
+                [html.joinChildren]: '<br>',
+              },
+              [
+                hasSheetMusicFiles &&
+                  language.$('releaseInfo.sheetMusicFiles.shortcut', {
+                    link: html.tag('a',
+                      {href: '#sheet-music-files'},
+                      language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
+                  }),
+
+                hasMidiProjectFiles &&
+                  language.$('releaseInfo.midiProjectFiles.shortcut', {
+                    link: html.tag('a',
+                      {href: '#midi-project-files'},
+                      language.$('releaseInfo.midiProjectFiles.shortcut.link')),
+                  }),
+
+                hasAdditionalFiles &&
+                  generateAdditionalFilesShortcut(track.additionalFiles),
+              ]),
+
+            html.tag('p',
               (empty(track.urls)
                 ? language.$('releaseInfo.listenOn.noLinks')
                 : language.$('releaseInfo.listenOn', {
@@ -283,8 +325,10 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(otherReleases) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.alsoReleasedAs')),
+                generateContentHeading({
+                  id: 'also-released-as',
+                  title: language.$('releaseInfo.alsoReleasedAs'),
+                }),
 
                 html.tag('ul', otherReleases.map(track =>
                   html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', {
@@ -295,8 +339,10 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(contributorContribs) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.contributors')),
+                generateContentHeading({
+                  id: 'contributors',
+                  title: language.$('releaseInfo.contributors'),
+                }),
 
                 html.tag('ul', contributorContribs.map(contrib =>
                   html.tag('li', getArtistString([contrib], {
@@ -307,20 +353,26 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(referencedTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksReferenced', {
-                    track: html.tag('i', track.name),
-                  })),
+                generateContentHeading({
+                  id: 'references',
+                  title:
+                    language.$('releaseInfo.tracksReferenced', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
 
                 html.tag('ul', referencedTracks.map(getTrackItem)),
               ]),
 
             ...html.fragment(
               !empty(referencedByTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksThatReference', {
-                    track: html.tag('i', track.name),
-                  })),
+                generateContentHeading({
+                  id: 'referenced-by',
+                  title:
+                    language.$('releaseInfo.tracksThatReference', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
 
                 generateTrackListDividedByGroups(referencedByTracks, {
                   getTrackItem,
@@ -330,20 +382,26 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(sampledTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksSampled', {
-                    track: html.tag('i', track.name),
-                  })),
+                generateContentHeading({
+                  id: 'samples',
+                  title:
+                    language.$('releaseInfo.tracksSampled', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
 
                 html.tag('ul', sampledTracks.map(getTrackItem)),
               ]),
 
             ...html.fragment(
               !empty(sampledByTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksThatSample', {
-                    track: html.tag('i', track.name),
-                  })),
+                generateContentHeading({
+                  id: 'sampled-by',
+                  title:
+                    language.$('releaseInfo.tracksThatSample', {
+                      track: html.tag('i', track.name),
+                    })
+                }),
 
                 html.tag('ul', sampledByTracks.map(getTrackItem)),
               ]),
@@ -351,10 +409,13 @@ export function write(track, {wikiData}) {
             ...html.fragment(
               wikiInfo.enableFlashesAndGames &&
               !empty(flashesThatFeature) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.flashesThatFeature', {
-                    track: html.tag('i', track.name),
-                  })),
+                generateContentHeading({
+                  id: 'featured-in',
+                  title:
+                    language.$('releaseInfo.flashesThatFeature', {
+                      track: html.tag('i', track.name),
+                    }),
+                }),
 
                 html.tag('ul', flashesThatFeature.map(({flash, as}) =>
                   html.tag('li',
@@ -371,16 +432,56 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               track.lyrics && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.lyrics')),
+                generateContentHeading({
+                  id: 'lyrics',
+                  title: language.$('releaseInfo.lyrics'),
+                }),
 
                 html.tag('blockquote', transformLyrics(track.lyrics)),
               ]),
 
             ...html.fragment(
+              hasSheetMusicFiles && [
+                generateContentHeading({
+                  id: 'sheet-music-files',
+                  title: language.$('releaseInfo.sheetMusicFiles.heading'),
+                }),
+
+                generateAlbumAdditionalFilesList(album, track.sheetMusicFiles, {
+                  fileSize: false,
+                }),
+              ]),
+
+            ...html.fragment(
+              hasMidiProjectFiles && [
+                generateContentHeading({
+                  id: 'midi-project-files',
+                  title: language.$('releaseInfo.midiProjectFiles.heading'),
+                }),
+
+                generateAlbumAdditionalFilesList(album, track.midiProjectFiles),
+              ]),
+
+            ...html.fragment(
+              hasAdditionalFiles && [
+                generateContentHeading({
+                  id: 'additional-files',
+                  title: language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
+                      unit: true,
+                    }),
+                  })
+                }),
+
+                generateAlbumAdditionalFilesList(album, track.additionalFiles),
+              ]),
+
+            ...html.fragment(
               hasCommentary && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.artistCommentary')),
+                generateContentHeading({
+                  id: 'artist-commentary',
+                  title: language.$('releaseInfo.artistCommentary'),
+                }),
 
                 html.tag('blockquote', generateCommentary({
                   link,
diff --git a/src/static/client.js b/src/static/client.js
index 9ae5510a..47936d82 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -444,6 +444,81 @@ if (localStorage.tryInfoCards) {
   addInfoCardLinkHandlers('track');
 }
 
+// Custom hash links --------------------------------------
+
+function addHashLinkHandlers() {
+  // Instead of defining a scroll offset (to account for the sticky heading)
+  // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
+  // This lets the scroll offset be consolidated where it makes sense, and
+  // sets an appropriate offset when (re)loading a page with hash for free!
+
+  let wasHighlighted;
+
+  for (const a of document.links) {
+    const href = a.getAttribute('href');
+    if (!href || !href.startsWith('#')) {
+      continue;
+    }
+
+    a.addEventListener('click', handleHashLinkClicked);
+  }
+
+  function handleHashLinkClicked(evt) {
+    if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
+      return;
+    }
+
+    const href = evt.target.getAttribute('href');
+    const id = href.slice(1);
+    const linked = document.getElementById(id);
+
+    if (!linked) {
+      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');
+    skipper.style.display = 'none';
+    setTimeout(() => skipper.style.display = '');
+
+    const box = linked.getBoundingClientRect();
+    const style = window.getComputedStyle(linked);
+
+    const scrollY =
+        window.scrollY
+      + box.top
+      - style['scroll-margin-top'].replace('px', '');
+
+    evt.preventDefault();
+    history.pushState({}, '', href);
+    window.scrollTo({top: scrollY, behavior: 'smooth'});
+    linked.focus({preventScroll: true});
+
+    const maxScroll =
+        document.body.scrollHeight
+      - window.innerHeight;
+
+    if (scrollY > maxScroll && linked.classList.contains('content-heading')) {
+      if (wasHighlighted) {
+        wasHighlighted.classList.remove('highlight-hash-link');
+      }
+
+      wasHighlighted = linked;
+      linked.classList.add('highlight-hash-link');
+      linked.addEventListener('animationend', function handle(evt) {
+        if (evt.animationName === 'highlight-hash-link') {
+          linked.removeEventListener('animationend', handle);
+          linked.classList.remove('highlight-hash-link');
+          wasHighlighted = null;
+        }
+      });
+    }
+  }
+}
+
+addHashLinkHandlers();
+
 // Sticky content heading ---------------------------------
 
 const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-heading-container'))
@@ -510,7 +585,7 @@ function updateStickyHeading() {
       for (let i = contentHeadings.length - 1; i >= 0; i--) {
         const heading = contentHeadings[i];
         const headingRect = heading.getBoundingClientRect();
-        if (headingRect.y + headingRect.height / 1.5 < stickyBottom) {
+        if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
           closestHeading = heading;
           break;
         }
diff --git a/src/static/site3.css b/src/static/site3.css
index 7abb5351..449e6fad 100644
--- a/src/static/site3.css
+++ b/src/static/site3.css
@@ -208,7 +208,19 @@ body::before {
   box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
 }
 
-#skippers > .skipper:not(:last-child)::after {
+#skippers > * {
+  display: inline-block;
+}
+
+#skippers > .skipper-list:not(:last-child)::after {
+  display: inline-block;
+  content: "\00a0";
+  margin-left: 2px;
+  margin-right: -2px;
+  border-left: 1px dotted;
+}
+
+#skippers .skipper-list > .skipper:not(:last-child)::after {
   content: " \00b7 ";
   font-weight: 800;
 }
@@ -342,14 +354,13 @@ body::before {
 .sidebar > details summary {
   margin-top: 0.5em;
   padding-left: 5px;
-  user-select: none;
 }
 
 .sidebar > details summary .group-name {
   color: var(--primary-color);
 }
 
-.sidebar > details summary:hover {
+.sidebar > details summary > span:hover {
   cursor: pointer;
   text-decoration: underline;
   text-decoration-color: var(--primary-color);
@@ -1138,8 +1149,49 @@ html[data-url-key="localized.home"] .carousel-container {
   margin-bottom: 0;
 }
 
+/* Custom hash links */
+
+.content-heading {
+  border-bottom: 3px double transparent;
+  margin-bottom: -3px;
+}
+
+.content-heading.highlight-hash-link {
+  animation: highlight-hash-link 4s;
+  animation-delay: 125ms;
+}
+
+/* This animation's name is referenced in JavaScript */
+@keyframes highlight-hash-link {
+  0% {
+    border-bottom-color: transparent;
+  }
+
+  10% {
+    border-bottom-color: white;
+  }
+
+  25% {
+    border-bottom-color: white;
+  }
+
+  100% {
+    border-bottom-color: transparent;
+  }
+}
+
 /* Sticky heading */
 
+#content [id] {
+  /* Adjust scroll margin. */
+  scroll-margin-top: calc(
+      74px /* Sticky heading */
+    + 33px /* Sticky subheading */
+    - 1em  /* One line of text (align bottom) */
+    - 12px /* Padding for hanging letters & focus ring */
+  );
+}
+
 .content-sticky-heading-container {
   position: sticky;
   top: 0;
diff --git a/src/strings-default.json b/src/strings-default.json
index 37e5fbad..bfe358e4 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -120,11 +120,17 @@
   "releaseInfo.artTags.inline": "Tags: {TAGS}",
   "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
   "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
-  "releaseInfo.additionalFiles.heading": "Has {ADDITIONAL_FILES}:",
+  "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:",
   "releaseInfo.additionalFiles.entry": "{TITLE}",
   "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
   "releaseInfo.additionalFiles.file": "{FILE}",
   "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})",
+  "releaseInfo.sheetMusicFiles.shortcut": "Download {LINK}.",
+  "releaseInfo.sheetMusicFiles.shortcut.link": "sheet music files",
+  "releaseInfo.sheetMusicFiles.heading": "Print or download sheet music files:",
+  "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.",
+  "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files",
+  "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:",
   "releaseInfo.note": "Note:",
   "trackList.section.withDuration": "{SECTION} ({DURATION}):",
   "trackList.group": "{GROUP}:",
@@ -177,11 +183,24 @@
   "misc.nav.gallery": "Gallery",
   "misc.pageTitle": "{TITLE}",
   "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
-  "misc.skippers.skipToContent": "Skip to content",
-  "misc.skippers.skipToSidebar": "Skip to sidebar",
-  "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)",
-  "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)",
-  "misc.skippers.skipToFooter": "Skip to footer",
+  "misc.skippers.skipTo": "Skip to:",
+  "misc.skippers.content": "Content",
+  "misc.skippers.sidebar": "Sidebar",
+  "misc.skippers.sidebar.left": "Sidebar (left)",
+  "misc.skippers.sidebar.right": "Sidebar (right)",
+  "misc.skippers.header": "Header",
+  "misc.skippers.footer": "Footer",
+  "misc.skippers.contributors": "Contributors",
+  "misc.skippers.references": "References...",
+  "misc.skippers.referencedBy": "Referenced by...",
+  "misc.skippers.samples": "Samples...",
+  "misc.skippers.sampledBy": "Sampled by...",
+  "misc.skippers.featuredIn": "Featured in...",
+  "misc.skippers.lyrics": "Lyrics",
+  "misc.skippers.sheetMusicFiles": "Sheet music files",
+  "misc.skippers.midiProjectFiles": "MIDI/project files",
+  "misc.skippers.additionalFiles": "Additional files",
+  "misc.skippers.artistCommentary": "Commentary",
   "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}",
   "misc.jumpTo": "Jump to:",
   "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
@@ -373,6 +392,14 @@
   "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
   "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})",
   "listingPage.listTracks.withLyrics.track": "{TRACK}",
+  "listingPage.listTracks.withSheetMusicFiles.title": "Tracks - with Sheet Music Files",
+  "listingPage.listTracks.withSheetMusicFiles.title.short": "...with Sheet Music Files",
+  "listingPage.listTracks.withSheetMusicFiles.album": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.withSheetMusicFiles.track": "{TRACK}",
+  "listingPage.listTracks.withMidiProjectFiles.title": "Tracks - with MIDI & Project Files",
+  "listingPage.listTracks.withMidiProjectFiles.title.short": "...with MIDI & Project Files",
+  "listingPage.listTracks.withMidiProjectFiles.album": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.withMidiProjectFiles.track": "{TRACK}",
   "listingPage.listTags.byName.title": "Tags - by Name",
   "listingPage.listTags.byName.title.short": "...by Name",
   "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
diff --git a/src/upd8.js b/src/upd8.js
index 39372833..fd565228 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -627,7 +627,11 @@ async function main() {
     ...wikiData.albumData.flatMap((album) =>
       [
         ...(album.additionalFiles ?? []),
-        ...album.tracks.flatMap((track) => track.additionalFiles ?? []),
+        ...album.tracks.flatMap((track) => [
+          ...(track.additionalFiles ?? []),
+          ...(track.sheetMusicFiles ?? []),
+          ...(track.midiProjectFiles ?? []),
+        ]),
       ]
         .flatMap((fileGroup) => fileGroup.files)
         .map((file) => ({
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 127afe2c..427111b4 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -19,6 +19,7 @@ import {
   generateAdditionalFilesList,
   generateAdditionalFilesShortcut,
   generateChronologyLinks,
+  generateContentHeading,
   generateCoverLink,
   generateInfoGalleryLinks,
   generateTrackListDividedByGroups,
@@ -192,6 +193,11 @@ export function bindUtilities({
     language,
   });
 
+  bound.generateContentHeading = bindOpts(generateContentHeading, {
+    [bindOpts.bindIndex]: 0,
+    html,
+  });
+
   bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
     [bindOpts.bindIndex]: 0,
     getRevealStringFromArtTags: bound.getRevealStringFromArtTags,
diff --git a/src/write/page-template.js b/src/write/page-template.js
index f47d3f0d..bd52c456 100644
--- a/src/write/page-template.js
+++ b/src/write/page-template.js
@@ -401,6 +401,87 @@ export function generateDocumentHTML(pageInfo, {
     footerHTML,
   ].filter(Boolean).join('\n');
 
+  const processSkippers = skipperList =>
+    skipperList
+      .filter(Boolean)
+      .map(([href, stringSubkey]) =>
+        html.tag('span', {class: 'skipper'},
+          html.tag('a',
+            {href},
+            language.$(`misc.skippers.${stringSubkey}`))));
+
+  // Hilariously jank. Sorry!
+  const hasID = id => mainHTML.includes(`id="${id}"`);
+  const hasContributors = hasID('contributors');
+  const hasReferences = hasID('references');
+  const hasReferencedBy = hasID('referenced-by');
+  const hasSamples = hasID('samples');
+  const hasSampledBy = hasID('sampled-by');
+  const hasFeaturedIn = hasID('featured-in');
+  const hasLyrics = hasID('lyrics');
+  const hasSheetMusicFiles = hasID('sheet-music-files');
+  const hasMidiProjectFiles = hasID('midi-project-files');
+  const hasAdditionalFiles = hasID('additional-files');
+  const hasArtistCommentary = hasID('artist-commentary');
+
+  const skippersHTML =
+    mainHTML &&
+      html.tag('div', {id: 'skippers'}, [
+        html.tag('span', language.$('misc.skippers.skipTo')),
+        html.tag('div', {class: 'skipper-list'},
+          processSkippers([
+            ['#content', 'content'],
+            sidebarLeftHTML &&
+              [
+                '#sidebar-left',
+                sidebarRightHTML
+                  ? 'sidebar.left'
+                  : 'sidebar',
+              ],
+            sidebarRightHTML &&
+              [
+                '#sidebar-right',
+                sidebarLeftHTML
+                  ? 'sidebar.right'
+                  : 'sidebar',
+              ],
+            navHTML &&
+              ['#header', 'header'],
+            footerHTML &&
+              ['#footer', 'footer'],
+          ])),
+
+        html.tag('div',
+          {
+            [html.onlyIfContent]: true,
+            class: 'skipper-list'
+          },
+          processSkippers([
+            hasContributors &&
+              ['#contributors', 'contributors'],
+            hasReferences &&
+              ['#references', 'references'],
+            hasReferencedBy &&
+              ['#referenced-by', 'referencedBy'],
+            hasSamples &&
+              ['#samples', 'samples'],
+            hasSampledBy &&
+              ['#sampled-by', 'sampledBy'],
+            hasFeaturedIn &&
+              ['#featured-in', 'featuredIn'],
+            hasLyrics &&
+              ['#lyrics', 'lyrics'],
+            hasSheetMusicFiles &&
+              ['#sheet-music-files', 'sheetMusicFiles'],
+            hasMidiProjectFiles &&
+              ['#midi-project-files', 'midiProjectFiles'],
+            hasAdditionalFiles &&
+              ['#additional-files', 'additionalFiles'],
+            hasArtistCommentary &&
+              ['#artist-commentary', 'artistCommentary'],
+          ])),
+      ]);
+
   const infoCardHTML = html.tag('div', {id: 'info-card-container'},
     html.tag('div', {id: 'info-card-decor'},
       html.tag('div', {id: 'info-card'}, [
@@ -566,30 +647,7 @@ export function generateDocumentHTML(pageInfo, {
         [
           html.tag('div', {id: 'page-container'}, [
             mainHTML &&
-              html.tag('div', {id: 'skippers'},
-                [
-                  ['#content', language.$('misc.skippers.skipToContent')],
-                  sidebarLeftHTML &&
-                    [
-                      '#sidebar-left',
-                      sidebarRightHTML
-                        ? language.$('misc.skippers.skipToSidebar.left')
-                        : language.$('misc.skippers.skipToSidebar'),
-                    ],
-                  sidebarRightHTML &&
-                    [
-                      '#sidebar-right',
-                      sidebarLeftHTML
-                        ? language.$('misc.skippers.skipToSidebar.right')
-                        : language.$('misc.skippers.skipToSidebar'),
-                    ],
-                  footerHTML &&
-                    ['#footer', language.$('misc.skippers.skipToFooter')],
-                ]
-                  .filter(Boolean)
-                  .map(([href, title]) =>
-                    html.tag('span', {class: 'skipper'},
-                      html.tag('a', {href}, title)))),
+            skippersHTML,
             layoutHTML,
           ]),