diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/html.js | 16 | ||||
-rw-r--r-- | src/util/link.js | 10 | ||||
-rw-r--r-- | src/util/replacer.js | 424 | ||||
-rw-r--r-- | src/util/strings.js | 11 | ||||
-rw-r--r-- | src/util/sugar.js | 11 |
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(); |