« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/replacer.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/replacer.js')
-rw-r--r--src/replacer.js308
1 files changed, 257 insertions, 51 deletions
diff --git a/src/replacer.js b/src/replacer.js
index 0698eced..8a929444 100644
--- a/src/replacer.js
+++ b/src/replacer.js
@@ -8,8 +8,8 @@
 import * as marked from 'marked';
 
 import * as html from '#html';
-import {escapeRegex, typeAppearance} from '#sugar';
-import {matchMarkdownLinks} from '#wiki-data';
+import {empty, escapeRegex, typeAppearance} from '#sugar';
+import {matchInlineLinks, matchMarkdownLinks} from '#wiki-data';
 
 export const replacerSpec = {
   'album': {
@@ -190,6 +190,9 @@ const tagHash = '#';
 const tagArgument = '*';
 const tagArgumentValue = '=';
 const tagLabel = '|';
+const tooltipBeginning = '<<';
+const tooltipEnding = '>>';
+const tooltipContent = ':';
 
 const noPrecedingWhitespace = '(?<!\\s)';
 
@@ -208,6 +211,14 @@ const R_tagArgumentValue = escapeRegex(tagArgumentValue);
 
 const R_tagLabel = escapeRegex(tagLabel);
 
+const R_tooltipBeginning =
+  '(?<=[^<]|^)' + escapeRegex(tooltipBeginning) + '(?!<)';
+
+const R_tooltipEnding =
+  '(?<=[^>]|^)' + escapeRegex(tooltipEnding) + '(?!>)';
+
+const R_tooltipContent = escapeRegex(tooltipContent);
+
 const regexpCache = {};
 
 const makeError = (i, message) => ({i, type: 'error', data: {message}});
@@ -247,9 +258,13 @@ function parseNodes(input, i, stopAt, textOnly) {
     }
   };
 
-  const literalsToMatch = stopAt
-    ? stopAt.concat([R_tagBeginning])
-    : [R_tagBeginning];
+  const beginnings = [tagBeginning, tooltipBeginning];
+  const R_beginnings = [R_tagBeginning, R_tooltipBeginning];
+
+  const literalsToMatch =
+    (stopAt
+      ? stopAt.concat(R_beginnings)
+      : R_beginnings);
 
   // The 8ackslash stuff here is to only match an even (or zero) num8er
   // of sequential 'slashes. Even amounts always cancel out! Odd amounts
@@ -300,8 +315,10 @@ function parseNodes(input, i, stopAt, textOnly) {
 
     if (textOnly && closestMatch === tagBeginning)
       throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
+    if (textOnly && closestMatch === tooltipBeginning)
+      throw makeError(i, `Unexpected <<tooltip>> - expected only text here.`);
 
-    const stopHere = closestMatch !== tagBeginning;
+    const stopHere = !beginnings.includes(closestMatch);
 
     iString = i;
     i = closestMatchIndex;
@@ -453,6 +470,51 @@ function parseNodes(input, i, stopAt, textOnly) {
 
       continue;
     }
+
+    if (closestMatch === tooltipBeginning) {
+      const iTooltip = closestMatchIndex;
+
+      let N;
+
+      // Label (hoverable text)
+
+      let label;
+
+      N = parseNodes(input, i, [R_tooltipContent, R_tooltipEnding]);
+
+      if (!stopped)
+        throw endOfInput(i, `reading tooltip label`);
+      if (input.slice(i).startsWith(tooltipEnding))
+        throw makeError(i, `Expected tooltip label and content.`);
+      if (!N.length)
+        throw makeError(i, `Expected tooltip label before content.`);
+
+      label = N;
+      i = stop_iParse;
+
+      // Content (tooltip text)
+
+      let content;
+
+      N = parseNodes(input, i, [R_tooltipEnding]);
+
+      if (!stopped)
+        throw endOfInput(i, `reading tooltip content`);
+      if (!N.length)
+        throw makeError(i, `Expected tooltip content`);
+
+      content = N;
+      i = stop_iParse;
+
+      nodes.push({
+        i: iTooltip,
+        iEnd: i,
+        type: 'tooltip',
+        data: {label, content},
+      });
+
+      continue;
+    }
   }
 
   return nodes;
@@ -464,7 +526,7 @@ export function squashBackslashes(text) {
   // a set of characters where the backslash carries meaning
   // into later formatting (i.e. markdown). Note that we do
   // NOT compress double backslashes into single backslashes.
-  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1');
+  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>.-])/g, '$1');
 }
 
 export function restoreRawHTMLTags(text) {
@@ -526,6 +588,7 @@ export function postprocessComments(inputNodes) {
 
 function postprocessHTMLTags(inputNodes, tagName, callback) {
   const outputNodes = [];
+  const errors = [];
 
   const lastNode = inputNodes.at(-1);
 
@@ -593,10 +656,16 @@ function postprocessHTMLTags(inputNodes, tagName, callback) {
           return false;
         })();
 
-        outputNodes.push(
-          callback(attributes, {
-            inline,
-          }));
+        try {
+          outputNodes.push(
+            callback(attributes, {
+              inline,
+            }));
+        } catch (caughtError) {
+          errors.push(new Error(
+            `Failed to process ${match[0]}`,
+            {cause: caughtError}));
+        }
 
         // No longer at the start of a line after the tag - there will at
         // least be text with only '\n' before the next of this tag that's
@@ -619,15 +688,33 @@ function postprocessHTMLTags(inputNodes, tagName, callback) {
     outputNodes.push(node);
   }
 
+  if (!empty(errors)) {
+    throw new AggregateError(
+      errors,
+    `Errors postprocessing <${tagName}> tags`);
+  }
+
   return outputNodes;
 }
 
+function complainAboutMediaSrc(src) {
+  if (!src) {
+    throw new Error(`Missing "src" attribute`);
+  }
+
+  if (src.startsWith('/media/')) {
+    throw new Error(`Start "src" with "media/", not "/media/"`);
+  }
+}
+
 export function postprocessImages(inputNodes) {
   return postprocessHTMLTags(inputNodes, 'img',
     (attributes, {inline}) => {
       const node = {type: 'image'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
       node.inline = attributes.get('inline') ?? inline;
 
       if (attributes.get('link')) node.link = attributes.get('link');
@@ -648,10 +735,13 @@ export function postprocessImages(inputNodes) {
 
 export function postprocessVideos(inputNodes) {
   return postprocessHTMLTags(inputNodes, 'video',
-    attributes => {
+    (attributes, {inline}) => {
       const node = {type: 'video'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
+      node.inline = attributes.get('inline') ?? inline;
 
       if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
       if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
@@ -668,8 +758,12 @@ export function postprocessAudios(inputNodes) {
       const node = {type: 'audio'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
       node.inline = attributes.get('inline') ?? inline;
+
       if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('nameless')) node.nameless = true;
 
       return node;
     });
@@ -762,7 +856,7 @@ export function postprocessSummaries(inputNodes) {
 }
 
 export function postprocessExternalLinks(inputNodes) {
-  const outputNodes = [];
+  let outputNodes = [];
 
   for (const node of inputNodes) {
     if (node.type !== 'text') {
@@ -818,57 +912,169 @@ export function postprocessExternalLinks(inputNodes) {
     }
   }
 
+  // Repeat everything, but for inline links, which are just a URL on its own,
+  // not formatted as a Markdown link. These don't have provided labels, and
+  // get labels automatically filled in by content code.
+
+  inputNodes = outputNodes;
+  outputNodes = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    let textNode = {
+      i: node.i,
+      iEnd: null,
+      type: 'text',
+      data: '',
+    };
+
+    let parseFrom = 0;
+    for (const match of matchInlineLinks(node.data)) {
+      const {href, index, length} = match;
+
+      textNode.data += node.data.slice(parseFrom, index);
+
+      if (textNode.data) {
+        textNode.iEnd = textNode.i + textNode.data.length;
+        outputNodes.push(textNode);
+
+        textNode = {
+          i: node.i + index + length,
+          iEnd: null,
+          type: 'text',
+          data: '',
+        };
+      }
+
+      outputNodes.push({
+        i: node.i + index,
+        iEnd: node.i + index + length,
+        type: 'external-link',
+        data: {label: null, href},
+      });
+
+      parseFrom = index + length;
+    }
+
+    if (parseFrom !== node.data.length) {
+      textNode.data += node.data.slice(parseFrom);
+      textNode.iEnd = node.iEnd;
+    }
+
+    if (textNode.data) {
+      outputNodes.push(textNode);
+    }
+  }
+
   return outputNodes;
 }
 
-export function parseContentNodes(input) {
+export function parseContentNodes(input, {
+  errorMode = 'throw',
+} = {}) {
   if (typeof input !== 'string') {
     throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
   }
 
-  try {
-    let output = parseNodes(input, 0);
-    output = postprocessComments(output);
-    output = postprocessImages(output);
-    output = postprocessVideos(output);
-    output = postprocessAudios(output);
-    output = postprocessHeadings(output);
-    output = postprocessSummaries(output);
-    output = postprocessExternalLinks(output);
-    return output;
-  } catch (errorNode) {
-    if (errorNode.type !== 'error') {
-      throw errorNode;
-    }
-
-    const {
-      i,
-      data: {message},
-    } = errorNode;
-
-    let lineStart = input.slice(0, i).lastIndexOf('\n');
-    if (lineStart >= 0) {
-      lineStart += 1;
-    } else {
-      lineStart = 0;
+  let result = null, error = null;
+
+  process: {
+    try {
+      result = parseNodes(input, 0);
+    } catch (caughtError) {
+      if (caughtError.type === 'error') {
+        const {i, data: {message}} = caughtError;
+
+        let lineStart = input.slice(0, i).lastIndexOf('\n');
+        if (lineStart >= 0) {
+          lineStart += 1;
+        } else {
+          lineStart = 0;
+        }
+
+        let lineEnd = input.slice(i).indexOf('\n');
+        if (lineEnd >= 0) {
+          lineEnd += i;
+        } else {
+          lineEnd = input.length;
+        }
+
+        const line = input.slice(lineStart, lineEnd);
+
+        const cursor = i - lineStart;
+
+        error =
+          new SyntaxError(
+            `Parse error (at pos ${i}): ${message}\n` +
+            line + `\n` +
+            '-'.repeat(cursor) + '^');
+      } else {
+        error = caughtError;
+      }
+
+      // A parse error means there's no output to continue with at all,
+      // so stop here.
+      break process;
     }
 
-    let lineEnd = input.slice(i).indexOf('\n');
-    if (lineEnd >= 0) {
-      lineEnd += i;
-    } else {
-      lineEnd = input.length;
+    const postprocessErrors = [];
+
+    for (const postprocess of [
+      postprocessComments,
+      postprocessImages,
+      postprocessVideos,
+      postprocessAudios,
+      postprocessHeadings,
+      postprocessSummaries,
+      postprocessExternalLinks,
+    ]) {
+      try {
+        result = postprocess(result);
+      } catch (caughtError) {
+        const error =
+          new Error(
+            `Error in step ${`"${postprocess.name}"`}`,
+            {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        postprocessErrors.push(error);
+      }
     }
 
-    const line = input.slice(lineStart, lineEnd);
+    if (!empty(postprocessErrors)) {
+      error =
+        new AggregateError(
+          postprocessErrors,
+        `Errors postprocessing content text`);
+
+      error[Symbol.for('hsmusic.aggregate.translucent')] = 'single';
+    }
+  }
 
-    const cursor = i - lineStart;
+  if (errorMode === 'throw') {
+    if (error) {
+      throw error;
+    } else {
+      return result;
+    }
+  } else if (errorMode === 'return') {
+    if (!result) {
+      result = [{
+        i: 0,
+        iEnd: input.length,
+        type: 'text',
+        data: input,
+      }];
+    }
 
-    throw new SyntaxError([
-      `Parse error (at pos ${i}): ${message}`,
-      line,
-      '-'.repeat(cursor) + '^',
-    ].join('\n'));
+    return {error, result};
+  } else {
+    throw new Error(`Unknown errorMode ${errorMode}`);
   }
 }