« get me outta code hell

bigass code refactor (no more legacy page writes) - 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:
author(quasar) nebula <towerofnix@gmail.com>2021-05-15 19:08:48 -0300
committer(quasar) nebula <towerofnix@gmail.com>2021-05-25 19:07:09 -0300
commitd41853b617e1b0e7fa41309ff0d42611305c3149 (patch)
tree13d4c0312f95efb22bb103fd3df07f6f0bbb2a79 /src/util
parent4fd0d8b5485f94ad67d32bc6a9273ac4727a6475 (diff)
bigass code refactor (no more legacy page writes)
Diffstat (limited to 'src/util')
-rw-r--r--src/util/html.js16
-rw-r--r--src/util/link.js10
-rw-r--r--src/util/replacer.js424
-rw-r--r--src/util/strings.js11
-rw-r--r--src/util/sugar.js11
5 files changed, 458 insertions, 14 deletions
diff --git a/src/util/html.js b/src/util/html.js
index 4895301b..94756984 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -73,18 +73,20 @@ export function escapeAttributeValue(value) {
 export function attributes(attribs) {
     return Object.entries(attribs)
         .map(([ key, val ]) => {
-            if (!val)
-                return [key, val];
-            else if (typeof val === 'string' || typeof val === 'boolean')
-                return [key, val];
+            if (typeof val === 'undefined' || val === null)
+                return [key, val, false];
+            else if (typeof val === 'string')
+                return [key, val, true];
+            else if (typeof val === 'boolean')
+                return [key, val, val];
             else if (typeof val === 'number')
-                return [key, val.toString()];
+                return [key, val.toString(), true];
             else if (Array.isArray(val))
-                return [key, val.join(' ')];
+                return [key, val.filter(Boolean).join(' '), val.length > 0];
             else
                 throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
         })
-        .filter(([ key, val ]) => val)
+        .filter(([ key, val, keep ]) => keep)
         .map(([ key, val ]) => (typeof val === 'boolean'
             ? `${key}`
             : `${key}="${escapeAttributeValue(val)}"`))
diff --git a/src/util/link.js b/src/util/link.js
index e5c3c596..107b35ff 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -14,15 +14,21 @@ import { getLinkThemeString } from './colors.js'
 
 const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
     (thing, {
-        strings, to,
+        to,
         text = '',
+        attributes = null,
         class: className = '',
+        color: color2 = true,
         hash = ''
     }) => (
         html.tag('a', {
             ...attr ? attr(thing) : {},
+            ...attributes ? attributes : {},
             href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''),
-            style: color ? getLinkThemeString(thing.color) : '',
+            style: (
+                typeof color2 === 'string' ? getLinkThemeString(color2) :
+                color2 && color ? getLinkThemeString(thing.color) :
+                ''),
             class: className
         }, text || thing.name)
     );
diff --git a/src/util/replacer.js b/src/util/replacer.js
new file mode 100644
index 00000000..a1e880ef
--- /dev/null
+++ b/src/util/replacer.js
@@ -0,0 +1,424 @@
+import find from './find.js';
+import {logError} from './cli.js';
+import {escapeRegex} from './sugar.js';
+
+export function validateReplacerSpec(replacerSpec, link) {
+    let success = true;
+
+    for (const [key, {link: linkKey, find: findKey, value, html}] of Object.entries(replacerSpec)) {
+        if (!html && !link[linkKey]) {
+            logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
+            success = false;
+        }
+        if (findKey && !find[findKey]) {
+            logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`;
+            success = false;
+        }
+    }
+
+    return success;
+}
+
+// Syntax literals.
+const tagBeginning = '[[';
+const tagEnding = ']]';
+const tagReplacerValue = ':';
+const tagHash = '#';
+const tagArgument = '*';
+const tagArgumentValue = '=';
+const tagLabel = '|';
+
+const noPrecedingWhitespace = '(?<!\\s)';
+
+const R_tagBeginning =
+    escapeRegex(tagBeginning);
+
+const R_tagEnding =
+    escapeRegex(tagEnding);
+
+const R_tagReplacerValue =
+    noPrecedingWhitespace +
+    escapeRegex(tagReplacerValue);
+
+const R_tagHash =
+    noPrecedingWhitespace +
+    escapeRegex(tagHash);
+
+const R_tagArgument =
+    escapeRegex(tagArgument);
+
+const R_tagArgumentValue =
+    escapeRegex(tagArgumentValue);
+
+const R_tagLabel =
+    escapeRegex(tagLabel);
+
+const regexpCache = {};
+
+const makeError = (i, message) => ({i, type: 'error', data: {message}});
+const endOfInput = (i, comment) => makeError(i, `Unexpected end of input (${comment}).`);
+
+// These are 8asically stored on the glo8al scope, which might seem odd
+// for a recursive function, 8ut the values are only ever used immediately
+// after they're set.
+let stopped,
+    stop_iMatch,
+    stop_iParse,
+    stop_literal;
+
+function parseOneTextNode(input, i, stopAt) {
+    return parseNodes(input, i, stopAt, true)[0];
+}
+
+function parseNodes(input, i, stopAt, textOnly) {
+    let nodes = [];
+    let escapeNext = false;
+    let string = '';
+    let iString = 0;
+
+    stopped = false;
+
+    const pushTextNode = (isLast) => {
+        string = input.slice(iString, i);
+
+        // If this is the last text node 8efore stopping (at a stopAt match
+        // or the end of the input), trim off whitespace at the end.
+        if (isLast) {
+            string = string.trimEnd();
+        }
+
+        if (string.length) {
+            nodes.push({i: iString, iEnd: i, type: 'text', data: string});
+            string = '';
+        }
+    };
+
+    const literalsToMatch = stopAt ? stopAt.concat([R_tagBeginning]) : [R_tagBeginning];
+
+    // The 8ackslash stuff here is to only match an even (or zero) num8er
+    // of sequential 'slashes. Even amounts always cancel out! Odd amounts
+    // don't, which would mean the following literal is 8eing escaped and
+    // should 8e counted only as part of the current string/text.
+    //
+    // Inspired 8y this: https://stackoverflow.com/a/41470813
+    const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
+
+    // There are 8asically only a few regular expressions we'll ever use,
+    // 8ut it's a pain to hard-code them all, so we dynamically gener8te
+    // and cache them for reuse instead.
+    let regexp;
+    if (regexpCache.hasOwnProperty(regexpSource)) {
+        regexp = regexpCache[regexpSource];
+    } else {
+        regexp = new RegExp(regexpSource);
+        regexpCache[regexpSource] = regexp;
+    }
+
+    // Skip whitespace at the start of parsing. This is run every time
+    // parseNodes is called (and thus parseOneTextNode too), so spaces
+    // at the start of syntax elements will always 8e skipped. We don't
+    // skip whitespace that shows up inside content (i.e. once we start
+    // parsing below), though!
+    const whitespaceOffset = input.slice(i).search(/[^\s]/);
+
+    // If the string is all whitespace, that's just zero content, so
+    // return the empty nodes array.
+    if (whitespaceOffset === -1) {
+        return nodes;
+    }
+
+    i += whitespaceOffset;
+
+    while (i < input.length) {
+        const match = input.slice(i).match(regexp);
+
+        if (!match) {
+            iString = i;
+            i = input.length;
+            pushTextNode(true);
+            break;
+        }
+
+        const closestMatch = match[0];
+        const closestMatchIndex = i + match.index;
+
+        if (textOnly && closestMatch === tagBeginning)
+            throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
+
+        const stopHere = (closestMatch !== tagBeginning);
+
+        iString = i;
+        i = closestMatchIndex;
+        pushTextNode(stopHere);
+
+        i += closestMatch.length;
+
+        if (stopHere) {
+            stopped = true;
+            stop_iMatch = closestMatchIndex;
+            stop_iParse = i;
+            stop_literal = closestMatch;
+            break;
+        }
+
+        if (closestMatch === tagBeginning) {
+            const iTag = closestMatchIndex;
+
+            let N;
+
+            // Replacer key (or value)
+
+            N = parseOneTextNode(input, i, [R_tagReplacerValue, R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
+
+            if (!stopped) throw endOfInput(i, `reading replacer key`);
+
+            if (!N) {
+                switch (stop_literal) {
+                    case tagReplacerValue:
+                    case tagArgument:
+                        throw makeError(i, `Expected text (replacer key).`);
+                    case tagLabel:
+                    case tagHash:
+                    case tagEnding:
+                        throw makeError(i, `Expected text (replacer key/value).`);
+                }
+            }
+
+            const replacerFirst = N;
+            i = stop_iParse;
+
+            // Replacer value (if explicit)
+
+            let replacerSecond;
+
+            if (stop_literal === tagReplacerValue) {
+                N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
+
+                if (!stopped) throw endOfInput(i, `reading replacer value`);
+                if (!N.length) throw makeError(i, `Expected content (replacer value).`);
+
+                replacerSecond = N;
+                i = stop_iParse
+            }
+
+            // Assign first & second to replacer key/value
+
+            let replacerKey,
+                replacerValue;
+
+            // Value is an array of nodes, 8ut key is just one (or null).
+            // So if we use replacerFirst as the value, we need to stick
+            // it in an array (on its own).
+            if (replacerSecond) {
+                replacerKey = replacerFirst;
+                replacerValue = replacerSecond;
+            } else {
+                replacerKey = null;
+                replacerValue = [replacerFirst];
+            }
+
+            // Hash
+
+            let hash;
+
+            if (stop_literal === tagHash) {
+                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+
+                if (!stopped) throw endOfInput(i, `reading hash`);
+
+                if (!N)
+                    throw makeError(i, `Expected content (hash).`);
+
+                hash = N;
+                i = stop_iParse;
+            }
+
+            // Arguments
+
+            const args = [];
+
+            while (stop_literal === tagArgument) {
+                N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]);
+
+                if (!stopped) throw endOfInput(i, `reading argument key`);
+
+                if (stop_literal !== tagArgumentValue)
+                    throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`);
+
+                if (!N)
+                    throw makeError(i, `Expected text (argument key).`);
+
+                const key = N;
+                i = stop_iParse;
+
+                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+
+                if (!stopped) throw endOfInput(i, `reading argument value`);
+                if (!N.length) throw makeError(i, `Expected content (argument value).`);
+
+                const value = N;
+                i = stop_iParse;
+
+                args.push({key, value});
+            }
+
+            let label;
+
+            if (stop_literal === tagLabel) {
+                N = parseOneTextNode(input, i, [R_tagEnding]);
+
+                if (!stopped) throw endOfInput(i, `reading label`);
+                if (!N) throw makeError(i, `Expected text (label).`);
+
+                label = N;
+                i = stop_iParse;
+            }
+
+            nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}});
+
+            continue;
+        }
+    }
+
+    return nodes;
+};
+
+export function parseInput(input) {
+    try {
+        return parseNodes(input, 0);
+    } 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 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;
+
+        throw new SyntaxError(fixWS`
+            Parse error (at pos ${i}): ${message}
+            ${line}
+            ${'-'.repeat(cursor) + '^'}
+        `);
+    }
+}
+
+function evaluateTag(node, opts) {
+    const { input, link, replacerSpec, strings, to, wikiData } = opts;
+
+    const source = input.slice(node.i, node.iEnd);
+
+    const replacerKey = node.data.replacerKey?.data || 'track';
+
+    if (!replacerSpec[replacerKey]) {
+        logWarn`The link ${source} has an invalid replacer key!`;
+        return source;
+    }
+
+    const {
+        find: findKey,
+        link: linkKey,
+        value: valueFn,
+        html: htmlFn,
+        transformName
+    } = replacerSpec[replacerKey];
+
+    const replacerValue = transformNodes(node.data.replacerValue, opts);
+
+    const value = (
+        valueFn ? valueFn(replacerValue) :
+        findKey ? find[findKey](replacerValue, {wikiData}) :
+        {
+            directory: replacerValue,
+            name: null
+        });
+
+    if (!value) {
+        logWarn`The link ${source} does not match anything!`;
+        return source;
+    }
+
+    const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+
+    const label = (enteredLabel
+        || transformName && transformName(value.name, node, input)
+        || value.name);
+
+    if (!valueFn && !label) {
+        logWarn`The link ${source} requires a label be entered!`;
+        return source;
+    }
+
+    const hash = node.data.hash && transformNodes(node.data.hash, opts);
+
+    const args = node.data.args && Object.fromEntries(node.data.args.map(
+        ({ key, value }) => [
+            transformNode(key, opts),
+            transformNodes(value, opts)
+        ]));
+
+    const fn = (htmlFn
+        ? htmlFn
+        : link[linkKey]);
+
+    try {
+        return fn(value, {text: label, hash, args, strings, to});
+    } catch (error) {
+        logError`The link ${source} failed to be processed: ${error}`;
+        return source;
+    }
+}
+
+function transformNode(node, opts) {
+    if (!node) {
+        throw new Error('Expected a node!');
+    }
+
+    if (Array.isArray(node)) {
+        throw new Error('Got an array - use transformNodes here!');
+    }
+
+    switch (node.type) {
+        case 'text':
+            return node.data;
+        case 'tag':
+            return evaluateTag(node, opts);
+        default:
+            throw new Error(`Unknown node type ${node.type}`);
+    }
+}
+
+function transformNodes(nodes, opts) {
+    if (!nodes || !Array.isArray(nodes)) {
+        throw new Error(`Expected an array of nodes! Got: ${nodes}`);
+    }
+
+    return nodes.map(node => transformNode(node, opts)).join('');
+}
+
+export function transformInline(input, {replacerSpec, link, strings, to, wikiData}) {
+    if (!replacerSpec) throw new Error('Expected replacerSpec');
+    if (!link) throw new Error('Expected link');
+    if (!strings) throw new Error('Expected strings');
+    if (!to) throw new Error('Expected to');
+    if (!wikiData) throw new Error('Expected wikiData');
+
+    const nodes = parseInput(input);
+    return transformNodes(nodes, {input, link, replacerSpec, strings, to, wikiData});
+}
diff --git a/src/util/strings.js b/src/util/strings.js
index 99104aa3..c0664351 100644
--- a/src/util/strings.js
+++ b/src/util/strings.js
@@ -1,4 +1,5 @@
 import { logWarn } from './cli.js';
+import { bindOpts } from './sugar.js';
 
 // Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le
 // name and not one I intend on using, thank you very much. (Don't even get me
@@ -194,13 +195,13 @@ export function genStrings(stringsJSON, {
         }
     };
 
-    const bindOpts = (obj, bind) => Object.fromEntries(Object.entries(obj).map(
-        ([ key, fn ]) => [key, (value, opts = {}) => fn(value, {...bind, ...opts})]
-    ));
-
     // And the provided utility dictionaries themselves, of course!
     for (const [key, utilDict] of Object.entries(bindUtilities)) {
-        strings[key] = bindOpts(utilDict, {strings});
+        const boundUtilDict = {};
+        for (const [key, fn] of Object.entries(utilDict)) {
+            boundUtilDict[key] = bindOpts(fn, {strings});
+        }
+        strings[key] = boundUtilDict;
     }
 
     return strings;
diff --git a/src/util/sugar.js b/src/util/sugar.js
index c24c617c..79a271bf 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -76,3 +76,14 @@ export function delay(ms) {
 export function escapeRegex(string) {
     return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
 }
+
+export function bindOpts(fn, bind) {
+    const bindIndex = bind[bindOpts.bindIndex] ?? 1;
+
+    return (...args) => {
+        const opts = args[bindIndex] ?? {};
+        return fn(...args.slice(0, bindIndex), {...bind, ...opts});
+    };
+}
+
+bindOpts.bindIndex = Symbol();