« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content/dependencies/transformContent.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/content/dependencies/transformContent.js')
-rw-r--r--src/content/dependencies/transformContent.js246
1 files changed, 200 insertions, 46 deletions
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 84fbe361..1bbd45e2 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -2,10 +2,26 @@ import {bindFind} from '#find';
 import {replacerSpec, parseInput} from '#replacer';
 
 import {Marked} from 'marked';
+import striptags from 'striptags';
 
 const commonMarkedOptions = {
   headerIds: false,
   mangle: false,
+
+  tokenizer: {
+    url(src) {
+      // Don't link emails
+      const cap = this.rules.inline.url.exec(src);
+      if (cap?.[2] === '@') return;
+
+      // Use normal tokenizer url behavior otherwise
+      // Note that super.url doesn't work here because marked is binding or
+      // applying this function on the tokenizer instance - super.prop would
+      // just read the prototype of the containing object literal, not the
+      // rebound tokenizer. (Thanks MDN.)
+      return Object.getPrototypeOf(this).url.call(this, src);
+    },
+  },
 };
 
 const multilineMarked = new Marked({
@@ -45,7 +61,7 @@ export default {
   sprawl(wikiData, content) {
     const find = bindFind(wikiData);
 
-    const parsedNodes = parseInput(content);
+    const parsedNodes = parseInput(content ?? '');
 
     return {
       nodes: parsedNodes
@@ -169,6 +185,8 @@ export default {
               link: relation(name, arg),
               label: node.data.label,
               hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
             }
           : getPlaceholder(node, content));
 
@@ -221,6 +239,16 @@ export default {
       default: true,
     },
 
+    absorbPunctuationFollowingExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -232,11 +260,34 @@ export default {
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
 
+    let offsetTextNode = 0;
+
     const contentFromNodes =
-      data.nodes.map(node => {
+      data.nodes.map((node, index) => {
+        const nextNode = data.nodes[index + 1];
+
+        const absorbFollowingPunctuation = template => {
+          if (nextNode?.type !== 'text') {
+            return;
+          }
+
+          const text = nextNode.data;
+          const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i);
+          const suffix = match?.[0];
+          if (suffix) {
+            template.setSlot('suffixNormalContent', suffix);
+            offsetTextNode = suffix.length;
+          }
+        };
+
         switch (node.type) {
-          case 'text':
-            return {type: 'text', data: node.data};
+          case 'text': {
+            const text = node.data.slice(offsetTextNode);
+
+            offsetTextNode = 0;
+
+            return {type: 'text', data: text};
+          }
 
           case 'image': {
             const src =
@@ -279,15 +330,16 @@ export default {
                       {class: 'align-center'},
 
                     {title:
-                      language.$('misc.external.opensInNewTab', {
-                        link:
-                          language.formatExternalLink(link, {
-                            style: 'platform',
-                          }),
+                      language.encapsulate('misc.external.opensInNewTab', capsule =>
+                        language.$(capsule, {
+                          link:
+                            language.formatExternalLink(link, {
+                              style: 'platform',
+                            }),
 
-                        annotation:
-                          language.$('misc.external.opensInNewTab.annotation'),
-                      }).toString()},
+                          annotation:
+                            language.$(capsule, 'annotation'),
+                        }).toString())},
 
                     content);
               }
@@ -332,13 +384,93 @@ export default {
             };
           }
 
+          case 'video': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {width, height, align, pixelate} = node;
+
+            const content =
+              html.tag('div', {class: 'content-video-container'},
+                align === 'center' &&
+                  {class: 'align-center'},
+
+                html.tag('video',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+
+                  {controls: true},
+
+                  pixelate &&
+                    {class: 'pixelate'}));
+
+            return {
+              type: 'processed-video',
+              data: content,
+            };
+          }
+
+          case 'audio': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {align, inline} = node;
+
+            const audio =
+              html.tag('audio',
+                src && {src},
+
+                align === 'center' &&
+                inline &&
+                  {class: 'align-center'},
+
+                {controls: true});
+
+            const content =
+              (inline
+                ? audio
+                : html.tag('div', {class: 'content-audio-container'},
+                    align === 'center' &&
+                      {class: 'align-center'},
+
+                    audio));
+
+            return {
+              type: 'processed-audio',
+              data: content,
+            };
+          }
+
           case 'internal-link': {
             const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
             if (nodeFromRelations.type === 'text') {
               return {type: 'text', data: nodeFromRelations.data};
             }
 
-            const {link, label, hash} = nodeFromRelations;
+            // TODO: This is a bit hacky, like the stuff below,
+            // but since we dressed it up in a utility function
+            // maybe it's okay...
+            const link =
+              html.resolve(
+                nodeFromRelations.link,
+                {slots: ['content', 'hash']});
+
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -373,6 +505,18 @@ export default {
               link.setSlot('tooltipStyle', 'none');
             }
 
+            let doTheAbsorbyThing = false;
+
+            // TODO: This is just silly.
+            try {
+              const tag = html.resolve(link, {normalize: 'tag'});
+              doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link');
+            } catch {}
+
+            if (doTheAbsorbyThing) {
+              absorbFollowingPunctuation(link);
+            }
+
             return {type: 'processed-internal-link', data: link};
           }
 
@@ -380,11 +524,19 @@ export default {
             const {label} = node.data;
             const externalLink = relations.externalLinks[externalLinkIndex++];
 
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
             externalLink.setSlots({
               content: label,
               fromContent: true,
             });
 
+            if (slots.absorbPunctuationFollowingExternalLinks) {
+              absorbFollowingPunctuation(externalLink);
+            }
+
             if (slots.indicateExternalLinks) {
               externalLink.setSlots({
                 indicateExternal: true,
@@ -412,12 +564,19 @@ export default {
                 ? valueFn(replacerValue)
                 : replacerValue);
 
-            const contents =
+            const content =
               (htmlFn
                 ? htmlFn(value, {html, language})
                 : value);
 
-            return {type: 'text', data: contents.toString()};
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
           }
 
           default:
@@ -493,15 +652,19 @@ export default {
 
         const attributes = html.parseAttributes(match[1]);
 
-        // Images that were all on their own line need to be removed from
-        // the surrounding <p> tag that marked generates. The HTML parser
-        // treats a <div> that starts inside a <p> as a Crocker-class
-        // misgiving, and will treat you very badly if you feed it that.
-        if (attributes.get('data-type') === 'processed-image') {
-          if (!attributes.get('data-inline')) {
-            tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
-            deleteParagraph = true;
-          }
+        // Images (or videos) that were all on their own line need to be
+        // removed from the surrounding <p> tag that marked generates.
+        // The HTML parser treats a <div> that starts inside a <p> as a
+        // Crocker-class misgiving, and will treat you very badly if you
+        // feed it that.
+        if (
+          (attributes.get('data-type') === 'processed-image' &&
+          !attributes.get('data-inline')) ||
+          attributes.get('data-type') === 'processed-video' ||
+          attributes.get('data-type') === 'processed-audio'
+        ) {
+          tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
+          deleteParagraph = true;
         }
 
         const nonTextNodeIndex = match[2];
@@ -512,7 +675,11 @@ export default {
         addText(markedOutput.slice(parseFrom));
       }
 
-      return html.tags(tags, {[html.joinChildren]: ''});
+      return (
+        html.tags(tags, {
+          [html.joinChildren]: '',
+          [html.onlyIfContent]: true,
+        }));
     };
 
     if (slots.mode === 'inline') {
@@ -537,9 +704,9 @@ export default {
           // Expand line breaks which don't follow a list, quote,
           // or <br> / "  ", and which don't precede or follow
           // indented text (by at least two spaces).
-          .replace(/(?<!^ *(?:-|\d\.).*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          .replace(/(?<!^ *(?:-|\d+\.).*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which are at the end of a list.
-          .replace(/(?<=^ *(?:-|\d\.).*)\n+(?!^ *(?:-|\d\.))/gm, '\n\n')
+          .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
@@ -571,25 +738,12 @@ export default {
 
       const markedInput =
         extractNonTextNodes({
-          getTextNodeContents(node, index) {
-            // First, replace line breaks that follow text content with
-            // <br> tags.
-            let content = node.data.replace(/(?!^)\n/gm, '<br>\n');
-
-            // Scrap line breaks that are at the end of a verse.
-            content = content.replace(/<br>$(?=\n\n)/gm, '');
-
-            // If the node started with a line break, and it's not the
-            // very first node, then whatever came before it was inline.
-            // (This is an assumption based on text links being basically
-            // the only tag that shows up in lyrics.) Since this text is
-            // following content that was already inline, restore that
-            // initial line break.
-            if (node.data[0] === '\n' && index !== 0) {
-              content = '<br>' + content;
-            }
-
-            return content;
+          getTextNodeContents(node) {
+            // Just insert <br> before every line break. The resulting
+            // text will appear all in one paragraph - this is expected
+            // for lyrics, and allows for multiple lines of proportional
+            // space between stanzas.
+            return node.data.replace(/\n/g, '<br>\n');
           },
         });