« 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--package-lock.json136
-rw-r--r--package.json2
-rw-r--r--src/cli.js21
-rw-r--r--src/content/dependencies/generateDividedFeaturedInFlashesList.js92
-rw-r--r--src/content/dependencies/generateDividedTrackList.js4
-rw-r--r--src/data/composite/wiki-properties/urls.js4
-rw-r--r--src/data/things/MusicVideo.js6
-rw-r--r--src/reformat-urls.js218
-rw-r--r--src/strings-default.yaml1
-rwxr-xr-xsrc/upd8.js322
-rw-r--r--src/validators.js104
-rw-r--r--src/write/build-modes/index.js1
-rw-r--r--src/write/tidy-modes/format-urls.js34
-rw-r--r--src/write/tidy-modes/index.js2
-rw-r--r--src/write/tidy-modes/sort.js (renamed from src/write/build-modes/sort.js)69
15 files changed, 799 insertions, 217 deletions
diff --git a/package-lock.json b/package-lock.json
index 65e12bd7..14996d6c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
                 "js-yaml": "4.1.1",
                 "marked": "17.0.5",
                 "msgpackr": "1.11.9",
+                "replace-in-file": "8.4.0",
                 "rimraf": "6.1.3",
                 "striptags": "4.0.0-alpha.4",
                 "word-count": "0.3.1",
@@ -1585,7 +1586,6 @@
             "version": "6.2.2",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
             "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
-            "dev": true,
             "license": "MIT",
             "engines": {
                 "node": ">=12"
@@ -1598,7 +1598,6 @@
             "version": "6.2.3",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
             "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
-            "dev": true,
             "license": "MIT",
             "engines": {
                 "node": ">=12"
@@ -1747,7 +1746,6 @@
             "version": "5.6.2",
             "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
             "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
-            "dev": true,
             "license": "MIT",
             "engines": {
                 "node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -2073,7 +2071,6 @@
             "version": "10.6.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
             "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
-            "dev": true,
             "license": "MIT"
         },
         "node_modules/env-paths": {
@@ -2114,7 +2111,6 @@
             "version": "3.2.0",
             "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
             "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
-            "dev": true,
             "license": "MIT",
             "engines": {
                 "node": ">=6"
@@ -2440,7 +2436,6 @@
             "version": "2.0.5",
             "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
             "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-            "dev": true,
             "license": "ISC",
             "engines": {
                 "node": "6.* || 8.* || >= 10.*"
@@ -2450,7 +2445,6 @@
             "version": "1.5.0",
             "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
             "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
-            "dev": true,
             "license": "MIT",
             "engines": {
                 "node": ">=18"
@@ -3788,6 +3782,63 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/replace-in-file": {
+            "version": "8.4.0",
+            "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-8.4.0.tgz",
+            "integrity": "sha512-D28k8jy2LtUGbCzCnR3znajaTWIjJ/Uee3UdodzcHRxE7zn6NmYW/dcSqyivnsYU3W+MxdX6SbF28NvJ0GRoLA==",
+            "license": "MIT",
+            "dependencies": {
+                "chalk": "^5.6.2",
+                "glob": "^13.0.0",
+                "yargs": "^18.0.0"
+            },
+            "bin": {
+                "replace-in-file": "bin/cli.js"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/replace-in-file/node_modules/cliui": {
+            "version": "9.0.1",
+            "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+            "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+            "license": "ISC",
+            "dependencies": {
+                "string-width": "^7.2.0",
+                "strip-ansi": "^7.1.0",
+                "wrap-ansi": "^9.0.0"
+            },
+            "engines": {
+                "node": ">=20"
+            }
+        },
+        "node_modules/replace-in-file/node_modules/yargs": {
+            "version": "18.0.0",
+            "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+            "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+            "license": "MIT",
+            "dependencies": {
+                "cliui": "^9.0.1",
+                "escalade": "^3.1.1",
+                "get-caller-file": "^2.0.5",
+                "string-width": "^7.2.0",
+                "y18n": "^5.0.5",
+                "yargs-parser": "^22.0.0"
+            },
+            "engines": {
+                "node": "^20.19.0 || ^22.12.0 || >=23"
+            }
+        },
+        "node_modules/replace-in-file/node_modules/yargs-parser": {
+            "version": "22.0.0",
+            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+            "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+            "license": "ISC",
+            "engines": {
+                "node": "^20.19.0 || ^22.12.0 || >=23"
+            }
+        },
         "node_modules/require-directory": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4094,7 +4145,6 @@
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
             "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
-            "dev": true,
             "license": "MIT",
             "dependencies": {
                 "emoji-regex": "^10.3.0",
@@ -4168,7 +4218,6 @@
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
             "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
-            "dev": true,
             "license": "MIT",
             "dependencies": {
                 "ansi-regex": "^6.2.2"
@@ -4797,7 +4846,6 @@
             "version": "9.0.2",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
             "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
-            "dev": true,
             "license": "MIT",
             "dependencies": {
                 "ansi-styles": "^6.2.1",
@@ -4927,7 +4975,6 @@
             "version": "5.0.8",
             "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
             "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
-            "dev": true,
             "license": "ISC",
             "engines": {
                 "node": ">=10"
@@ -6046,14 +6093,12 @@
         "ansi-regex": {
             "version": "6.2.2",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
-            "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
-            "dev": true
+            "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="
         },
         "ansi-styles": {
             "version": "6.2.3",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
-            "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
-            "dev": true
+            "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="
         },
         "arg": {
             "version": "4.1.3",
@@ -6151,8 +6196,7 @@
         "chalk": {
             "version": "5.6.2",
             "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
-            "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
-            "dev": true
+            "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
         },
         "chokidar": {
             "version": "5.0.0",
@@ -6365,8 +6409,7 @@
         "emoji-regex": {
             "version": "10.6.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
-            "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
-            "dev": true
+            "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="
         },
         "env-paths": {
             "version": "2.2.1",
@@ -6389,8 +6432,7 @@
         "escalade": {
             "version": "3.2.0",
             "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
-            "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
-            "dev": true
+            "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
         },
         "escape-string-regexp": {
             "version": "4.0.0",
@@ -6589,14 +6631,12 @@
         "get-caller-file": {
             "version": "2.0.5",
             "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
-            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-            "dev": true
+            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
         },
         "get-east-asian-width": {
             "version": "1.5.0",
             "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
-            "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
-            "dev": true
+            "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="
         },
         "glob": {
             "version": "13.0.6",
@@ -7481,6 +7521,46 @@
             "integrity": "sha512-Zb9DJ5u6GhgqRSBnxV2QSnLqEwcKxHWFA1N2yUa4ZUAO1P8jlWKYtWZ6/ooV6yylspGXJX0O/uNzEv0xrCtwaA==",
             "dev": true
         },
+        "replace-in-file": {
+            "version": "8.4.0",
+            "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-8.4.0.tgz",
+            "integrity": "sha512-D28k8jy2LtUGbCzCnR3znajaTWIjJ/Uee3UdodzcHRxE7zn6NmYW/dcSqyivnsYU3W+MxdX6SbF28NvJ0GRoLA==",
+            "requires": {
+                "chalk": "^5.6.2",
+                "glob": "^13.0.0",
+                "yargs": "^18.0.0"
+            },
+            "dependencies": {
+                "cliui": {
+                    "version": "9.0.1",
+                    "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+                    "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+                    "requires": {
+                        "string-width": "^7.2.0",
+                        "strip-ansi": "^7.1.0",
+                        "wrap-ansi": "^9.0.0"
+                    }
+                },
+                "yargs": {
+                    "version": "18.0.0",
+                    "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+                    "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+                    "requires": {
+                        "cliui": "^9.0.1",
+                        "escalade": "^3.1.1",
+                        "get-caller-file": "^2.0.5",
+                        "string-width": "^7.2.0",
+                        "y18n": "^5.0.5",
+                        "yargs-parser": "^22.0.0"
+                    }
+                },
+                "yargs-parser": {
+                    "version": "22.0.0",
+                    "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+                    "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="
+                }
+            }
+        },
         "require-directory": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -7688,7 +7768,6 @@
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
             "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
-            "dev": true,
             "requires": {
                 "emoji-regex": "^10.3.0",
                 "get-east-asian-width": "^1.0.0",
@@ -7739,7 +7818,6 @@
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
             "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
-            "dev": true,
             "requires": {
                 "ansi-regex": "^6.2.2"
             }
@@ -8166,7 +8244,6 @@
             "version": "9.0.2",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
             "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
-            "dev": true,
             "requires": {
                 "ansi-styles": "^6.2.1",
                 "string-width": "^7.0.0",
@@ -8243,8 +8320,7 @@
         "y18n": {
             "version": "5.0.8",
             "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
-            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
-            "dev": true
+            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
         },
         "yallist": {
             "version": "5.0.0",
diff --git a/package.json b/package.json
index 7aef341a..79fe4b53 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
         "#page-specs": "./src/page/index.js",
         "#quickstat": "./src/quickstat.js",
         "#node-utils": "./src/node-utils.js",
+        "#reformat-urls": "./src/reformat-urls.js",
         "#repl": "./src/write/build-modes/repl.js",
         "#replacer": "./src/replacer.js",
         "#reverse": "./src/reverse.js",
@@ -72,6 +73,7 @@
         "js-yaml": "4.1.1",
         "marked": "17.0.5",
         "msgpackr": "1.11.9",
+        "replace-in-file": "8.4.0",
         "rimraf": "6.1.3",
         "striptags": "4.0.0-alpha.4",
         "word-count": "0.3.1",
diff --git a/src/cli.js b/src/cli.js
index ec72a625..52ac9f9c 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -231,18 +231,29 @@ export function showHelpForOptions({
   options,
   indentWrap,
   sort = entries => entries,
+  silentIfNoOptions = false,
 }) {
-  if (heading) {
-    console.log(colors.bright(heading));
-  }
-
   const sortedOptions =
     sort(
       Object.entries(options)
         .map(([name, descriptor]) => ({name, descriptor})));
 
+  if (!sortedOptions.length && silentIfNoOptions) return;
+
+  if (heading) {
+    console.log(colors.bright(heading));
+  }
+
   if (!sortedOptions.length) {
-    console.log(`(No options available)`)
+    if (heading) {
+      console.log(``);
+      console.log(`  (No options available)`);
+      console.log(``);
+    } else {
+      console.log(`(No options available)`);
+    }
+
+    return;
   }
 
   let justInsertedPaddingLine = false;
diff --git a/src/content/dependencies/generateDividedFeaturedInFlashesList.js b/src/content/dependencies/generateDividedFeaturedInFlashesList.js
index 93e29991..c4f5e445 100644
--- a/src/content/dependencies/generateDividedFeaturedInFlashesList.js
+++ b/src/content/dependencies/generateDividedFeaturedInFlashesList.js
@@ -14,13 +14,26 @@ export default {
 
   query(sprawl, features, _contextTrack) {
     if (!sprawl.enableFlashesAndGames) {
-      return {dividingSides: [], dividedFeatures: []};
+      return {
+        dividingSides: [], dividingLabels: [],
+        dividedFeatures: [],
+        undividedFeatures: [],
+      };
+    }
+
+    if (empty(sprawl.divideFlashListsBySides)) {
+      return {
+        dividingSides: [], dividingLabels: [],
+        dividedFeatures: [],
+        undividedFeatures: features,
+      };
     }
 
     const {allSides} = sprawl;
 
     const divisions = new Map();
     const dividingSideIndices = [];
+    const undividedFeatures = [];
     for (const {side, label} of sprawl.divideFlashListsBySides) {
       divisions.set(side, {label, features: []});
       dividingSideIndices.push(allSides.indexOf(side));
@@ -33,11 +46,13 @@ export default {
       const closestDividingSideIndex =
         dividingSideIndices.findLast(i => i <= sideIndex);
 
-      const closestDividingSide =
-        allSides.at(closestDividingSideIndex);
+      if (typeof closestDividingSideIndex === 'number') {
+        const closestDividingSide =
+          allSides.at(closestDividingSideIndex);
 
-      if (closestDividingSide) {
         divisions.get(closestDividingSide).features.push(feature);
+      } else {
+        undividedFeatures.push(feature);
       }
     }
 
@@ -51,12 +66,16 @@ export default {
       dividedFeatures,
       (_side, _label, dividedFeatures) => !empty(dividedFeatures));
 
-    return {dividingSides, dividingLabels, dividedFeatures};
+    return {
+      dividingSides, dividingLabels,
+      dividedFeatures,
+      undividedFeatures,
+    };
   },
 
-  relations: (relation, query, sprawl, features, contextTrack) => ({
+  relations: (relation, query, _sprawl, features, contextTrack) => ({
     flatList:
-      (empty(sprawl.divideFlashListsBySides)
+      (empty(query.dividedFeatures)
         ? relation('generateTrackFeaturedInFlashesList', features, contextTrack)
         : null),
 
@@ -70,24 +89,19 @@ export default {
     dividedFlashLists:
       query.dividedFeatures
         .map(features => relation('generateTrackFeaturedInFlashesList', features, contextTrack)),
+
+    undividedFlashList:
+      (empty(query.undividedFeatures)
+        ? null
+        : relation('generateTrackFeaturedInFlashesList', query.undividedFeatures, contextTrack)),
   }),
 
   data: (query, _sprawl, _tracks) => ({
-    dividingSideNames:
-      query.dividingSides
-        .map(side => side.name),
-
     dividingLabels:
       query.dividingLabels,
   }),
 
-  slots: {
-    headingString: {
-      type: 'string',
-    },
-  },
-
-  generate(data, relations, slots, {html, language}) {
+  generate(data, relations, {html, language}) {
     if (relations.flatList) {
       return relations.flatList;
     }
@@ -107,36 +121,26 @@ export default {
       html.tag('dl', {class: 'division-list'},
         {[html.onlyIfContent]: true},
 
-        language.encapsulate('flashList', listCapsule =>
+        language.encapsulate('flashList', listCapsule => [
           stitchArrays({
             sideLink: relations.dividingSideLinks,
             flashList: relations.dividedFlashLists,
-            sideName: data.dividingSideNames,
-            label: data.dividingLabels,
-          }).map(({sideLink, flashList, sideName, label}) => [
-              language.encapsulate(listCapsule, 'underSide', capsule =>
-                (slots.headingString
-                  ? relations.contentHeading.clone().slots({
-                      tag: 'dt',
-
-                      title:
-                        language.$(capsule, {side: sideLink}),
-
-                      stickyTitle:
-                        language.$(slots.headingString, 'sticky', 'fromGroup', {
-                          side:
-                            (label
-                              ? language.sanitize(label)
-                              : language.sanitize(sideName)),
-                        }),
-                    })
-
-                  : html.tag('dt',
-                      language.$(capsule, {
-                        side: sideLink,
-                      })))),
+          }).map(({sideLink, flashList}) => [
+              html.tag('dt',
+                language.$(listCapsule, 'underSide', {side: sideLink})),
 
               html.tag('dd', flashList),
-            ]))));
+            ]),
+
+          html.tags([
+            html.tag('dt',
+              {[html.onlyIfSiblings]: true},
+              language.$(listCapsule, 'underOther')),
+
+            html.tag('dd',
+              {[html.onlyIfContent]: true},
+              relations.undividedFlashList),
+          ]),
+        ])));
   },
 };
diff --git a/src/content/dependencies/generateDividedTrackList.js b/src/content/dependencies/generateDividedTrackList.js
index e89f08db..870c8156 100644
--- a/src/content/dependencies/generateDividedTrackList.js
+++ b/src/content/dependencies/generateDividedTrackList.js
@@ -85,9 +85,9 @@ export default {
     return {groupingGroups, groupedTracks, ungroupedTracks};
   },
 
-  relations: (relation, query, sprawl, tracks, contextTrack) => ({
+  relations: (relation, query, _sprawl, tracks, contextTrack) => ({
     flatList:
-      (empty(sprawl.divideTrackListsByGroups)
+      (empty(query.groupedTracks)
         ? relation('generateNearbyTrackList', tracks, contextTrack, [])
         : null),
 
diff --git a/src/data/composite/wiki-properties/urls.js b/src/data/composite/wiki-properties/urls.js
index 3160a0bf..04ccf689 100644
--- a/src/data/composite/wiki-properties/urls.js
+++ b/src/data/composite/wiki-properties/urls.js
@@ -1,14 +1,14 @@
 // A list of URLs! This will always be present on the data object, even if set
 // to an empty array or null.
 
-import {isURL, validateArrayItems} from '#validators';
+import {isCuratedURL, validateArrayItems} from '#validators';
 
 // TODO: Not templateCompositeFrom.
 
 export default function() {
   return {
     flags: {update: true, expose: true},
-    update: {validate: validateArrayItems(isURL)},
+    update: {validate: validateArrayItems(isCuratedURL)},
     expose: {transform: value => value ?? []},
   };
 }
diff --git a/src/data/things/MusicVideo.js b/src/data/things/MusicVideo.js
index 7ebbba37..16dffa3b 100644
--- a/src/data/things/MusicVideo.js
+++ b/src/data/things/MusicVideo.js
@@ -4,7 +4,7 @@ import {colors} from '#cli';
 import {input, V} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {is, isDate, isStringNonEmpty, isURL, validateArrayItems}
+import {is, isCuratedURL, isDate, isStringNonEmpty, validateArrayItems}
   from '#validators';
 import {parseContributors, parseDate} from '#yaml';
 
@@ -70,7 +70,7 @@ export class MusicVideo extends Thing {
       flags: {update: true, expose: true},
 
       update: {
-        validate: isURL,
+        validate: isCuratedURL,
       },
 
       expose: {
@@ -86,7 +86,7 @@ export class MusicVideo extends Thing {
       flags: {update: true, expose: true},
 
       update: {
-        validate: validateArrayItems(isURL),
+        validate: validateArrayItems(isCuratedURL),
       },
 
       expose: {
diff --git a/src/reformat-urls.js b/src/reformat-urls.js
new file mode 100644
index 00000000..69b15de5
--- /dev/null
+++ b/src/reformat-urls.js
@@ -0,0 +1,218 @@
+// Find-replace calls analogous to isCuratedURL in #validators.
+// This can't catch everything, but should automate the greater bulk of it.
+
+import * as path from 'node:path';
+
+import {replaceInFile} from 'replace-in-file';
+
+import {colors, logInfo} from '#cli';
+import {escapeRegex, re} from '#sugar';
+
+function or(options) {
+  return options.map(escapeRegex).join('|');
+}
+
+function https(namespace, domain) {
+  return [
+    `${namespace}: http:// to https://`,
+
+    re('gmi', [
+      '^- http://',
+      `(?=(?:` + domain + ')/)',
+    ]),
+
+    '- https://',
+  ];
+}
+
+function trimQueryParameter(namespace, domain, parameter) {
+  return [
+    `${namespace}: trim ?${parameter} query parameter`,
+
+    re('gmi', [
+      '^(',
+        '- https://',
+        '(?:' + domain + ')',
+        '\/.*',
+      ')',
+
+      '[&?]' + parameter + '=',
+      '[^\n&?]+',
+    ]),
+
+    '$1',
+  ];
+}
+
+function trimTrailingSlash(namespace, domain) {
+  return [
+    `${namespace}: trim trailing slash`,
+
+    re('gmi', [
+      '^(',
+        '- https://',
+        '(?:' + domain + ')',
+        '\/.*',
+      ')',
+
+      '/',
+      '(?=[#?]|$)',
+    ]),
+
+    '$1',
+  ];
+}
+
+
+// Rules are evaluated top to bottom, in order,
+// so each rule can build off previous ones.
+const findreplace = [];
+
+// General
+
+findreplace.push([
+  `general: add slash to stand in for empty path`,
+  re('gmi', ['^(- [a-z]*://[^\n?#/]+)(?=[?#]|$)']),
+  '$1/',
+]);
+
+// Apple Music
+
+findreplace.push([
+  `apple music: trim country code`,
+  /^(- https:\/\/music.apple.com\/)[a-z][a-z]\//gmi,
+  '$1',
+]);
+
+// SoundCloud
+
+findreplace.push(trimTrailingSlash('soundcloud', 'soundcloud.com'));
+
+// Spotify
+
+findreplace.push(trimQueryParameter('spotify', 'open\.spotify\.com', 'si'));
+findreplace.push(trimQueryParameter('spotify', 'open\.spotify\.com', 'nd'));
+
+// Tumblr
+
+findreplace.push([
+  `tumblr: tumblr.com -> www.tumblr.com`,
+  /^- https:\/\/tumblr\.com\//gmi,
+  '- https://www.tumblr.com/',
+]);
+
+// Twitter
+
+const twitterDomains =
+  or([
+    'www.twitter.com',
+    'x.com',
+  ]);
+
+findreplace.push(https('twitter', twitterDomains));
+
+findreplace.push([
+  `twitter: www.twitter.com -> twitter.com`,
+  /^- https:\/\/www\.twitter\.com\//gmi,
+  '- https://twitter.com/',
+]);
+
+findreplace.push([
+  `twitter: x.com -> twitter.com`,
+  /^- https:\/\/x\.com\//gmi,
+  '- https://twitter.com/',
+]);
+
+// YouTube
+
+const youtubeDomains =
+  or([
+    'www.youtube.com',
+    'youtube.com',
+    'youtu.be',
+  ]);
+
+findreplace.push(https('youtube', youtubeDomains));
+
+findreplace.push(trimQueryParameter('youtube', youtubeDomains, 'si'));
+
+findreplace.push([
+  `youtube: youtu.be -> www.youtube.com/watch?v=___`,
+  /^- https:\/\/youtu\.be\/([a-z0-9_-]{11,11})$/gmi,
+  '- https://www.youtube.com/watch?v=$1'
+]);
+
+findreplace.push([
+  `youtube: youtu.be -> www.youtube.com/watch?v=___&t=___`,
+  /^- https:\/\/youtu\.be\/([a-z0-9_-]{11,11})\?t=(\d+)$/gmi,
+  '- https://www.youtube.com/watch?v=$1&t=$2',
+]);
+
+findreplace.push([
+  `youtube: youtube.com -> www.youtube.com`,
+  /^- https:\/\/youtube\.com\//gmi,
+  '- https://www.youtube.com/',
+]);
+
+
+export async function reformatCuratedURLs({
+  dataPath,
+  showChangedFiles = true,
+  showSatisfiedRules = true,
+}) {
+  if (!dataPath) {
+    throw new Error(`Expected dataPath`);
+  }
+
+  let changedFiles = new Map();
+  let errored = false;
+  let anyChanged = false;
+
+  try {
+    for (const [message, find, replace] of findreplace) {
+      const options = {
+        files: dataPath + '/**/*.yaml',
+        from: find,
+        to: replace,
+      };
+
+      let anyChangedForThisRule = false;
+      for (const result of await replaceInFile(options)) {
+        if (result.hasChanged) {
+          anyChanged = true;
+          anyChangedForThisRule = true;
+          if (changedFiles.has(result.file)) {
+            changedFiles.get(result.file).push(message);
+          } else {
+            changedFiles.set(result.file, [message]);
+          }
+        }
+      }
+
+      if (showSatisfiedRules && !anyChangedForThisRule) {
+        logInfo`Already satisfied: ${message}`;
+      }
+    }
+
+    return changedFiles;
+  } catch (caughtError) {
+    errored = true;
+    throw caughtError;
+  } finally {
+    const entries = Array.from(changedFiles.entries());
+    entries.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? +1 : 0);
+
+    if (showChangedFiles) {
+      for (const [file, messages] of entries) {
+        logInfo`Updated: ${path.relative(dataPath, file)}`;
+        for (const message of messages) {
+          console.log(colors.dim(` - ${message}`));
+        }
+      }
+    }
+
+    if (!errored) {
+      return new Map(entries);
+    }
+  }
+}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 15bff2e8..daca8347 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -472,6 +472,7 @@ trackList:
 
 flashList:
   underSide: "under {SIDE}:"
+  underOther: "other flashes:"
 
   item:
     _: "{FLASH}"
diff --git a/src/upd8.js b/src/upd8.js
index 2091e5ba..e9353007 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -54,6 +54,7 @@ import {bindReverse} from '#reverse';
 import {writeSearchData} from '#search';
 import {sortByName} from '#sort';
 import thingConstructors from '#things';
+import {disableCuratedURLValidation} from '#validators';
 import {identifyAllWebRoutes} from '#web-routes';
 
 import {
@@ -80,6 +81,7 @@ import {
   empty,
   filterMultipleArrays,
   indentWrap as unboundIndentWrap,
+  stitchArrays,
   withEntries,
 } from '#sugar';
 
@@ -114,6 +116,7 @@ import {
 import FileSizePreloader from './file-size-preloader.js';
 import {listingSpec, listingTargetSpec} from './listing-spec.js';
 import * as buildModes from './write/build-modes/index.js';
+import * as tidyModes from './write/tidy-modes/index.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
@@ -131,6 +134,7 @@ const STATUS_NOT_APPLICABLE    = `not applicable`;
 const STATUS_STARTED_NOT_DONE  = `started but not yet done`;
 const STATUS_DONE_CLEAN        = `done without warnings`;
 const STATUS_FATAL_ERROR       = `fatal error`;
+const STATUS_INVALID_SIGNAL    = `invalid exit signal`;
 const STATUS_HAS_WARNINGS      = `has warnings`;
 
 const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null};
@@ -199,10 +203,6 @@ async function main() {
       {...defaultStepStatus, name: `precache nearly all data`,
         for: ['build']},
 
-    sortWikiDataSourceFiles:
-      {...defaultStepStatus, name: `apply sorting rules to wiki data files`,
-        for: ['build']},
-
     checkWikiDataSourceFileSorting:
       {...defaultStepStatus, name: `check sorting rules against wiki data files`},
 
@@ -251,6 +251,14 @@ async function main() {
       {...defaultStepStatus, name: `identify web routes`,
         for: ['build']},
 
+    reformatCuratedURLs:
+      {...defaultStepStatus, name: `reformat curated URLs`,
+        for: ['build']},
+
+    sortWikiDataSourceFiles:
+      {...defaultStepStatus, name: `apply sorting rules to wiki data files`,
+        for: ['build']},
+
     performBuild:
       {...defaultStepStatus, name: `perform selected build mode`,
         for: ['build']},
@@ -272,32 +280,50 @@ async function main() {
 
   const defaultQueueSize = 500;
 
-  const buildModeFlagOptions = (
+  const buildModeFlagOptions =
     withEntries(buildModes, entries =>
       entries.map(([key, mode]) => [key, {
         help: mode.description,
         type: 'flag',
-      }])));
+      }]));
 
-  const selectedBuildModeFlags = Object.keys(
-    await parseOptions(process.argv.slice(2), {
-      [parseOptions.handleUnknown]: () => {},
-      ...buildModeFlagOptions,
-    }));
+  const selectedBuildModeFlags =
+    Object.keys(
+      await parseOptions(process.argv.slice(2), {
+        [parseOptions.handleUnknown]: () => {},
+        ...buildModeFlagOptions,
+      }));
 
-  let selectedBuildModeFlag;
-  let sortInAdditionToBuild = false;
+  const tidyModeFlagOptions =
+    withEntries(tidyModes, entries =>
+      entries.map(([key, mode]) => [key, {
+        help: mode.description,
+        type: 'flag',
+      }]));
+
+  const selectedTidyModeFlags =
+    Object.keys(
+      await parseOptions(process.argv.slice(2), {
+        [parseOptions.handleUnknown]: () => {},
+        ...tidyModeFlagOptions,
+      }));
 
-  // As an exception, --sort can be combined with another build mode.
-  if (selectedBuildModeFlags.length >= 2 && selectedBuildModeFlags.includes('sort')) {
-    sortInAdditionToBuild = true;
-    selectedBuildModeFlags.splice(selectedBuildModeFlags.indexOf('sort'), 1);
+  if (selectedTidyModeFlags.includes('format-urls')) {
+    Object.assign(stepStatusSummary.reformatCuratedURLs, {
+      status: STATUS_NOT_STARTED,
+      annotation: `--format-urls provided`,
+    });
+  } else {
+    Object.assign(stepStatusSummary.reformatCuratedURLs, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--format-urls not provided`,
+    });
   }
 
-  if (sortInAdditionToBuild) {
+  if (selectedTidyModeFlags.includes('sort')) {
     Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
       status: STATUS_NOT_STARTED,
-      annotation: `--sort provided with another build mode`,
+      annotation: `--sort provided`,
     });
 
     Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
@@ -316,17 +342,15 @@ async function main() {
     });
   }
 
-  if (empty(selectedBuildModeFlags)) {
-    // No build mode selected. This is not a valid state for building the wiki,
-    // but we want to let access to --help, so we'll show a message about what
-    // to do later.
-    selectedBuildModeFlag = null;
-  } else if (selectedBuildModeFlags.length > 1) {
-    logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
-    logError`Please specify one build mode.`;
-    return false;
-  } else {
-    selectedBuildModeFlag = selectedBuildModeFlags[0];
+  let selectedBuildModeFlag;
+  switch (selectedBuildModeFlags.length) {
+    case 0: selectedBuildModeFlag = null; break;
+    case 1: selectedBuildModeFlag = selectedBuildModeFlags[0]; break;
+    default: {
+      logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
+      logError`Please specify one build mode.`;
+      return false;
+    }
   }
 
   const selectedBuildMode =
@@ -334,16 +358,25 @@ async function main() {
       ? buildModes[selectedBuildModeFlag]
       : null);
 
-  // This is about to get a whole lot more stuff put in it.
-  const wikiData = {
-    listingSpec,
-    listingTargetSpec,
-  };
+  const selectedTidyModes =
+    selectedTidyModeFlags
+      .map(flag => tidyModes[flag]);
 
-  const buildOptions =
-    (selectedBuildMode
-      ? selectedBuildMode.getCLIOptions()
-      : {});
+  const tidyingOnly =
+    !selectedBuildMode &&
+    !empty(selectedTidyModes);
+
+  const selectedBuildModeOptions =
+    selectedBuildMode?.getCLIOptions?.() ??
+    {};
+
+  const selectedTidyModeOptions =
+    selectedTidyModes.map(tidyMode =>
+      tidyMode.getCLIOptions?.() ??
+      {});
+
+  const selectedTidyModeOptionsFlat =
+    Object.fromEntries(selectedTidyModeOptions.flat());
 
   const commonOptions = {
     'help': {
@@ -452,6 +485,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-curated-url-validation': {
+      help: `Skips checking if URLs match a set of standardizing rules; only intended for use with old data`,
+      type: 'flag',
+    },
+
     'skip-file-sizes': {
       help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
       type: 'flag',
@@ -580,9 +618,11 @@ async function main() {
     // here, even though we won't be doing anything with them later.
     // (This is a bit of a hack.)
     ...buildModeFlagOptions,
+    ...tidyModeFlagOptions,
 
     ...commonOptions,
-    ...buildOptions,
+    ...selectedTidyModeOptionsFlat,
+    ...selectedBuildModeOptions,
   });
 
   shouldShowStepStatusSummary = cliOptions['show-step-summary'] ?? false;
@@ -599,7 +639,7 @@ async function main() {
       `and website content/structure ` +
       `from provided data, media, and language directories.\n` +
       `\n` +
-      `CLI options are divided into three groups:\n`));
+      `CLI options are divided into five groups:\n`));
 
     console.log(` 1) ` + indentWrap(
       `Common options: ` +
@@ -608,37 +648,63 @@ async function main() {
       {spaces: 4, bullet: true}));
 
     console.log(` 2) ` + indentWrap(
+      `Tidying mode selection: ` +
+      `One or more tidying mode may be selected, ` +
+      `and they adjust the contents of data files ` +
+      `to satisfy predefined or data-configured standardization rules`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 3) ` + indentWrap(
+      `Tidying mode options: ` +
+      `Each tidy mode may `))
+
+    console.log(` 4) ` + indentWrap(
       `Build mode selection: ` +
       `One build mode should be selected, ` +
       `and it decides the main set of behavior to use ` +
       `for presenting or interacting with site content`,
       {spaces: 4, bullet: true}));
 
-    console.log(` 3) ` + indentWrap(
-      `Build options: ` +
+    console.log(` 5) ` + indentWrap(
+      `Build mode options: ` +
       `Each build mode has a set of unique options ` +
       `which customize behavior for that build mode`,
       {spaces: 4, bullet: true}));
 
+    console.log(`All options may be specified in any order.`);
+
     console.log(``);
 
     showHelpForOptions({
       heading: `Common options`,
       options: commonOptions,
-      wrap,
     });
 
     showHelpForOptions({
+      heading: `Tidying mode selection`,
+      options: tidyModeFlagOptions,
+    });
+
+    stitchArrays({
+      flag: selectedTidyModeFlags,
+      options: selectedTidyModeOptions,
+    }).forEach(({flag, options}) => {
+        showHelpForOptions({
+          heading: `Options for tidying mode --${flag}`,
+          options,
+          silentIfNoOptions: false,
+        });
+      });
+
+    showHelpForOptions({
       heading: `Build mode selection`,
       options: buildModeFlagOptions,
-      wrap,
     });
 
     if (selectedBuildMode) {
       showHelpForOptions({
-        heading: `Build options for --${selectedBuildModeFlag}`,
-        options: buildOptions,
-        wrap,
+        heading: `Options for build mode --${selectedBuildModeFlag}`,
+        options: selectedBuildModeOptions,
       });
     } else {
       console.log(
@@ -704,6 +770,34 @@ async function main() {
     });
   }
 
+  if (tidyingOnly) {
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `tidying modes provided`,
+    });
+
+    for (const key of [
+      'preloadFileSizes',
+      'watchLanguageFiles',
+      'verifyImagePaths',
+      'buildSearchIndex',
+      'generateThumbnails',
+      'identifyWebRoutes',
+      'checkWikiDataSourceFileSorting',
+    ]) {
+      Object.assign(stepStatusSummary[key], {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `tidying modes provided without build mode`,
+      });
+    }
+  }
+
+  if (cliOptions['skip-curated-url-validation']) {
+    logWarn`Won't check if any URLs match the curated URL rules this run`;
+    logWarn `(--skip-curated-url-validation passed).`;
+    disableCuratedURLValidation();
+  }
+
   // Finish setting up defaults by combining information from all options.
 
   const _fallbackStep = (stepKey, {
@@ -960,6 +1054,10 @@ async function main() {
         break decideBuildSearchIndex;
       }
 
+      if (tidyingOnly) {
+        break decideBuildSearchIndex;
+      }
+
       const indexFile = path.join(wikiCachePath, 'search', 'index.json')
       let stats;
       try {
@@ -1478,6 +1576,12 @@ async function main() {
     timeStart: Date.now(),
   });
 
+  // This is about to get a whole lot more stuff put in it.
+  const wikiData = {
+    listingSpec,
+    listingTargetSpec,
+  };
+
   let yamlDataSteps;
   let yamlDocumentProcessingAggregate;
 
@@ -1993,40 +2097,7 @@ async function main() {
     });
   }
 
-  if (stepStatusSummary.sortWikiDataSourceFiles.status === STATUS_NOT_STARTED) {
-    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
-      status: STATUS_STARTED_NOT_DONE,
-      timeStart: Date.now(),
-    });
-
-    const {SortingRule} = thingConstructors;
-    const results =
-      await Array.fromAsync(SortingRule.go({dataPath, wikiData}));
-
-    if (results.some(result => result.changed)) {
-      logInfo`Updated data files to satisfy sorting.`;
-      logInfo`Restarting automatically, since that's now needed!`;
-
-      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
-        status: STATUS_DONE_CLEAN,
-        annotation: `changes cueing restart`,
-        timeEnd: Date.now(),
-        memory: process.memoryUsage(),
-      });
-
-      return 'restart';
-    } else {
-      logInfo`All sorting rules are satisfied. Nice!`;
-      paragraph = false;
-
-      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
-        status: STATUS_DONE_CLEAN,
-        annotation: `no changes needed`,
-        timeEnd: Date.now(),
-        memory: process.memoryUsage(),
-      });
-    }
-  } else if (stepStatusSummary.checkWikiDataSourceFileSorting.status === STATUS_NOT_STARTED) {
+  if (stepStatusSummary.checkWikiDataSourceFileSorting.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
       status: STATUS_STARTED_NOT_DONE,
       timeStart: Date.now(),
@@ -3197,10 +3268,79 @@ async function main() {
 
   quickstat.reset();
 
+  let restartBeforeBuild = false;
+  const updatedTidyModes = [];
+
+  for (const [step, tidyMode] of [
+    ['reformatCuratedURLs', 'format-urls'],
+    ['sortWikiDataSourceFiles', 'sort'],
+  ]) {
+    if (stepStatusSummary[step].status !== STATUS_NOT_STARTED) {
+      continue;
+    }
+
+    Object.assign(stepStatusSummary[step], {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const tidySignal =
+      await tidyModes[tidyMode].go({
+        wikiData,
+        dataPath,
+        tidyingOnly,
+      });
+
+    switch (tidySignal) {
+      case 'clean': {
+        Object.assign(stepStatusSummary[step], {
+          status: STATUS_DONE_CLEAN,
+          annotation: `no changes needed`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        break;
+      }
+
+      case 'updated': {
+        Object.assign(stepStatusSummary[step], {
+          status: STATUS_DONE_CLEAN,
+          annotation: `changes cueing restart`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        restartBeforeBuild = true;
+        updatedTidyModes.push(tidyMode);
+
+        break;
+      }
+
+      default: {
+        Object.assign(stepStatusSummary[step], {
+          status: STATUS_INVALID_SIGNAL,
+          annotation: `unknown: ${tidySignal}`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        logError`Invalid exit signal for ${'--' + tidyMode}: ${tidySignal}`;
+        fileIssue();
+
+        return false;
+      }
+    }
+  }
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
 
+  if (restartBeforeBuild) {
+    return 'restart';
+  }
+
   const developersComment =
     `<!--\n` + [
       wikiData.wikiInfo.canonicalBase
@@ -3347,15 +3487,15 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
           console.log('');
         }
 
-        if (numRestarts > 5) {
+        if (numRestarts > 2) {
           logError`A restart was cued, but we've restarted a bunch already.`;
           logError`Exiting because this is probably a bug!`;
           console.log('');
           break;
         } else {
           console.log('');
-          logInfo`A restart was cued. This is probably normal, and required`;
-          logInfo`to load updated data files. Restarting automatically now!`;
+          console.log(`A restart was cued. This is probably normal, and required`);
+          console.log(`to load updated data files. Restarting automatically now!`);
           console.log('');
           numRestarts++;
         }
@@ -3416,6 +3556,8 @@ function showStepStatusSummary() {
       .map(({status}) =>
         status === STATUS_HAS_WARNINGS ||
         status === STATUS_FATAL_ERROR ||
+        status === STATUS_INVALID_SIGNAL ||
+        status === STATUS_NOT_STARTED ||
         status === STATUS_STARTED_NOT_DONE);
 
   const anyStepsNotClean =
@@ -3470,7 +3612,12 @@ function showStepStatusSummary() {
 
     message += ` `;
     message += `${name}: `.padEnd(longestNameLength + 4, '.');
-    message += ` `;
+    if (stepsNotClean[index]) {
+      message += `! `;
+    } else {
+      message += `  `;
+    }
+
     message += status;
 
     if (annotation) {
@@ -3482,17 +3629,18 @@ function showStepStatusSummary() {
         console.error(colors.green(message));
         break;
 
-      case STATUS_NOT_STARTED:
       case STATUS_NOT_APPLICABLE:
         console.error(colors.dim(message));
         break;
 
       case STATUS_HAS_WARNINGS:
+      case STATUS_NOT_STARTED:
       case STATUS_STARTED_NOT_DONE:
         console.error(colors.yellow(message));
         break;
 
       case STATUS_FATAL_ERROR:
+      case STATUS_INVALID_SIGNAL:
         console.error(colors.red(message));
         break;
 
diff --git a/src/validators.js b/src/validators.js
index 1c9ce9e3..7dcd7b8c 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -22,6 +22,14 @@ export function setValidatorCreatorMeta(validator, creator, meta) {
   return validator;
 }
 
+// External configuration
+
+let disabledCuratedURLValidation = false;
+
+export function disableCuratedURLValidation() {
+  disabledCuratedURLValidation = true;
+}
+
 // Basic types (primitives)
 
 export function a(noun) {
@@ -710,7 +718,101 @@ export function isName(name) {
 export function isURL(string) {
   isStringNonEmpty(string);
 
-  new URL(string);
+  // This might error.
+  const url = new URL(string);
+
+  // This might, too.
+  decodeURIComponent(url.pathname);
+
+  return true;
+}
+
+export function isCuratedURL(string) {
+  if (disabledCuratedURLValidation) {
+    return isURL(string);
+  }
+
+  // Do the same basic checks as in isURL.
+  // We'll need access to the URL object.
+  const url = new URL(string);
+  decodeURIComponent(url.pathname);
+
+  const useHostname = hostname =>
+    new Error(
+      `Use ${colors.green(hostname)}, ` +
+      `not ${colors.red(url.hostname)}`);
+
+  switch (url.hostname) {
+    case 'tumblr.com':
+      throw useHostname('www.tumblr.com');
+
+    case 'www.twitter.com':
+    case 'x.com':
+      throw useHostname('twitter.com');
+
+    case 'youtu.be':
+    case 'youtube.com':
+      throw useHostname('www.youtube.com');
+  }
+
+  const dropSearchParam = (param, message = null) => {
+    if (url.searchParams.has(param)) {
+      const index =
+        Array.from(url.searchParams)
+          .findIndex(entry => entry[0] === param);
+
+      const prefix = (index === 0 ? '?' : '&');
+
+      const value = url.searchParams.get(param);
+
+      throw new Error(
+        `Remove ${colors.red(`${prefix}${param}=${value}`)}` +
+        (message ? ` (${message})` : ''));
+    }
+  };
+
+  const dropTrailingSlash = () => {
+    if (url.pathname.length >= 3 && url.pathname.endsWith('/')) {
+      throw new Error(
+        `Remove slash at end: ` +
+        url.pathname.slice(0, -1) +
+        colors.bright(colors.red('/')));
+    }
+  };
+
+  switch (url.hostname) {
+    case 'soundcloud.com':
+      dropTrailingSlash();
+      break;
+
+    case 'open.spotify.com':
+      dropSearchParam('si', `tracking parameter`);
+      dropSearchParam('nd', `unnecessary parameter`);
+      break;
+
+    case 'www.youtube.com':
+      dropSearchParam('si', `tracking parameter`);
+      break;
+  }
+
+  if (url.hostname === 'music.apple.com') {
+    const countryMatch =
+      url.pathname.match(/^\/[a-z][a-z]\//);
+
+    if (countryMatch) {
+      throw new Error(`Remove country code ${colors.red(countryMatch[0])}`);
+    }
+  }
+
+  if (url.pathname === '/' && string === url.origin + url.hash + url.search) {
+    if (url.hash) {
+      throw new Error(`Add slash before "#" hash`);
+    } else if (url.search) {
+      throw new Error(`Add slash before "?" query`);
+    } else {
+      throw new Error(`Add slash at end`);
+    }
+  }
 
   return true;
 }
diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js
index 4b61619d..3ae2cfc6 100644
--- a/src/write/build-modes/index.js
+++ b/src/write/build-modes/index.js
@@ -1,4 +1,3 @@
 export * as 'live-dev-server' from './live-dev-server.js';
 export * as 'repl' from './repl.js';
-export * as 'sort' from './sort.js';
 export * as 'static-build' from './static-build.js';
diff --git a/src/write/tidy-modes/format-urls.js b/src/write/tidy-modes/format-urls.js
new file mode 100644
index 00000000..5771fe3e
--- /dev/null
+++ b/src/write/tidy-modes/format-urls.js
@@ -0,0 +1,34 @@
+export const description = `Update data files in-place to satisfy formatting rules for curated URLs`;
+
+import {logInfo} from '#cli';
+import {reformatCuratedURLs} from '#reformat-urls';
+
+export async function go({
+  dataPath,
+  tidyingOnly,
+}) {
+  const changedFiles =
+    await reformatCuratedURLs({
+      dataPath,
+      showChangedFiles: true,
+      showSatisfiedRules: tidyingOnly,
+    });
+
+  if (changedFiles.size === 0) {
+    if (tidyingOnly) {
+      logInfo`All URL formatting rules were already satisfied. Good to go!`;
+      return 'clean';
+    } else {
+      logInfo`All curated URL formatting rules are satisfied - nice!`;
+      return 'clean';
+    }
+  } else {
+    const filesPart =
+      (changedFiles.size === 1
+        ? `1 file`
+        : `${changedFiles.size} files`);
+
+    logInfo`Updated ${filesPart} to satisfy URL formatting rules.`;
+    return 'updated';
+  }
+}
diff --git a/src/write/tidy-modes/index.js b/src/write/tidy-modes/index.js
new file mode 100644
index 00000000..54e2bbf3
--- /dev/null
+++ b/src/write/tidy-modes/index.js
@@ -0,0 +1,2 @@
+export * as 'format-urls' from './format-urls.js';
+export * as 'sort' from './sort.js';
diff --git a/src/write/build-modes/sort.js b/src/write/tidy-modes/sort.js
index 1a738ac8..967a5be1 100644
--- a/src/write/build-modes/sort.js
+++ b/src/write/tidy-modes/sort.js
@@ -4,48 +4,34 @@ import {logInfo} from '#cli';
 import {empty} from '#sugar';
 import thingConstructors from '#things';
 
-export const config = {
-  fileSizes: {
-    applicable: false,
-  },
-
-  languageReloading: {
-    applicable: false,
-  },
-
-  mediaValidation: {
-    applicable: false,
-  },
-
-  search: {
-    applicable: false,
-  },
-
-  thumbs: {
-    applicable: false,
-  },
+export async function go({
+  wikiData,
+  dataPath,
+  tidyingOnly,
+}) {
+  if (empty(wikiData.sortingRules)) {
+    if (tidyingOnly) {
+      logInfo`There aren't any sorting rules in for this wiki.`;
+    }
 
-  webRoutes: {
-    applicable: false,
-  },
+    return 'clean';
+  }
 
-  sort: {
-    applicable: false,
-  },
-};
+  const {SortingRule} = thingConstructors;
 
-export function getCLIOptions() {
-  return {};
-}
+  if (!tidyingOnly) {
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData}));
 
-export async function go({wikiData, dataPath}) {
-  if (empty(wikiData.sortingRules)) {
-    logInfo`There aren't any sorting rules in for this wiki.`;
-    return true;
+    if (results.some(result => result.changed)) {
+      logInfo`Updated data files to satisfy sorting.`;
+      return 'updated';
+    } else {
+      logInfo`All sorting rules are satisfied - nice!`;
+      return 'clean';
+    }
   }
 
-  const {SortingRule} = thingConstructors;
-
   let numUpdated = 0;
   let numActive = 0;
 
@@ -56,7 +42,7 @@ export async function go({wikiData, dataPath}) {
 
     if (result.changed) {
       numUpdated++;
-      logInfo`Updating to satisfy ${niceMessage}.`;
+      logInfo`Updated to satisfy ${niceMessage}.`;
     } else {
       logInfo`Already good: ${niceMessage}`;
     }
@@ -64,13 +50,12 @@ export async function go({wikiData, dataPath}) {
 
   if (numUpdated > 1) {
     logInfo`Updated data files to satisfy ${numUpdated} sorting rules.`;
+    return 'updated';
   } else if (numUpdated === 1) {
     logInfo`Updated data files to satisfy ${1} sorting rule.`
-  } else if (numActive >= 1) {
-    logInfo`All sorting rules were already satisfied. Good to go!`;
+    return 'updated';
   } else {
-    logInfo`No sorting rules are currently active.`;
+    logInfo`All sorting rules were already satisfied. Good to go!`;
+    return 'clean';
   }
-
-  return true;
 }