« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/util')
-rw-r--r--src/util/cli.js69
-rw-r--r--src/util/magic-constants.js1
-rw-r--r--src/util/transform-content.js453
-rw-r--r--src/util/urls.js118
4 files changed, 614 insertions, 27 deletions
diff --git a/src/util/cli.js b/src/util/cli.js
index f1a31900..1ddc90e0 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -64,8 +64,10 @@ export async function parseOptions(options, optionDescriptorMap) {
   // options is the array of options you want to process;
   // optionDescriptorMap is a mapping of option names to objects that describe
   // the expected value for their corresponding options.
-  // Returned is a mapping of any specified option names to their values, or
-  // a process.exit(1) and error message if there were any issues.
+  //
+  // Returned is...
+  // - a mapping of any specified option names to their values
+  // - a process.exit(1) and error message if there were any issues
   //
   // Here are examples of optionDescriptorMap to cover all the things you can
   // do with it:
@@ -95,11 +97,10 @@ export async function parseOptions(options, optionDescriptorMap) {
   // ['--directory', 'apple'] -> {'directory': 'apple'}
   // ['--directory', 'artichoke'] -> (error)
   // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']}
-  //
-  // TODO: Be able to validate the values in a series option.
 
   const handleDashless = optionDescriptorMap[parseOptions.handleDashless];
   const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown];
+
   const result = Object.create(null);
   for (let i = 0; i < options.length; i++) {
     const option = options[i];
@@ -107,6 +108,7 @@ export async function parseOptions(options, optionDescriptorMap) {
       // --x can be a flag or expect a value or series of values
       let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x']
       let descriptor = optionDescriptorMap[name];
+
       if (!descriptor) {
         if (handleUnknown) {
           handleUnknown(option);
@@ -116,36 +118,49 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
         continue;
       }
+
       if (descriptor.alias) {
         name = descriptor.alias;
         descriptor = optionDescriptorMap[name];
       }
-      if (descriptor.type === 'flag') {
-        result[name] = true;
-      } else if (descriptor.type === 'value') {
-        let value = option.slice(2).split('=')[1];
-        if (!value) {
-          value = options[++i];
-          if (!value || value.startsWith('-')) {
-            value = null;
-          }
+
+      switch (descriptor.type) {
+        case 'flag': {
+          result[name] = true;
+          break;
         }
-        if (!value) {
-          console.error(`Expected a value for --${name}`);
-          process.exit(1);
+
+        case 'value': {
+          let value = option.slice(2).split('=')[1];
+          if (!value) {
+            value = options[++i];
+            if (!value || value.startsWith('-')) {
+              value = null;
+            }
+          }
+
+          if (!value) {
+            console.error(`Expected a value for --${name}`);
+            process.exit(1);
+          }
+
+          result[name] = value;
+          break;
         }
-        result[name] = value;
-      } else if (descriptor.type === 'series') {
-        if (!options.slice(i).includes(';')) {
-          console.error(
-            `Expected a series of values concluding with ; (\\;) for --${name}`
-          );
-          process.exit(1);
+
+        case 'series': {
+          if (!options.slice(i).includes(';')) {
+            console.error(`Expected a series of values concluding with ; (\\;) for --${name}`);
+            process.exit(1);
+          }
+
+          const endIndex = i + options.slice(i).indexOf(';');
+          result[name] = options.slice(i + 1, endIndex);
+          i = endIndex;
+          break;
         }
-        const endIndex = i + options.slice(i).indexOf(';');
-        result[name] = options.slice(i + 1, endIndex);
-        i = endIndex;
       }
+
       if (descriptor.validate) {
         const validation = await descriptor.validate(result[name]);
         if (validation !== true) {
@@ -167,10 +182,12 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
         continue;
       }
+
       if (descriptor.alias) {
         name = descriptor.alias;
         descriptor = optionDescriptorMap[name];
       }
+
       if (descriptor.type === 'flag') {
         result[name] = true;
       } else {
diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js
index 73fdbc6d..83dd7db5 100644
--- a/src/util/magic-constants.js
+++ b/src/util/magic-constants.js
@@ -7,4 +7,3 @@
 // (TM).
 
 export const OFFICIAL_GROUP_DIRECTORY = 'official';
-export const FANDOM_GROUP_DIRECTORY = 'fandom';
diff --git a/src/util/transform-content.js b/src/util/transform-content.js
new file mode 100644
index 00000000..d1d0f51a
--- /dev/null
+++ b/src/util/transform-content.js
@@ -0,0 +1,453 @@
+// See also replacer.js, which covers the actual syntax parser and node
+// interpreter. This file works with replacer.js to provide higher-level
+// interfaces for converting various content found in wiki data to HTML for
+// display on the site.
+
+import * as html from './html.js';
+export {transformInline} from './replacer.js';
+
+export const replacerSpec = {
+  album: {
+    find: 'album',
+    link: 'album',
+  },
+  'album-commentary': {
+    find: 'album',
+    link: 'albumCommentary',
+  },
+  'album-gallery': {
+    find: 'album',
+    link: 'albumGallery',
+  },
+  artist: {
+    find: 'artist',
+    link: 'artist',
+  },
+  'artist-gallery': {
+    find: 'artist',
+    link: 'artistGallery',
+  },
+  'commentary-index': {
+    find: null,
+    link: 'commentaryIndex',
+  },
+  date: {
+    find: null,
+    value: (ref) => new Date(ref),
+    html: (date, {language}) =>
+      html.tag('time',
+        {datetime: date.toString()},
+        language.formatDate(date)),
+  },
+  'flash-index': {
+    find: null,
+    link: 'flashIndex',
+  },
+  flash: {
+    find: 'flash',
+    link: 'flash',
+    transformName(name, node, input) {
+      const nextCharacter = input[node.iEnd];
+      const lastCharacter = name[name.length - 1];
+      if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') {
+        return name.slice(0, -1);
+      } else {
+        return name;
+      }
+    },
+  },
+  group: {
+    find: 'group',
+    link: 'groupInfo',
+  },
+  'group-gallery': {
+    find: 'group',
+    link: 'groupGallery',
+  },
+  home: {
+    find: null,
+    link: 'home',
+  },
+  'listing-index': {
+    find: null,
+    link: 'listingIndex',
+  },
+  listing: {
+    find: 'listing',
+    link: 'listing',
+  },
+  media: {
+    find: null,
+    link: 'media',
+  },
+  'news-index': {
+    find: null,
+    link: 'newsIndex',
+  },
+  'news-entry': {
+    find: 'newsEntry',
+    link: 'newsEntry',
+  },
+  root: {
+    find: null,
+    link: 'root',
+  },
+  site: {
+    find: null,
+    link: 'site',
+  },
+  static: {
+    find: 'staticPage',
+    link: 'staticPage',
+  },
+  string: {
+    find: null,
+    value: (ref) => ref,
+    html: (ref, {language, args}) => language.$(ref, args),
+  },
+  tag: {
+    find: 'artTag',
+    link: 'tag',
+  },
+  track: {
+    find: 'track',
+    link: 'track',
+  },
+};
+
+function splitLines(text) {
+  return text.split(/\r\n|\r|\n/);
+}
+
+function joinLineBreaks(sourceLines) {
+  const outLines = [];
+
+  let lineSoFar = '';
+  for (let i = 0; i < sourceLines.length; i++) {
+    const line = sourceLines[i];
+    lineSoFar += line;
+    if (!line.endsWith('<br>')) {
+      outLines.push(lineSoFar);
+      lineSoFar = '';
+    }
+  }
+
+  if (lineSoFar) {
+    outLines.push(lineSoFar);
+  }
+
+  return outLines;
+}
+
+function parseAttributes(string, {to}) {
+  const attributes = Object.create(null);
+  const skipWhitespace = (i) => {
+    const ws = /\s/;
+    if (ws.test(string[i])) {
+      const match = string.slice(i).match(/[^\s]/);
+      if (match) {
+        return i + match.index;
+      } else {
+        return string.length;
+      }
+    } else {
+      return i;
+    }
+  };
+
+  for (let i = 0; i < string.length; ) {
+    i = skipWhitespace(i);
+    const aStart = i;
+    const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
+    const attribute = string.slice(aStart, aEnd);
+    i = skipWhitespace(aEnd);
+    if (string[i] === '=') {
+      i = skipWhitespace(i + 1);
+      let end, endOffset;
+      if (string[i] === '"' || string[i] === "'") {
+        end = string[i];
+        endOffset = 1;
+        i++;
+      } else {
+        end = '\\s';
+        endOffset = 0;
+      }
+      const vStart = i;
+      const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
+      const value = string.slice(vStart, vEnd);
+      i = vEnd + endOffset;
+      if (attribute === 'src' && value.startsWith('media/')) {
+        attributes[attribute] = to('media.path', value.slice('media/'.length));
+      } else {
+        attributes[attribute] = value;
+      }
+    } else {
+      attributes[attribute] = attribute;
+    }
+  }
+  return Object.fromEntries(
+    Object.entries(attributes).map(([key, val]) => [
+      key,
+      val === 'true'
+        ? true
+        : val === 'false'
+        ? false
+        : val === key
+        ? true
+        : val,
+    ])
+  );
+}
+
+function unbound_transformMultiline(text, {
+  img,
+  to,
+  transformInline,
+
+  thumb = null,
+}) {
+  // Heck yes, HTML magics.
+
+  text = transformInline(text.trim());
+
+  const outLines = [];
+
+  const indentString = ' '.repeat(4);
+
+  let levelIndents = [];
+  const openLevel = (indent) => {
+    // opening a sublist is a pain: to be semantically *and* visually
+    // correct, we have to append the <ul> at the end of the existing
+    // previous <li>
+    const previousLine = outLines[outLines.length - 1];
+    if (previousLine?.endsWith('</li>')) {
+      // we will re-close the <li> later
+      outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
+    } else {
+      // if the previous line isn't a list item, this is the opening of
+      // the first list level, so no need for indent
+      outLines.push('<ul>');
+    }
+    levelIndents.push(indent);
+  };
+  const closeLevel = () => {
+    levelIndents.pop();
+    if (levelIndents.length) {
+      // closing a sublist, so close the list item containing it too
+      outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
+    } else {
+      // closing the final list level! no need for indent here
+      outLines.push('</ul>');
+    }
+  };
+
+  // okay yes we should support nested formatting, more than one blockquote
+  // layer, etc, but hear me out here: making all that work would basically
+  // be the same as implementing an entire markdown converter, which im not
+  // interested in doing lol. sorry!!!
+  let inBlockquote = false;
+
+  let lines = splitLines(text);
+  lines = joinLineBreaks(lines);
+  for (let line of lines) {
+    const imageLine = line.startsWith('<img');
+    line = line.replace(/<img (.*?)>/g, (match, attributes) =>
+      img({
+        lazy: true,
+        link: true,
+        thumb,
+        ...parseAttributes(attributes, {to}),
+      })
+    );
+
+    let indentThisLine = 0;
+    let lineContent = line;
+    let lineTag = 'p';
+
+    const listMatch = line.match(/^( *)- *(.*)$/);
+    if (listMatch) {
+      // is a list item!
+      if (!levelIndents.length) {
+        // first level is always indent = 0, regardless of actual line
+        // content (this is to avoid going to a lesser indent than the
+        // initial level)
+        openLevel(0);
+      } else {
+        // find level corresponding to indent
+        const indent = listMatch[1].length;
+        let i;
+        for (i = levelIndents.length - 1; i >= 0; i--) {
+          if (levelIndents[i] <= indent) break;
+        }
+        // note: i cannot equal -1 because the first indentation level
+        // is always 0, and the minimum indentation is also 0
+        if (levelIndents[i] === indent) {
+          // same indent! return to that level
+          while (levelIndents.length - 1 > i) closeLevel();
+          // (if this is already the current level, the above loop
+          // will do nothing)
+        } else if (levelIndents[i] < indent) {
+          // lesser indent! branch based on index
+          if (i === levelIndents.length - 1) {
+            // top level is lesser: add a new level
+            openLevel(indent);
+          } else {
+            // lower level is lesser: return to that level
+            while (levelIndents.length - 1 > i) closeLevel();
+          }
+        }
+      }
+      // finally, set variables for appending content line
+      indentThisLine = levelIndents.length;
+      lineContent = listMatch[2];
+      lineTag = 'li';
+    } else {
+      // not a list item! close any existing list levels
+      while (levelIndents.length) closeLevel();
+
+      // like i said, no nested shenanigans - quotes only appear outside
+      // of lists. sorry!
+      const quoteMatch = line.match(/^> *(.*)$/);
+      if (quoteMatch) {
+        // is a quote! open a blockquote tag if it doesnt already exist
+        if (!inBlockquote) {
+          inBlockquote = true;
+          outLines.push('<blockquote>');
+        }
+        indentThisLine = 1;
+        lineContent = quoteMatch[1];
+      } else if (inBlockquote) {
+        // not a quote! close a blockquote tag if it exists
+        inBlockquote = false;
+        outLines.push('</blockquote>');
+      }
+
+      // let some escaped symbols display as the normal symbol, since the
+      // point of escaping them is just to avoid having them be treated as
+      // syntax markers!
+      if (lineContent.match(/( *)\\-/)) {
+        lineContent = lineContent.replace('\\-', '-');
+      } else if (lineContent.match(/( *)\\>/)) {
+        lineContent = lineContent.replace('\\>', '>');
+      }
+    }
+
+    if (lineTag === 'p') {
+      // certain inline element tags should still be postioned within a
+      // paragraph; other elements (e.g. headings) should be added as-is
+      const elementMatch = line.match(/^<(.*?)[ >]/);
+      if (
+        elementMatch &&
+        !imageLine &&
+        ![
+          'a',
+          'abbr',
+          'b',
+          'bdo',
+          'br',
+          'cite',
+          'code',
+          'data',
+          'datalist',
+          'del',
+          'dfn',
+          'em',
+          'i',
+          'img',
+          'ins',
+          'kbd',
+          'mark',
+          'output',
+          'picture',
+          'q',
+          'ruby',
+          'samp',
+          'small',
+          'span',
+          'strong',
+          'sub',
+          'sup',
+          'svg',
+          'time',
+          'var',
+          'wbr',
+        ].includes(elementMatch[1])
+      ) {
+        lineTag = '';
+      }
+
+      // for sticky headings!
+      if (elementMatch && elementMatch[1] === 'h2') {
+        lineContent = lineContent.replace(/<h2(.*?)>/g, (match, attributes) => {
+          const parsedAttributes = parseAttributes(attributes, {to});
+          return `<h2 ${html.attributes({
+            ...parsedAttributes,
+            class: [...parsedAttributes.class?.split(' ') ?? [], 'content-heading'],
+          })}>`;
+        });
+      }
+    }
+
+    let pushString = indentString.repeat(indentThisLine);
+    if (lineTag) {
+      pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
+    } else {
+      pushString += lineContent;
+    }
+    outLines.push(pushString);
+  }
+
+  // after processing all lines...
+
+  // if still in a list, close all levels
+  while (levelIndents.length) closeLevel();
+
+  // if still in a blockquote, close its tag
+  if (inBlockquote) {
+    inBlockquote = false;
+    outLines.push('</blockquote>');
+  }
+
+  return outLines.join('\n');
+}
+
+function unbound_transformLyrics(text, {
+  transformInline,
+  transformMultiline,
+}) {
+  // Different from transformMultiline 'cuz it joins multiple lines together
+  // with line 8reaks (<br>); transformMultiline treats each line as its own
+  // complete paragraph (or list, etc).
+
+  // If it looks like old data, then like, oh god.
+  // Use the normal transformMultiline tool.
+  if (text.includes('<br')) {
+    return transformMultiline(text);
+  }
+
+  text = transformInline(text.trim());
+
+  let buildLine = '';
+  const addLine = () => outLines.push(`<p>${buildLine}</p>`);
+  const outLines = [];
+  for (const line of text.split('\n')) {
+    if (line.length) {
+      if (buildLine.length) {
+        buildLine += '<br>';
+      }
+      buildLine += line;
+    } else if (buildLine.length) {
+      addLine();
+      buildLine = '';
+    }
+  }
+  if (buildLine.length) {
+    addLine();
+  }
+  return outLines.join('\n');
+}
+
+export {
+  unbound_transformLyrics as transformLyrics,
+  unbound_transformMultiline as transformMultiline
+}
diff --git a/src/util/urls.js b/src/util/urls.js
index 1f9cd9c0..c2119b8d 100644
--- a/src/util/urls.js
+++ b/src/util/urls.js
@@ -136,3 +136,121 @@ export const thumb = {
   medium: thumbnailHelper('.medium'),
   small: thumbnailHelper('.small'),
 };
+
+// Makes the generally-used and wiki-specialized "to" page utility.
+// "to" returns a relative path from the current page to the target.
+export function getURLsFrom({
+  baseDirectory,
+  pagePath,
+  urls,
+}) {
+  const pageSubKey = pagePath[0];
+  const subdirectoryPrefix = getPageSubdirectoryPrefix({pagePath});
+
+  return (targetFullKey, ...args) => {
+    const [groupKey, subKey] = targetFullKey.split('.');
+    let from, to;
+
+    // When linking to *outside* the localized area of the site, we need to
+    // make sure the result is correctly relative to the 8ase directory.
+    if (
+      groupKey !== 'localized' &&
+      groupKey !== 'localizedDefaultLanguage' &&
+      baseDirectory
+    ) {
+      from = 'localizedWithBaseDirectory.' + pageSubKey;
+      to = targetFullKey;
+    } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) {
+      // Special case for specifically linking *from* a page with base
+      // directory *to* a page without! Used for the language switcher and
+      // hopefully nothing else oh god.
+      from = 'localizedWithBaseDirectory.' + pageSubKey;
+      to = 'localized.' + subKey;
+    } else if (groupKey === 'localizedDefaultLanguage') {
+      // Linking to the default, except surprise, we're already IN the default
+      // (no baseDirectory set).
+      from = 'localized.' + pageSubKey;
+      to = 'localized.' + subKey;
+    } else {
+      // If we're linking inside the localized area (or there just is no
+      // 8ase directory), the 8ase directory doesn't matter.
+      from = 'localized.' + pageSubKey;
+      to = targetFullKey;
+    }
+
+    return (
+      subdirectoryPrefix +
+      urls.from(from).to(to, ...args));
+  };
+}
+
+// Makes the generally-used and wiki-specialized "absoluteTo" page utility.
+// "absoluteTo" returns an absolute path, starting at site root (/) leading
+// to the target.
+export function getURLsFromRoot({
+  baseDirectory,
+  urls,
+}) {
+  const {to} = urls.from('shared.root');
+
+  return (targetFullKey, ...args) => {
+    const [groupKey, subKey] = targetFullKey.split('.');
+    return (
+      '/' +
+      (groupKey === 'localized' && baseDirectory
+        ? to(
+            'localizedWithBaseDirectory.' + subKey,
+            baseDirectory,
+            ...args
+          )
+        : to(targetFullKey, ...args))
+    );
+  };
+}
+
+export function getPagePathname({
+  baseDirectory,
+  device = false,
+  pagePath,
+  urls,
+}) {
+  const {[device ? 'toDevice' : 'to']: to} = urls.from('shared.root');
+
+  return (baseDirectory
+    ? to('localizedWithBaseDirectory.' + pagePath[0], baseDirectory, ...pagePath.slice(1))
+    : to('localized.' + pagePath[0], ...pagePath.slice(1)));
+}
+
+export function getPagePathnameAcrossLanguages({
+  defaultLanguage,
+  languages,
+  pagePath,
+  urls,
+}) {
+  return withEntries(languages, entries => entries
+    .filter(([key, language]) => key !== 'default' && !language.hidden)
+    .map(([_key, language]) => [
+      language.code,
+      getPagePathname({
+        baseDirectory:
+          (language === defaultLanguage
+            ? ''
+            : language.code),
+        pagePath,
+        urls,
+      }),
+    ]));
+}
+
+// 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({
+  pagePath,
+}) {
+  const timesNestedDeeply = (pagePath
+    .slice(1) // skip URL key, only check arguments
+    .join('/')
+    .split('/')
+    .length - 1);
+  return '../'.repeat(timesNestedDeeply);
+}