diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/aggregate.js | 108 | ||||
-rw-r--r-- | src/util/cli.js | 49 | ||||
-rw-r--r-- | src/util/external-links.js | 681 | ||||
-rw-r--r-- | src/util/html.js | 100 | ||||
-rw-r--r-- | src/util/replacer.js | 87 | ||||
-rw-r--r-- | src/util/serialize.js | 6 |
6 files changed, 750 insertions, 281 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js index c5d4198..3ad8bdb 100644 --- a/src/util/aggregate.js +++ b/src/util/aggregate.js @@ -91,6 +91,46 @@ export function openAggregate({ return aggregate.callAsync(() => withAggregateAsync(...args)); }; + aggregate.receive = (results) => { + if (!Array.isArray(results)) { + if (typeof results === 'object' && results.aggregate) { + const {aggregate, result} = results; + + try { + aggregate.close(); + } catch (error) { + errors.push(error); + } + + return result; + } + + throw new Error(`Expected an array or {aggregate, result} object`); + } + + return results.map(({aggregate, result}) => { + if (!aggregate) { + console.log('nope:', results); + throw new Error(`Expected an array of {aggregate, result} objects`); + } + + try { + aggregate.close(); + } catch (error) { + errors.push(error); + } + + return result; + }); + }; + + aggregate.contain = (results) => { + return { + aggregate, + result: aggregate.receive(results), + }; + }; + aggregate.map = (...args) => { const parent = aggregate; const {result, aggregate: child} = mapAggregate(...args); @@ -136,18 +176,33 @@ export function aggregateThrows(errorClass) { return {[openAggregate.errorClassSymbol]: errorClass}; } -// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn) -// in aggregate utilities. -function _reorganizeAggregateArguments(arg1, arg2) { - if (typeof arg1 === 'function') { - return {fn: arg1, opts: arg2 ?? {}}; - } else if (typeof arg2 === 'function') { - return {fn: arg2, opts: arg1 ?? {}}; +// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate +// utilities (or other shapes besides functions). +function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') { + if (desire(arg1)) { + return [arg1, arg2 ?? {}]; + } else if (desire(arg2)) { + return [arg2, arg1]; } else { - throw new Error(`Expected a function`); + return [undefined, undefined]; } } +// Takes a list of {aggregate, result} objects, puts all the aggregates into +// a new aggregate, and puts all the results into an array, returning both on +// a new {aggregate, result} object. This is essentailly the generalized +// composable version of functions like mapAggregate or filterAggregate. +export function receiveAggregate(arg1, arg2) { + const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray); + if (!array) { + throw new Error(`Expected an array`); + } + + const aggregate = openAggregate(opts); + const result = aggregate.receive(array); + return {aggregate, result}; +} + // Performs an ordinary array map with the given function, collating into a // results array (with errored inputs filtered out) and an error aggregate. // @@ -158,12 +213,20 @@ function _reorganizeAggregateArguments(arg1, arg2) { // use aggregate.close() to throw the error. (This aggregate may be passed to a // parent aggregate: `parent.call(aggregate.close)`!) export function mapAggregate(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _mapAggregate('sync', null, array, fn, opts); } export function mapAggregateAsync(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; return _mapAggregate('async', promiseAll, array, fn, remainingOpts); } @@ -200,12 +263,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { // // As with mapAggregate, the returned aggregate property is not yet closed. export function filterAggregate(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _filterAggregate('sync', null, array, fn, opts); } export async function filterAggregateAsync(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; return _filterAggregate('async', promiseAll, array, fn, remainingOpts); } @@ -268,12 +339,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { // function with it, then closing the function and returning the result (if // there's no throw). export function withAggregate(arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _withAggregate('sync', opts, fn); } export function withAggregateAsync(arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _withAggregate('async', opts, fn); } @@ -294,6 +373,7 @@ export function _withAggregate(mode, aggregateOpts, fn) { export const unhelpfulTraceLines = [ /sugar/, + /aggregate/, /node:/, /<anonymous>/, ]; diff --git a/src/util/cli.js b/src/util/cli.js index 973fef1..ce513f0 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -215,12 +215,30 @@ export function decorateTime(arg1, arg2) { timeSpent: 0, timesCalled: 0, displayTime() { - const averageTime = meta.timeSpent / meta.timesCalled; + const align1 = 48; + const align2 = 22; + + const averageTime = (meta.timeSpent / meta.timesCalled).toExponential(1); + const idPart = typeof id === 'symbol' ? id.description : id; + const timePart = `${meta.timeSpent} ms / ${meta.timesCalled} calls`; + const avgPart = `(avg: ${averageTime} ms)`; + + const alignPart1 = + (idPart.length >= align1 + ? ' ' + : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' '); + + const alignPart2 = + (timePart.length >= align2 + ? ' ' + : ' '.repeat(Math.max(0, align2 - timePart.length))); + console.log( - `\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${ - meta.timeSpent - } ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m` - ); + colors.bright(idPart) + + alignPart1 + + timePart + + alignPart2 + + colors.dim(avgPart)); }, }; @@ -228,7 +246,7 @@ export function decorateTime(arg1, arg2) { const fn = function (...args) { const start = Date.now(); - const ret = functionToBeWrapped(...args); + const ret = functionToBeWrapped.apply(this, args); const end = Date.now(); meta.timeSpent += end - start; meta.timesCalled++; @@ -250,11 +268,20 @@ decorateTime.displayTime = function () { ...Object.getOwnPropertyNames(map), ]; - if (keys.length) { - console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); - for (const key of keys) { - map[key].displayTime(); - } + if (!keys.length) { + return; + } + + console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); + + const metas = + keys + .map(key => map[key]) + .filter(meta => meta.timeSpent >= 1) // Milliseconds! + .sort((a, b) => a.timeSpent - b.timeSpent); + + for (const meta of metas) { + meta.displayTime(); } }; diff --git a/src/util/external-links.js b/src/util/external-links.js index 8ab8dec..a616efb 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -1,8 +1,10 @@ -import {empty, stitchArrays} from '#sugar'; +import {empty, stitchArrays, withEntries} from '#sugar'; import { anyOf, is, + isBoolean, + isObject, isStringNonEmpty, looseArrayOf, optional, @@ -13,9 +15,8 @@ import { } from '#validators'; export const externalLinkStyles = [ - 'normal', - 'compact', 'platform', + 'handle', 'icon-id', ]; @@ -86,25 +87,26 @@ export const isExternalLinkSpec = }), platform: isStringNonEmpty, - substring: optional(isStringNonEmpty), - - // TODO: Don't allow 'handle' or 'custom' options if the corresponding - // properties aren't provided - normal: optional(is('domain', 'handle', 'custom')), - compact: optional(is('domain', 'handle', 'custom')), - icon: optional(isStringNonEmpty), handle: optional(isExternalLinkExtractSpec), - // TODO: This should validate each value with isExternalLinkExtractSpec. - custom: optional(validateAllPropertyValues(isExternalLinkExtractSpec)), + detail: + optional(anyOf( + isStringNonEmpty, + validateProperties({ + [validateProperties.validateOtherKeys]: + isExternalLinkExtractSpec, + + substring: isStringNonEmpty, + }))), + + unusualDomain: optional(isBoolean), + + icon: optional(isStringNonEmpty), })); export const fallbackDescriptor = { platform: 'external', - - normal: 'domain', - compact: 'domain', icon: 'globe', }; @@ -120,7 +122,7 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'playlist', + detail: 'playlist', icon: 'youtube', }, @@ -133,7 +135,7 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'fullAlbum', + detail: 'fullAlbum', icon: 'youtube', }, @@ -145,43 +147,9 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'fullAlbum', - - icon: 'youtube', - }, - - // Special handling for artist links - - { - match: { - domain: 'patreon.com', - context: 'artist', - }, - - platform: 'patreon', - - normal: 'handle', - compact: 'handle', - icon: 'globe', - - handle: /([^/]*)\/?$/, - }, - - { - match: { - context: 'artist', - domain: 'youtube.com', - }, - - platform: 'youtube', + detail: 'fullAlbum', - normal: 'handle', - compact: 'handle', icon: 'youtube', - - handle: { - pathname: /^(@.*?)\/?$/, - }, }, // Special handling for flash links @@ -193,7 +161,7 @@ export const externalLinkSpec = [ }, platform: 'bgreco', - substring: 'flash', + detail: 'flash', icon: 'globe', }, @@ -203,20 +171,16 @@ export const externalLinkSpec = [ match: { context: 'flash', domain: 'homestuck.com', - pathname: /^story\/[0-9]+\/?$/, }, platform: 'homestuck', - substring: 'page', - - normal: 'custom', - icon: 'globe', - custom: { - page: { - pathname: /[0-9]+/, - }, + detail: { + substring: 'page', + page: {pathname: /^story\/([0-9]+)\/?$/,}, }, + + icon: 'globe', }, { @@ -227,7 +191,7 @@ export const externalLinkSpec = [ }, platform: 'homestuck', - substring: 'secretPage', + detail: 'secretPage', icon: 'globe', }, @@ -239,7 +203,7 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'flash', + detail: 'flash', icon: 'youtube', }, @@ -247,12 +211,48 @@ export const externalLinkSpec = [ // Generic domains, sorted alphabetically (by string) { + match: { + domains: [ + 'music.amazon.co.jp', + 'music.amazon.com', + ], + }, + + platform: 'amazonMusic', + icon: 'globe', + }, + + { + match: {domain: 'music.apple.com'}, + platform: 'appleMusic', + icon: 'appleMusic', + }, + + { + match: {domain: 'artstation.com'}, + + platform: 'artstation', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'artstation', + }, + + { + match: {domain: '.artstation.com'}, + + platform: 'artstation', + handle: {domain: /^[^.]+/}, + + icon: 'artstation', + }, + + { match: {domains: ['bc.s3m.us', 'music.solatrus.com']}, platform: 'bandcamp', + handle: {domain: /.+/}, + unusualDomain: true, - normal: 'domain', - compact: 'domain', icon: 'bandcamp', }, @@ -260,11 +260,36 @@ export const externalLinkSpec = [ match: {domain: '.bandcamp.com'}, platform: 'bandcamp', + handle: {domain: /^[^.]+/}, - compact: 'handle', icon: 'bandcamp', + }, + + { + match: {domain: 'bsky.app'}, + + platform: 'bluesky', + handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/}, + + icon: 'bluesky', + }, + + { + match: {domain: '.carrd.co'}, + + platform: 'carrd', + handle: {domain: /^[^.]+/}, - handle: {domain: /^[^.]*/}, + icon: 'carrd', + }, + + { + match: {domain: 'cohost.org'}, + + platform: 'cohost', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'cohost', }, { @@ -280,24 +305,60 @@ export const externalLinkSpec = [ }, { + match: {domain: '.deviantart.com'}, + + platform: 'deviantart', + handle: {domain: /^[^.]+/}, + + icon: 'deviantart', + }, + + { match: {domain: 'deviantart.com'}, + platform: 'deviantart', + handle: {pathname: /^([^/]+)\/?$/}, + icon: 'deviantart', }, { - match: { - domain: 'mspaintadventures.fandom.com', - pathname: /^wiki\/(.+)\/?$/, - }, + match: {domain: 'deviantart.com'}, + platform: 'deviantart', + icon: 'deviantart', + }, - platform: 'fandom', - substring: 'mspaintadventures.page', + { + match: {domain: 'facebook.com'}, - normal: 'custom', - icon: 'globe', + platform: 'facebook', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'facebook', + }, - custom: { + { + match: {domain: 'facebook.com'}, + + platform: 'facebook', + handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/}, + + icon: 'facebook', + }, + + { + match: {domain: 'facebook.com'}, + platform: 'facebook', + icon: 'facebook', + }, + + { + match: {domain: 'mspaintadventures.fandom.com'}, + + platform: 'fandom.mspaintadventures', + + detail: { + substring: 'page', page: { pathname: /^wiki\/(.+)\/?$/, transform: [ @@ -306,52 +367,150 @@ export const externalLinkSpec = [ ], }, }, + + icon: 'globe', }, { match: {domain: 'mspaintadventures.fandom.com'}, - platform: 'fandom', - substring: 'mspaintadventures', + platform: 'fandom.mspaintadventures', icon: 'globe', }, { - match: {domain: 'fandom.com'}, + match: {domains: ['fandom.com', '.fandom.com']}, platform: 'fandom', icon: 'globe', }, { + match: {domain: 'gamebanana.com'}, + platform: 'gamebanana', + icon: 'globe', + }, + + { match: {domain: 'homestuck.com'}, platform: 'homestuck', icon: 'globe', }, { + match: { + domain: 'hsmusic.wiki', + pathname: /^media\/misc\/archive/, + }, + + platform: 'hsmusic.archive', + + icon: 'globe', + }, + + { match: {domain: 'hsmusic.wiki'}, - platform: 'local', + platform: 'hsmusic', icon: 'globe', }, { match: {domain: 'instagram.com'}, + platform: 'instagram', + handle: {pathname: /^([^/]+)\/?$/}, + icon: 'instagram', }, { - match: {domains: ['types.pl']}, + match: {domain: 'instagram.com'}, + platform: 'instagram', + icon: 'instagram', + }, + + // The Wayback Machine is a separate entry. + { + match: {domain: 'archive.org'}, + platform: 'internetArchive', + icon: 'internetArchive', + }, + + { + match: {domain: '.itch.io'}, + + platform: 'itch', + handle: {domain: /^[^.]+/}, + + icon: 'itch', + }, + + { + match: {domain: 'itch.io'}, + + platform: 'itch', + handle: {pathname: /^profile\/([^/]+)\/?$/}, + + icon: 'itch', + }, + + { + match: {domain: 'ko-fi.com'}, + + platform: 'kofi', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'kofi', + }, + + { + match: {domain: 'linktr.ee'}, + + platform: 'linktree', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'linktree', + }, + + { + match: {domains: [ + 'mastodon.social', + 'shrike.club', + 'types.pl', + ]}, platform: 'mastodon', + handle: {domain: /.+/}, + unusualDomain: true, - normal: 'domain', - compact: 'domain', icon: 'mastodon', }, { + match: {domains: ['mspfa.com', '.mspfa.com']}, + platform: 'mspfa', + icon: 'globe', + }, + + { + match: {domain: '.neocities.org'}, + + platform: 'neocities', + handle: {domain: /.+/}, + + icon: 'globe', + }, + + { + match: {domain: '.newgrounds.com'}, + + platform: 'newgrounds', + handle: {domain: /^[^.]+/}, + + icon: 'newgrounds', + }, + + { match: {domain: 'newgrounds.com'}, platform: 'newgrounds', icon: 'newgrounds', @@ -359,8 +518,17 @@ export const externalLinkSpec = [ { match: {domain: 'patreon.com'}, + platform: 'patreon', - icon: 'globe', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'patreon', + }, + + { + match: {domain: 'patreon.com'}, + platform: 'patreon', + icon: 'patreon', }, { @@ -373,51 +541,111 @@ export const externalLinkSpec = [ match: {domain: 'soundcloud.com'}, platform: 'soundcloud', + handle: {pathname: /^([^/]+)\/?$/}, - compact: 'handle', icon: 'soundcloud', + }, - handle: /([^/]*)\/?$/, + { + match: {domain: 'soundcloud.com'}, + platform: 'soundcloud', + icon: 'soundcloud', }, { - match: {domain: 'spotify.com'}, + match: {domains: ['spotify.com', 'open.spotify.com']}, platform: 'spotify', - icon: 'globe', + icon: 'spotify', + }, + + { + match: {domain: 'tiktok.com'}, + + platform: 'tiktok', + handle: {pathname: /^@?([^/]+)\/?$/}, + + icon: 'tiktok', + }, + + { + match: {domain: 'toyhou.se'}, + + platform: 'toyhouse', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'toyhouse', }, { match: {domain: '.tumblr.com'}, platform: 'tumblr', + handle: {domain: /^[^.]+/}, + + icon: 'tumblr', + }, + + { + match: {domain: 'tumblr.com'}, + + platform: 'tumblr', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'tumblr', + }, - compact: 'handle', + { + match: {domain: 'tumblr.com'}, + platform: 'tumblr', icon: 'tumblr', + }, - handle: {domain: /^[^.]*/}, + { + match: {domain: 'twitch.tv'}, + + platform: 'twitch', + handle: {pathname: /^(.+)\/?/}, + + icon: 'twitch', }, { match: {domain: 'twitter.com'}, platform: 'twitter', + handle: {pathname: /^@?([^/]+)\/?$/}, - compact: 'handle', icon: 'twitter', + }, - handle: { - prefix: '@', - pathname: /^@?([a-zA-Z0-9_]*)\/?$/, - }, + { + match: {domain: 'twitter.com'}, + platform: 'twitter', + icon: 'twitter', }, { - match: {domain: 'wikipedia.org'}, + match: {domain: 'web.archive.org'}, + platform: 'waybackMachine', + icon: 'internetArchive', + }, + + { + match: {domains: ['wikipedia.org', '.wikipedia.org']}, platform: 'wikipedia', icon: 'misc', }, { + match: {domain: 'youtube.com'}, + + platform: 'youtube', + handle: {pathname: /^@([^/]+)\/?$/}, + + icon: 'youtube', + }, + + { match: {domains: ['youtube.com', 'youtu.be']}, platform: 'youtube', icon: 'youtube', @@ -443,10 +671,30 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { } = {}) { const {domain, pathname, query} = urlParts(url); - const compareDomain = string => domain.includes(string); + const compareDomain = string => { + // A dot at the start of the descriptor's domain indicates + // we're looking to match a subdomain. + if (string.startsWith('.')) matchSubdomain: { + // "www" is never an acceptable subdomain for this purpose. + // Sorry to people whose usernames are www!! + if (domain.startsWith('www.')) { + return false; + } + + return domain.endsWith(string); + } + + // No dot means we're looking for an exact/full domain match. + // But let "www" pass here too, implicitly. + return domain === string || domain === 'www.' + string; + }; + const comparePathname = regex => regex.test(pathname.slice(1)); const compareQuery = regex => regex.test(query.slice(1)); + const compareExtractSpec = extract => + extractPartFromExternalLink(url, extract, {mode: 'test'}); + const contextArray = (Array.isArray(context) ? context @@ -454,33 +702,55 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { const matchingDescriptors = descriptors - .filter(({match}) => { - if (match.domain) return compareDomain(match.domain); - if (match.domains) return match.domains.some(compareDomain); - return false; - }) - .filter(({match}) => { - if (Array.isArray(match.context)) - return match.context.some(c => contextArray.includes(c)); - if (match.context) - return contextArray.includes(match.context); - return true; - }) - .filter(({match}) => { - if (match.pathname) return comparePathname(match.pathname); - if (match.pathnames) return match.pathnames.some(comparePathname); - return true; - }) - .filter(({match}) => { - if (match.query) return compareQuery(match.query); - if (match.queries) return match.quieries.some(compareQuery); - return true; - }); + .filter(({match}) => + (match.domain + ? compareDomain(match.domain) + : match.domains + ? match.domains.some(compareDomain) + : false)) + + .filter(({match}) => + (Array.isArray(match.context) + ? match.context.some(c => contextArray.includes(c)) + : match.context + ? contextArray.includes(match.context) + : true)) + + .filter(({match}) => + (match.pathname + ? comparePathname(match.pathname) + : match.pathnames + ? match.pathnames.some(comparePathname) + : true)) + + .filter(({match}) => + (match.query + ? compareQuery(match.query) + : match.queries + ? match.quieries.some(compareQuery) + : true)) + + .filter(({handle}) => + (handle + ? compareExtractSpec(handle) + : true)) + + .filter(({detail}) => + (typeof detail === 'object' + ? Object.entries(detail) + .filter(([key]) => key !== 'substring') + .map(([_key, value]) => value) + .every(compareExtractSpec) + : true)); return [...matchingDescriptors, fallbackDescriptor]; } -export function extractPartFromExternalLink(url, extract) { +export function extractPartFromExternalLink(url, extract, { + // Set to 'test' to just see if this would extract anything. + // This disables running custom transformations. + mode = 'extract', +} = {}) { const {domain, pathname, query} = urlParts(url); let regexen = []; @@ -556,15 +826,23 @@ export function extractPartFromExternalLink(url, extract) { })) { const match = test.match(regex); if (match) { - value = prefix + (match[1] ?? match[0]); + value = match[1] ?? match[0]; break; } } + if (mode === 'test') { + return !!value; + } + if (!value) { return null; } + if (prefix) { + value = prefix + value; + } + for (const fn of transform) { value = fn(value); } @@ -587,134 +865,77 @@ export function extractAllCustomPartsFromExternalLink(url, custom) { export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { const prefix = 'misc.external'; - function getPlatform() { - return language.$(prefix, descriptor.platform); - } - - function getDomain() { - return urlParts(url).domain; - } - - function getCustom() { - if (!descriptor.custom) { + function getDetail() { + if (!descriptor.detail) { return null; } - const customParts = - extractAllCustomPartsFromExternalLink(url, descriptor.custom); - - if (!customParts) { - return null; - } + if (typeof descriptor.detail === 'string') { + return language.$(prefix, descriptor.platform, descriptor.detail); + } else { + const {substring, ...rest} = descriptor.detail; - return language.$(prefix, descriptor.platform, descriptor.substring, customParts); - } + const opts = + withEntries(rest, entries => entries + .map(([key, value]) => [ + key, + extractPartFromExternalLink(url, value), + ])); - function getHandle() { - if (!descriptor.handle) { - return null; + return language.$(prefix, descriptor.platform, substring, opts); } - - return extractPartFromExternalLink(url, descriptor.handle); } - function getNormal() { - if (descriptor.custom) { - if (descriptor.normal === 'custom') { - return getCustom(); + switch (style) { + case 'platform': { + const platform = language.$(prefix, descriptor.platform); + const domain = urlParts(url).domain; + + if (descriptor === fallbackDescriptor) { + // The fallback descriptor has a "platform" which is just + // the word "External". This isn't really useful when you're + // looking for platform info! + if (domain) { + return language.sanitize(domain.replace(/^www\./, '')); + } else { + return platform; + } + } else if (descriptor.detail) { + return getDetail(); + } else if (descriptor.unusualDomain && domain) { + return language.$(prefix, 'withDomain', {platform, domain}); } else { - return null; + return platform; } } - if (descriptor.normal === 'domain') { - const platform = getPlatform(); - const domain = getDomain(); - - if (!platform || !domain) { - return null; - } - - return language.$(prefix, 'withDomain', {platform, domain}); - } - - if (descriptor.normal === 'handle') { - const platform = getPlatform(); - const handle = getHandle(); - - if (!platform || !handle) { - return null; - } - - return language.$(prefix, 'withHandle', {platform, handle}); - } - - return language.$(prefix, descriptor.platform, descriptor.substring); - } - - function getCompact() { - if (descriptor.custom) { - if (descriptor.compact === 'custom') { - return getCustom(); + case 'handle': { + if (descriptor.handle) { + return extractPartFromExternalLink(url, descriptor.handle); } else { return null; } } - if (descriptor.compact === 'domain') { - const domain = getDomain(); - - if (!domain) { + case 'icon-id': { + if (descriptor.icon) { + return descriptor.icon; + } else { return null; } - - return language.sanitize(domain.replace(/^www\./, '')); } - - if (descriptor.compact === 'handle') { - const handle = getHandle(); - - if (!handle) { - return null; - } - - return language.sanitize(handle); - } - } - - function getIconId() { - return descriptor.icon ?? null; - } - - switch (style) { - case 'normal': return getNormal(); - case 'compact': return getCompact(); - case 'platform': return getPlatform(); - case 'icon-id': return getIconId(); } } export function couldDescriptorSupportStyle(descriptor, style) { - if (style === 'normal') { - if (descriptor.custom) { - return descriptor.normal === 'custom'; - } else { - return true; - } - } - - if (style === 'compact') { - if (descriptor.custom) { - return descriptor.compact === 'custom'; - } else { - return !!descriptor.compact; - } - } - if (style === 'platform') { return true; } + if (style === 'handle') { + return !!descriptor.handle; + } + if (style === 'icon-id') { return !!descriptor.icon; } @@ -744,15 +965,13 @@ export function getExternalLinkStringOfStyleFromDescriptors(url, style, descript } export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) { - const getStyle = style => - getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); - - return { - 'normal': getStyle('normal'), - 'compact': getStyle('compact'), - 'platform': getStyle('platform'), - 'icon-id': getStyle('icon-id'), - }; + return ( + Object.fromEntries( + externalLinkStyles.map(style => + getExternalLinkStringOfStyleFromDescriptor( + url, + style, + descriptor, {language})))); } export function getExternalLinkStringsFromDescriptors(url, descriptors, { diff --git a/src/util/html.js b/src/util/html.js index b8dea51..9e07f9b 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -73,6 +73,15 @@ export const joinChildren = Symbol(); // or when there are multiple children. export const noEdgeWhitespace = Symbol(); +// Pass as a value on an object-shaped set of attributes to indicate that it's +// always, absolutely, no matter what, a valid attribute addition. It will be +// completely exempt from validation, which may provide a significant speed +// boost IF THIS OPERATION IS REPEATED MANY TENS OF THOUSANDS OF TIMES. +// Basically, don't use this unless you're 1) providing a constant set of +// attributes, and 2) writing a very basic building block which loads of other +// content will build off of! +export const blessAttributes = Symbol(); + // Don't pass this directly, use html.metatag('blockwrap') instead. // Causes *following* content (past the metatag) to be placed inside a span // which is styled 'inline-block', which ensures that the words inside the @@ -373,13 +382,12 @@ export class Tag { throw new Error(`Tag <${this.tagName}> is self-closing but got content`); } - let contentArray; - - if (Array.isArray(value)) { - contentArray = value; - } else { - contentArray = [value]; - } + const contentArray = + (Array.isArray(value) + ? value.flat(Infinity).filter(Boolean) + : value + ? [value] + : []); if (this.chunkwrap) { if (contentArray.some(content => content?.blockwrap)) { @@ -387,10 +395,7 @@ export class Tag { } } - this.#content = contentArray - .flat(Infinity) - .filter(Boolean); - + this.#content = contentArray; this.#content.toString = () => this.#stringifyContent(); } @@ -697,7 +702,7 @@ export class Tag { if (index === 0) { content += chunk; } else { - const whitespace = chunk.match(/^\s+/); + const whitespace = chunk.match(/^\s+/) ?? ''; content += chunkwrapSplitter; content += '</span>'; content += whitespace; @@ -994,6 +999,12 @@ export class Attributes { } } + with(...args) { + const clone = this.clone(); + clone.add(...args); + return clone; + } + #addMultipleAttributes(attributes) { const flatInputAttributes = [attributes].flat(Infinity).filter(Boolean); @@ -1007,6 +1018,8 @@ export class Attributes { const setResults = {}; for (const key of Reflect.ownKeys(set)) { + if (key === blessAttributes) continue; + const value = set[key]; setResults[key] = this.#addOneAttribute(key, value); } @@ -1088,8 +1101,17 @@ export class Attributes { return this.#attributes[attribute]; } - has(attribute) { - return attribute in this.#attributes; + has(attribute, pattern) { + if (typeof pattern === 'undefined') { + return attribute in this.#attributes; + } else if (this.has(attribute)) { + const value = this.get(attribute); + if (Array.isArray(value)) { + return value.includes(pattern); + } else { + return value === pattern; + } + } } remove(attribute) { @@ -1325,6 +1347,22 @@ export function smush(smushee) { return smush(Tag.normalize(smushee)); } +// Much gentler version of smush - this only flattens nested html.tags(), and +// guarantees the result is itself an html.tags(). It doesn't manipulate text +// content, and it doesn't resolve templates. +export function smooth(smoothie) { + // Helper function to avoid intermediate html.tags() calls. + function helper(tag) { + if (tag instanceof Tag && tag.contentOnly) { + return tag.content.flatMap(helper); + } else { + return tag; + } + } + + return tags(helper(smoothie)); +} + export function template(description) { return new Template(description); } @@ -1546,14 +1584,16 @@ export class Template { return true; } - if ('validate' in description) { + if (Object.hasOwn(description, 'validate')) { description.validate({ ...commonValidators, ...validators, })(value); + + return true; } - if ('type' in description) { + if (Object.hasOwn(description, 'type')) { switch (description.type) { case 'html': { return isHTML(value); @@ -1564,14 +1604,14 @@ export class Template { } case 'string': { + if (typeof value === 'string') + return true; + // Tags and templates are valid in string arguments - they'll be // stringified when exposed to the description's .content() function. if (value instanceof Tag || value instanceof Template) return true; - if (typeof value !== 'string') - throw new TypeError(`Slot expects string, got ${typeof value}`); - return true; } @@ -1815,9 +1855,29 @@ export const isAttributesAdditionPair = pair => { return true; }; -export const isAttributesAdditionSinglet = +const isAttributesAdditionSingletHelper = anyOf( validateInstanceOf(Template), validateInstanceOf(Attributes), validateAllPropertyValues(isAttributeValue), looseArrayOf(value => isAttributesAdditionSinglet(value))); + +export const isAttributesAdditionSinglet = (value) => { + if (typeof value === 'object' && value !== null) { + if (Object.hasOwn(value, blessAttributes)) { + return true; + } + + if ( + Array.isArray(value) && + value.length === 1 && + typeof value[0] === 'object' && + value[0] !== null && + Object.hasOwn(value[0], blessAttributes) + ) { + return true; + } + } + + return isAttributesAdditionSingletHelper(value); +}; diff --git a/src/util/replacer.js b/src/util/replacer.js index 0a3117e..d1b0a26 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -5,6 +5,8 @@ // function, which converts nodes parsed here into actual HTML, links, etc // for embedding in a wiki webpage. +import * as marked from 'marked'; + import * as html from '#html'; import {escapeRegex, typeAppearance} from '#sugar'; @@ -460,7 +462,14 @@ export function postprocessImages(inputNodes) { let match = null, parseFrom = 0; while (match = imageRegexp.exec(node.data)) { const previousText = node.data.slice(parseFrom, match.index); - outputNodes.push({type: 'text', data: previousText}); + + outputNodes.push({ + type: 'text', + data: previousText, + i: node.i + parseFrom, + iEnd: node.i + parseFrom + match.index, + }); + parseFrom = match.index + match[0].length; const imageNode = {type: 'image'}; @@ -532,6 +541,8 @@ export function postprocessImages(inputNodes) { outputNodes.push({ type: 'text', data: node.data.slice(parseFrom), + i: node.i + parseFrom, + iEnd: node.iEnd, }); } @@ -574,7 +585,78 @@ export function postprocessHeadings(inputNodes) { textContent += node.data.slice(parseFrom); } - outputNodes.push({type: 'text', data: textContent}); + outputNodes.push({ + type: 'text', + data: textContent, + i: node.i, + iEnd: node.iEnd, + }); + } + + return outputNodes; +} + +export function postprocessExternalLinks(inputNodes) { + const outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + const plausibleLinkRegexp = /\[.*?\)/g; + + let textContent = ''; + + let plausibleMatch = null, parseFrom = 0; + while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) { + textContent += node.data.slice(parseFrom, plausibleMatch.index); + + // Pedantic rules use more particular parentheses detection in link + // destinations - they allow one level of balanced parentheses, and + // otherwise, parentheses must be escaped. This allows for entire links + // to be wrapped in parentheses, e.g below: + // + // This is so cool. ([You know??](https://example.com)) + // + const definiteMatch = + marked.Lexer.rules.inline.pedantic.link + .exec(node.data.slice(plausibleMatch.index)); + + if (definiteMatch) { + const {1: label, 2: href} = definiteMatch; + + // Split the containing text node into two - the second of these will + // be added after iterating over matches, or by the next match. + if (textContent.length) { + outputNodes.push({type: 'text', data: textContent}); + textContent = ''; + } + + const offset = plausibleMatch.index + definiteMatch.index; + const length = definiteMatch[0].length; + + outputNodes.push({ + i: node.i + offset, + iEnd: node.i + offset + length, + type: 'external-link', + data: {label, href}, + }); + + parseFrom = offset + length; + } else { + parseFrom = plausibleMatch.index; + } + } + + if (parseFrom !== node.data.length) { + textContent += node.data.slice(parseFrom); + } + + if (textContent.length) { + outputNodes.push({type: 'text', data: textContent}); + } } return outputNodes; @@ -589,6 +671,7 @@ export function parseInput(input) { let output = parseNodes(input, 0); output = postprocessImages(output); output = postprocessHeadings(output); + output = postprocessExternalLinks(output); return output; } catch (errorNode) { if (errorNode.type !== 'error') { diff --git a/src/util/serialize.js b/src/util/serialize.js index 4992e2b..eb18a75 100644 --- a/src/util/serialize.js +++ b/src/util/serialize.js @@ -14,10 +14,10 @@ export function serializeLink(thing) { } export function serializeContribs(contribs) { - return contribs.map(({who, what}) => { + return contribs.map(({artist, annotation}) => { const ret = {}; - ret.artist = serializeLink(who); - if (what) ret.contribution = what; + ret.artist = serializeLink(artist); + if (annotation) ret.contribution = annotation; return ret; }); } |