« 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/misc-templates.js43
-rw-r--r--src/util/urls.js56
-rw-r--r--src/write/bind-utilities.js1
-rw-r--r--src/write/build-modes/live-dev-server.js287
-rw-r--r--src/write/build-modes/static-build.js28
-rw-r--r--src/write/page-template.js21
6 files changed, 355 insertions, 81 deletions
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 9a1bbf50..bccb8831 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -971,46 +971,37 @@ function unbound_generateStickyHeadingContainer({
 
 function unbound_getFooterLocalizationLinks(pathname, {
   html,
+  defaultLanguage,
   language,
+  languages,
   to,
-  paths,
 
-  defaultLanguage,
-  languages,
+  pageSubKey,
+  urlArgs,
 }) {
-  const {urlPath} = paths;
-  const keySuffix = urlPath[0].replace(/^localized\./, '.');
-  const toArgs = urlPath.slice(1);
-
   const links = Object.entries(languages)
     .filter(([code, language]) => code !== 'default' && !language.hidden)
     .map(([code, language]) => language)
     .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0))
     .map((language) =>
-      html.tag(
-        'span',
-        html.tag(
-          'a',
+      html.tag('span',
+        html.tag('a',
           {
             href:
               language === defaultLanguage
-                ? to('localizedDefaultLanguage' + keySuffix, ...toArgs)
+                ? to(
+                    'localizedDefaultLanguage.' + pageSubKey,
+                    ...urlArgs)
                 : to(
-                    'localizedWithBaseDirectory' + keySuffix,
-                    language.code,
-                    ...toArgs
-                  ),
+                    'localizedWithBaseDirectory.' + pageSubKey,
+                    language.code, ...urlArgs),
           },
-          language.name
-        )
-      )
-    );
-
-  return html.tag(
-    'div',
-    {class: 'footer-localization-links'},
-    language.$('misc.uiLanguage', {languages: links.join('\n')})
-  );
+          language.name)));
+
+  return html.tag('div', {class: 'footer-localization-links'},
+    language.$('misc.uiLanguage', {
+      languages: links.join('\n'),
+    }));
 }
 
 // Exports
diff --git a/src/util/urls.js b/src/util/urls.js
index f05f134b..69ff1d7e 100644
--- a/src/util/urls.js
+++ b/src/util/urls.js
@@ -142,11 +142,11 @@ export function getURLsFrom({
 
   baseDirectory,
   pageSubKey,
-  paths,
+  subdirectoryPrefix,
 }) {
   return (targetFullKey, ...args) => {
     const [groupKey, subKey] = targetFullKey.split('.');
-    let path = paths.subdirectoryPrefix;
+    let path = subdirectoryPrefix;
 
     let from;
     let to;
@@ -184,32 +184,47 @@ export function getURLsFrom({
   };
 }
 
-export function getPagePaths({
-  outputPath,
+export function getPagePathname({
+  baseDirectory,
+  fullKey,
+  urlArgs,
   urls,
+}) {
+  const [groupKey, subKey] = fullKey.split('.');
 
+  return (groupKey === 'localized' && baseDirectory
+    ? urls
+        .from('shared.root')
+        .toDevice(
+          'localizedWithBaseDirectory.' + subKey,
+          baseDirectory,
+          ...urlArgs)
+    : urls
+        .from('shared.root')
+        .toDevice(fullKey, ...urlArgs));
+}
+
+// Needed for the rare path arguments which themselves contains one or more
+// slashes, e.g. for listings, with arguments like 'albums/by-name'.
+export function getPageSubdirectoryPrefix({urlArgs}) {
+  return '../'.repeat(urlArgs.join('/').split('/').length - 1);
+}
+
+export function getPagePaths({
   baseDirectory,
   fullKey,
+  outputPath,
   urlArgs,
+  urls,
 }) {
   const [groupKey, subKey] = fullKey.split('.');
 
-  const pathname =
-    groupKey === 'localized' && baseDirectory
-      ? urls
-          .from('shared.root')
-          .toDevice(
-            'localizedWithBaseDirectory.' + subKey,
-            baseDirectory,
-            ...urlArgs)
-      : urls
-          .from('shared.root')
-          .toDevice(fullKey, ...urlArgs);
-
-  // Needed for the rare path arguments which themselves contains one or more
-  // slashes, e.g. for listings, with arguments like 'albums/by-name'.
-  const subdirectoryPrefix =
-    '../'.repeat(urlArgs.join('/').split('/').length - 1);
+  const pathname = getPagePathname({
+    baseDirectory,
+    fullKey,
+    urlArgs,
+    urls,
+  });
 
   const outputDirectory = path.join(outputPath, pathname);
 
@@ -224,6 +239,5 @@ export function getPagePaths({
 
     output,
     pathname,
-    subdirectoryPrefix,
   };
 }
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 6632ba30..1c4dd282 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -61,6 +61,7 @@ export function bindUtilities({
   const bound = {};
 
   bound.html = html;
+  bound.language = language;
 
   bound.img = bindOpts(img, {
     [bindOpts.bindIndex]: 0,
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index c3094712..80badeb9 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -1,34 +1,55 @@
+import * as http from 'http';
+import {createReadStream} from 'fs';
+import {stat} from 'fs/promises';
+import * as path from 'path';
+import {pipeline} from 'stream/promises'
+
+import {bindUtilities} from '../bind-utilities.js';
+
+import {serializeThings} from '../../data/serialize.js';
+
 import * as pageSpecs from '../../page/index.js';
 
+import {logInfo, progressCallAll} from '../../util/cli.js';
+import {withEntries} from '../../util/sugar.js';
+
+import {
+  getPagePathname,
+  getPageSubdirectoryPrefix,
+  getURLsFrom,
+} from '../../util/urls.js';
+
 import {
-  logInfo,
-  progressCallAll,
-} from '../../util/cli.js';
+  generateDocumentHTML,
+  generateGlobalWikiDataJSON,
+  generateRedirectHTML,
+} from '../page-template.js';
 
 export function getCLIOptions() {
-  // Stub.
   return {};
 }
 
 export async function go({
   _cliOptions,
   _dataPath,
-  _mediaPath,
+  mediaPath,
   _queueSize,
 
-  _defaultLanguage,
-  _languages,
-  _srcRootPath,
-  _urls,
+  defaultLanguage,
+  languages,
+  srcRootPath,
+  urls,
   _urlSpec,
   wikiData,
 
-  _cachebust,
-  _developersComment,
-  _getSizeOfAdditionalFile,
+  cachebust,
+  developersComment,
+  getSizeOfAdditionalFile,
 }) {
+  const port = 8002;
+
   let targetSpecPairs = getPageSpecsWithTargets({wikiData});
-  const writes = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
+  const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
     targetSpecPairs.map(({
       pageSpec,
       target,
@@ -38,7 +59,245 @@ export async function go({
         ? pageSpec.writeTargetless({wikiData})
         : pageSpec.write(target, {wikiData}))).flat();
 
-  logInfo`Will be serving a total of ${writes.length} pages.`;
+  logInfo`Will be serving a total of ${pages.length} pages.`;
+
+  const urlToPageMap = Object.fromEntries(pages
+    .filter(page => page.type === 'page' || page.type === 'redirect')
+    .flatMap(page => {
+      let servePath;
+      if (page.type === 'page')
+        servePath = page.path;
+      else if (page.type === 'redirect')
+        servePath = page.fromPath;
+
+      const fullKey = 'localized.' + servePath[0];
+      const urlArgs = servePath.slice(1);
+
+      return Object.values(languages).map(language => {
+        const baseDirectory =
+          language === defaultLanguage ? '' : language.code;
+
+        const pathname = getPagePathname({
+          baseDirectory,
+          fullKey,
+          urlArgs,
+          urls,
+        });
+
+        return [pathname, {
+          baseDirectory,
+          language,
+          page,
+          servePath,
+        }];
+      });
+    }));
+
+  const server = http.createServer(async (request, response) => {
+    const contentTypeHTML = {'Content-Type': 'text/html; charset=utf-8'};
+    const contentTypeJSON = {'Content-Type': 'text/json; charset=utf-8'};
+    const contentTypePlain = {'Content-Type': 'text/plain; charset=utf-8'};
+
+    const requestTime = new Date().toLocaleDateString('en-US', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
+    const requestHead = `${requestTime} - ${request.socket.remoteAddress}`;
+
+    let url;
+    try {
+      url = new URL(request.url, `http://${request.headers.host}`);
+    } catch (error) {
+      response.writeHead(500, contentTypePlain);
+      response.end('Failed to parse request URL\n');
+      return;
+    }
+
+    const {pathname} = url;
+
+    // Specialized routes
+
+    if (pathname === '/data.json') {
+      response.writeHead(200, contentTypeJSON);
+      response.end(generateGlobalWikiDataJSON({
+        serializeThings,
+        wikiData,
+      }));
+      return;
+    }
+
+    const {
+      area: localFileArea,
+      path: localFilePath
+    } = pathname.match(/^\/(?<area>static|media)\/(?<path>.*)/)?.groups ?? {};
+
+    if (localFileArea) {
+      // Not security tested, man, this is a dev server!!
+      const safePath = path.resolve('/', localFilePath).replace(/^\//, '');
+
+      let localDirectory;
+      if (localFileArea === 'static') {
+        localDirectory = path.join(srcRootPath, 'static');
+      } else if (localFileArea === 'media') {
+        localDirectory = mediaPath;
+      }
+
+      const filePath = path.resolve(localDirectory, safePath);
+
+      try {
+        await stat(filePath);
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          response.writeHead(404, contentTypePlain);
+          response.end(`No ${localFileArea} file found for: ${safePath}`);
+          console.log(`${requestHead} [404] ${pathname}`);
+          console.log(`ENOENT for stat: ${filePath}`);
+        } else {
+          response.writeHead(500, contentTypePlain);
+          response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          console.error(error);
+        }
+        return;
+      }
+
+      try {
+        response.writeHead(200); // Sorry, no MIME type for now
+        await pipeline(
+          createReadStream(filePath),
+          response);
+        console.log(`${requestHead} [200] ${pathname}`);
+      } catch (error) {
+        response.writeHead(500, contentTypePlain);
+        response.end(`Failed during file-to-response pipeline`);
+        console.error(`${requestHead} [500] ${pathname}`);
+        console.error(error);
+        return;
+      }
+    }
+
+    // Other routes determined by page and URL specs
+
+    // URL to page map expects trailing slash but no leading slash.
+    const pathnameKey = pathname.replace(/^\//, '') + (pathname.endsWith('/') ? '' : '/');
+
+    if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
+      response.writeHead(404, contentTypePlain);
+      response.end(`No page found for: ${pathnameKey}\n`);
+      console.log(`${requestHead} [404] ${pathname}`);
+      return;
+    }
+
+    const {
+      baseDirectory,
+      language,
+      page,
+      servePath,
+    } = urlToPageMap[pathnameKey];
+
+    const to = getURLsFrom({
+      urls,
+      baseDirectory,
+      pageSubKey: servePath[0],
+      subdirectoryPrefix: getPageSubdirectoryPrefix({
+        urlArgs: servePath.slice(1),
+      }),
+    });
+
+    const absoluteTo = (targetFullKey, ...args) => {
+      const [groupKey, subKey] = targetFullKey.split('.');
+      const from = urls.from('shared.root');
+      return (
+        '/' +
+        (groupKey === 'localized' && baseDirectory
+          ? from.to(
+              'localizedWithBaseDirectory.' + subKey,
+              baseDirectory,
+              ...args
+            )
+          : from.to(targetFullKey, ...args))
+      );
+    };
+
+    try {
+      const pageSubKey = servePath[0];
+      const urlArgs = servePath.slice(1);
+
+      if (page.type === 'redirect') {
+        response.writeHead(301, contentTypeHTML);
+
+        const target = to('localized.' + page.toPath[0], ...page.toPath.slice(1));
+        const redirectHTML = generateRedirectHTML(page.title, target, {language});
+
+        response.end(redirectHTML);
+
+        console.log(`${requestHead} [301] (redirect) ${pathname}`);
+        return;
+      }
+
+      response.writeHead(200, contentTypeHTML);
+
+      const localizedPathnames = withEntries(languages, entries => entries
+        .filter(([key, language]) => key !== 'default' && !language.hidden)
+        .map(([_key, language]) => [
+          language.code,
+          getPagePathname({
+            baseDirectory:
+              (language === defaultLanguage
+                ? ''
+                : language.code),
+            fullKey: 'localized.' + pageSubKey,
+            urlArgs,
+            urls,
+          }),
+        ]));
+
+      const bound = bindUtilities({
+        language,
+        to,
+        wikiData,
+      });
+
+      const pageInfo = page.page({
+        ...bound,
+
+        absoluteTo,
+        relativeTo: to,
+        to,
+        urls,
+
+        getSizeOfAdditionalFile,
+      });
+
+      const pageHTML = generateDocumentHTML(pageInfo, {
+        cachebust,
+        defaultLanguage,
+        developersComment,
+        getThemeString: bound.getThemeString,
+        language,
+        languages,
+        localizedPathnames,
+        oEmbedJSONHref: null, // No oEmbed support for live dev server
+        pageSubKey,
+        pathname,
+        urlArgs,
+        to,
+        transformMultiline: bound.transformMultiline,
+        wikiData,
+      });
+
+      console.log(`${requestHead} [200] ${pathname}`);
+      response.end(pageHTML);
+    } catch (error) {
+      response.writeHead(500, contentTypePlain);
+      response.end(`Error generating page, view server log for details\n`);
+      console.error(`${requestHead} [500] ${pathname}`);
+      console.error(error);
+    }
+  });
+
+  server.listen(port);
+  logInfo`${'All done!'} Listening at ${`http://0.0.0.0:${port}/`}`;
+
+  // Just keep going... forever!!!
+  await new Promise(() => {});
 
   return true;
 }
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index b3700c43..1544a122 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -16,7 +16,6 @@ import * as pageSpecs from '../../page/index.js';
 
 import link from '../../util/link.js';
 import {empty, queue, withEntries} from '../../util/sugar.js';
-import {getPagePaths, getURLsFrom} from '../../util/urls.js';
 
 import {
   logError,
@@ -26,6 +25,13 @@ import {
   progressPromiseAll,
 } from '../../util/cli.js';
 
+import {
+  getPagePathname,
+  getPagePaths,
+  getPageSubdirectoryPrefix,
+  getURLsFrom,
+} from '../../util/urls.js';
+
 const pageFlags = Object.keys(pageSpecs);
 
 export function getCLIOptions() {
@@ -263,20 +269,18 @@ export async function go({
         const pageSubKey = path[0];
         const urlArgs = path.slice(1);
 
-        const localizedPaths = withEntries(languages, entries => entries
+        const localizedPathnames = withEntries(languages, entries => entries
           .filter(([key, language]) => key !== 'default' && !language.hidden)
           .map(([_key, language]) => [
             language.code,
-            getPagePaths({
-              outputPath,
-              urls,
-
+            getPagePathname({
               baseDirectory:
                 (language === defaultLanguage
                   ? ''
                   : language.code),
               fullKey: 'localized.' + pageSubKey,
               urlArgs,
+              urls,
             }),
           ]));
 
@@ -293,7 +297,9 @@ export async function go({
           urls,
           baseDirectory,
           pageSubKey,
-          paths,
+          subdirectoryPrefix: getPageSubdirectoryPrefix({
+            urlArgs: page.path.slice(1),
+          }),
         });
 
         const absoluteTo = (targetFullKey, ...args) => {
@@ -320,8 +326,6 @@ export async function go({
         const pageInfo = page({
           ...bound,
 
-          language,
-
           absoluteTo,
           relativeTo: to,
           to,
@@ -350,7 +354,7 @@ export async function go({
           getThemeString: bound.getThemeString,
           language,
           languages,
-          localizedPaths,
+          localizedPathnames,
           oEmbedJSONHref,
           paths,
           to,
@@ -382,7 +386,9 @@ export async function go({
           urls,
           baseDirectory,
           pageSubKey: fromPath[0],
-          paths: from,
+          subdirectoryPrefix: getPageSubdirectoryPrefix({
+            urlArgs: fromPath.slice(1),
+          }),
         });
 
         const target = to('localized.' + toPath[0], ...toPath.slice(1));
diff --git a/src/write/page-template.js b/src/write/page-template.js
index efbc5795..88d81c23 100644
--- a/src/write/page-template.js
+++ b/src/write/page-template.js
@@ -53,11 +53,13 @@ export function generateDocumentHTML(pageInfo, {
   getThemeString,
   language,
   languages,
-  localizedPaths,
-  paths,
+  localizedPathnames,
   oEmbedJSONHref,
+  pageSubKey,
+  pathname,
   to,
   transformMultiline,
+  urlArgs,
   wikiData,
 }) {
   const {wikiInfo} = wikiData;
@@ -125,11 +127,11 @@ export function generateDocumentHTML(pageInfo, {
     : null;
 
   const canonical = wikiInfo.canonicalBase
-    ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname)
+    ? wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname)
     : '';
 
   const localizedCanonical = wikiInfo.canonicalBase
-    ? Object.entries(localizedPaths).map(([code, {pathname}]) => ({
+    ? Object.entries(localizedPathnames).map(([code, pathname]) => ({
         lang: code,
         href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname),
       }))
@@ -162,13 +164,14 @@ export function generateDocumentHTML(pageInfo, {
           },
           footer.content),
 
-        getFooterLocalizationLinks(paths.pathname, {
+        getFooterLocalizationLinks(pathname, {
           defaultLanguage,
           html,
           language,
           languages,
-          paths,
+          pageSubKey,
           to,
+          urlArgs,
         }),
       ]);
 
@@ -264,7 +267,7 @@ export function generateDocumentHTML(pageInfo, {
           ? to(...cur.path)
           : cur.href
           ? (() => {
-              logWarn`Using legacy href format nav link in ${paths.pathname}`;
+              logWarn`Using legacy href format nav link in ${pathname}`;
               return cur.href;
             })()
           : null,
@@ -447,9 +450,9 @@ export function generateDocumentHTML(pageInfo, {
     {
       lang: language.intlCode,
       'data-language-code': language.code,
-      'data-url-key': paths.urlPath[0],
+      'data-url-key': 'localized.' + pageSubKey,
       ...Object.fromEntries(
-        paths.urlPath.slice(1).map((v, i) => [['data-url-value' + i], v])
+        urlArgs.map((v, i) => [['data-url-value' + i], v])
       ),
       'data-rebase-localized': to('localized.root'),
       'data-rebase-shared': to('shared.root'),