« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/write/build-modes/static-build.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/write/build-modes/static-build.js')
-rw-r--r--src/write/build-modes/static-build.js265
1 files changed, 175 insertions, 90 deletions
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index a355a002..b5ded04c 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -1,13 +1,7 @@
+import {cp, mkdir, stat, symlink, writeFile, unlink} from 'node:fs/promises';
 import * as path from 'node:path';
 
-import {
-  copyFile,
-  mkdir,
-  stat,
-  symlink,
-  writeFile,
-  unlink,
-} from 'node:fs/promises';
+import {rimraf} from 'rimraf';
 
 import {quickLoadContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
@@ -24,6 +18,7 @@ import {
 } from '#cli';
 
 import {
+  getOrigin,
   getPagePathname,
   getURLsFrom,
   getURLsFromRoot,
@@ -38,7 +33,7 @@ export const description = `Generates all page content in one build (according t
 
 export const config = {
   fileSizes: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
@@ -46,11 +41,19 @@ export const config = {
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
+  },
+
+  search: {
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -99,23 +102,16 @@ export function getCLIOptions() {
 
 export async function go({
   cliOptions,
-  _dataPath,
-  mediaPath,
-  mediaCachePath,
   queueSize,
 
+  universalUtilities,
+
   defaultLanguage,
   languages,
-  missingImagePaths,
-  srcRootPath,
-  thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
-  cachebust,
-  developersComment: _developersComment,
-  getSizeOfAdditionalFile,
-  getSizeOfImagePath,
   niceShowAggregate,
 }) {
   const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT;
@@ -148,20 +144,17 @@ export async function go({
 
   await mkdir(outputPath, {recursive: true});
 
-  await writeSymlinks({
-    srcRootPath,
-    mediaPath,
-    mediaCachePath,
+  await writeWebRouteSymlinks({
     outputPath,
-    urls,
+    webRoutes,
   });
 
-  if (writeAll) {
-    await writeFavicon({
-      mediaPath,
-      outputPath,
-    });
+  await writeWebRouteCopies({
+    outputPath,
+    webRoutes,
+  });
 
+  if (writeAll) {
     await writeSharedFilesAndPages({
       outputPath,
       randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}),
@@ -186,7 +179,7 @@ export async function go({
           return null;
         }
 
-        const paths = [];
+        let paths = [];
 
         if (pageSpec.pathsTargetless) {
           const result = pageSpec.pathsTargetless({wikiData});
@@ -216,6 +209,9 @@ export async function go({
           // TODO: Validate each pathsForTargets entry
         }
 
+        paths =
+          paths.filter(path => path.condition?.() ?? true);
+
         return paths;
       })
       .filter(Boolean)
@@ -277,6 +273,8 @@ export async function go({
     showAggregate: niceShowAggregate,
   });
 
+  const commonUtilities = {...universalUtilities};
+
   const perLanguageFn = async (language, i, entries) => {
     const baseDirectory =
       language === defaultLanguage ? '' : language.code;
@@ -305,19 +303,13 @@ export async function go({
         });
 
         const bound = bindUtilities({
+          ...commonUtilities,
+
           absoluteTo,
-          cachebust,
-          defaultLanguage,
-          getSizeOfAdditionalFile,
-          getSizeOfImagePath,
           language,
-          languages,
-          missingImagePaths,
           pagePath,
-          thumbsCache,
-          to,
-          urls,
-          wikiData,
+          pagePathStringFromRoot: pathname,
+          to: page.absoluteLinks ? absoluteTo : to,
         });
 
         let pageHTML, oEmbedJSON;
@@ -432,66 +424,159 @@ async function writePage({
   ].filter(Boolean));
 }
 
-function writeSymlinks({
-  srcRootPath,
-  mediaPath,
-  mediaCachePath,
+function filterNoOrigin(route) {
+  return !getOrigin(route.to);
+}
+
+function writeWebRouteSymlinks({
   outputPath,
-  urls,
+  webRoutes,
 }) {
-  return progressPromiseAll('Writing site symlinks.', [
-    link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
-    link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
-    link(mediaPath, 'media.root'),
-    link(mediaCachePath, 'thumb.root'),
-  ]);
-
-  async function link(directory, urlKey) {
-    const pathname = urls.from('shared.root').toDevice(urlKey);
-    const file = path.join(outputPath, pathname);
-
-    try {
-      await unlink(file);
-    } catch (error) {
-      if (error.code !== 'ENOENT') {
-        throw error;
+  const symlinkRoutes =
+    webRoutes
+      .filter(route => route.statically === 'symlink')
+      .filter(filterNoOrigin);
+
+  const promises =
+    symlinkRoutes.map(async route => {
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const symlinkNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const symlinkPath = path.join(parentDirectory, symlinkNamePart);
+
+      try {
+        await unlink(symlinkPath);
+      } catch (error) {
+        if (error.code !== 'ENOENT') {
+          throw error;
+        }
       }
-    }
 
-    try {
-      await symlink(path.resolve(directory), file);
-    } catch (error) {
-      if (error.code === 'EPERM') {
-        await symlink(path.resolve(directory), file, 'junction');
-      } else {
-        throw error;
+      await mkdir(parentDirectory, {recursive: true});
+
+      try {
+        await symlink(route.from, symlinkPath);
+      } catch (error) {
+        if (error.code === 'EPERM') {
+          await symlink(route.from, symlinkPath, 'junction');
+        } else {
+          throw error;
+        }
       }
-    }
-  }
+    });
+
+  return progressPromiseAll(`Writing web route symlinks.`, promises);
 }
 
-async function writeFavicon({
-  mediaPath,
+async function writeWebRouteCopies({
   outputPath,
+  webRoutes,
 }) {
-  const faviconFile = 'favicon.ico';
+  const copyRoutes =
+    webRoutes
+      .filter(route => route.statically === 'copy')
+      .filter(filterNoOrigin);
+
+  const promises =
+    copyRoutes.map(async route => {
+      const permissionName = '__hsmusic-ok-for-deletion.txt';
+
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const copyNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const copyPath = path.join(parentDirectory, copyNamePart);
+
+      // We're going to do a rimraf call! This is freaking terrifying,
+      // so nope out on a couple important conditions.
+
+      let needsDelete;
+      try {
+        await stat(copyPath);
+        needsDelete = true;
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          needsDelete = false;
+        } else {
+          throw error;
+        }
+      }
 
-  try {
-    await stat(path.join(mediaPath, faviconFile));
-  } catch (error) {
-    return;
-  }
+      if (needsDelete) {
+        // First remove it directly, in case it's a symlink.
+        try {
+          await unlink(copyPath);
+          needsDelete = false;
+        } catch (error) {
+          // EPERM is POSIX, but libuv may or may not flat-out just raise
+          // the system error (which is ostensibly EISDIR on Linux).
+          // https://github.com/nodejs/node-v0.x-archive/issues/5791
+          // https://man7.org/linux/man-pages/man2/unlink.2.html
+          //
+          // Both of these indidcate "a directory, probably" and we'll
+          // still check for the deletion permission file where we expect
+          // it before actually touching anything.
+          if (error.code !== 'EPERM' && error.code !== 'EISDIR') {
+            throw error;
+          }
+        }
+      }
 
-  try {
-    await copyFile(
-      path.join(mediaPath, faviconFile),
-      path.join(outputPath, faviconFile));
-  } catch (error) {
-    logWarn`Failed to copy favicon! ${error.message}`;
-    return;
-  }
+      if (needsDelete) {
+        // Then check that the deletion permission file exists
+        // where we expect it.
+        try {
+          await stat(path.join(copyPath, permissionName));
+        } catch (error) {
+          if (error.code === 'ENOENT') {
+            throw new Error(`Couldn't find ${permissionName} in ${copyPath} - please delete or move away this folder manually`);
+          } else {
+            throw error;
+          }
+        }
+
+        // And *then* actually delete that directory.
+        await rimraf(copyPath);
+      }
 
-  logInfo`Copied favicon to site root.`;
+      // Actually copy the source path where it's wanted.
+      await cp(route.from, copyPath, {recursive: true});
+
+      // And certify that it's OK to delete this path, next time around.
+      await writeFile(path.join(copyPath, permissionName),
+        `The presence of this file (by its name, not its contents)\n` +
+        `indicates hsmusic may delete everything contained in this\n` +
+        `directory (the one which directly contains this file, *not*\n` +
+        `any further-up parent directories).\n` +
+        `\n` +
+        `If you make edits, or add any files, they will be deleted or\n` +
+        `overwritten the next time you run the build.\n` +
+        `\n` +
+        `If you delete *this* file, hsmusic will error during the next\n` +
+        `build, and will ask that you delete the containing directory\n` +
+        `yourself.\n`);
+    });
+
+  const results =
+    await Promise.allSettled(promises);
+
+  const errors =
+    results
+      .filter(({status}) => status === 'rejected')
+      .map(({reason}) => reason)
+      .map(err =>
+        (err.message.startsWith(`Couldn't find`)
+          ? err.message
+          : err));
+
+  if (empty(errors)) {
+    logInfo`Wrote web route copies.`;
+  } else {
+    throw new AggregateError(errors, `Errors copying internal files ("web routes")`);
+  }
 }
 
 async function writeSharedFilesAndPages({