From 16286da93ad64ab3d944d02bb9faa7a7310e0ce1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Jan 2025 17:16:25 -0400 Subject: move some modules out of util, data --- src/util/external-links.js | 1024 ---------------------- src/util/html.js | 2017 -------------------------------------------- src/util/node-utils.js | 102 --- src/util/replacer.js | 852 ------------------- 4 files changed, 3995 deletions(-) delete mode 100644 src/util/external-links.js delete mode 100644 src/util/html.js delete mode 100644 src/util/node-utils.js delete mode 100644 src/util/replacer.js (limited to 'src/util') diff --git a/src/util/external-links.js b/src/util/external-links.js deleted file mode 100644 index 43c09265..00000000 --- a/src/util/external-links.js +++ /dev/null @@ -1,1024 +0,0 @@ -import {empty, stitchArrays, withEntries} from '#sugar'; - -import { - anyOf, - is, - isBoolean, - isObject, - isStringNonEmpty, - looseArrayOf, - optional, - validateAllPropertyValues, - validateArrayItems, - validateInstanceOf, - validateProperties, -} from '#validators'; - -export const externalLinkStyles = [ - 'platform', - 'handle', - 'icon-id', -]; - -export const isExternalLinkStyle = is(...externalLinkStyles); - -export const externalLinkContexts = [ - 'album', - 'albumOneTrack', - 'albumMultipleTracks', - 'albumNoTracks', - 'artist', - 'flash', - 'generic', - 'group', - 'track', -]; - -export const isExternalLinkContext = - anyOf( - is(...externalLinkContexts), - looseArrayOf(is(...externalLinkContexts))); - -// This might need to be adjusted for YAML importing... -const isRegExp = - validateInstanceOf(RegExp); - -export const isExternalLinkTransformCommand = - is(...[ - 'decode-uri', - 'find-replace', - ]); - -export const isExternalLinkTransformSpec = - anyOf( - isExternalLinkTransformCommand, - validateProperties({ - [validateProperties.allowOtherKeys]: true, - command: isExternalLinkTransformCommand, - })); - -export const isExternalLinkExtractSpec = - validateProperties({ - prefix: optional(isStringNonEmpty), - transform: optional(validateArrayItems(isExternalLinkTransformSpec)), - url: optional(isRegExp), - domain: optional(isRegExp), - pathname: optional(isRegExp), - query: optional(isRegExp), - }); - -export const isExternalLinkSpec = - validateArrayItems( - validateProperties({ - match: validateProperties({ - // TODO: Don't allow providing both of these, and require providing one - domain: optional(isStringNonEmpty), - domains: optional(validateArrayItems(isStringNonEmpty)), - - // TODO: Don't allow providing both of these - pathname: optional(isRegExp), - pathnames: optional(validateArrayItems(isRegExp)), - - // TODO: Don't allow providing both of these - query: optional(isRegExp), - queries: optional(validateArrayItems(isRegExp)), - - context: optional(isExternalLinkContext), - }), - - platform: isStringNonEmpty, - - handle: optional(isExternalLinkExtractSpec), - - detail: - optional(anyOf( - isStringNonEmpty, - validateProperties({ - [validateProperties.validateOtherKeys]: - isExternalLinkExtractSpec, - - substring: isStringNonEmpty, - }))), - - unusualDomain: optional(isBoolean), - - icon: optional(isStringNonEmpty), - })); - -export const fallbackDescriptor = { - platform: 'external', - icon: 'globe', -}; - -// TODO: Define all this stuff in data as YAML! -export const externalLinkSpec = [ - // Special handling for album links - - { - match: { - context: 'album', - domain: 'youtube.com', - pathname: /^playlist/, - }, - - platform: 'youtube', - detail: 'playlist', - - icon: 'youtube', - }, - - { - match: { - context: 'albumMultipleTracks', - domain: 'youtube.com', - pathname: /^watch/, - }, - - platform: 'youtube', - detail: 'fullAlbum', - - icon: 'youtube', - }, - - { - match: { - context: 'albumMultipleTracks', - domain: 'youtu.be', - }, - - platform: 'youtube', - detail: 'fullAlbum', - - icon: 'youtube', - }, - - // Special handling for flash links - - { - match: { - context: 'flash', - domain: 'bgreco.net', - }, - - platform: 'bgreco', - detail: 'flash', - - icon: 'globe', - }, - - // This takes precedence over the secretPage match below. - { - match: { - context: 'flash', - domain: 'homestuck.com', - }, - - platform: 'homestuck', - - detail: { - substring: 'page', - page: {pathname: /^story\/([0-9]+)\/?$/,}, - }, - - icon: 'globe', - }, - - { - match: { - context: 'flash', - domain: 'homestuck.com', - pathname: /^story\/.+\/?$/, - }, - - platform: 'homestuck', - detail: 'secretPage', - - icon: 'globe', - }, - - { - match: { - context: 'flash', - domains: ['youtube.com', 'youtu.be'], - }, - - platform: 'youtube', - detail: 'flash', - - icon: 'youtube', - }, - - // 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, - - icon: 'bandcamp', - }, - - { - match: {domain: '.bandcamp.com'}, - - platform: 'bandcamp', - handle: {domain: /^[^.]+/}, - - icon: 'bandcamp', - }, - - { - match: {domain: 'bsky.app'}, - - platform: 'bluesky', - handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/}, - - icon: 'bluesky', - }, - - { - match: {domain: '.carrd.co'}, - - platform: 'carrd', - handle: {domain: /^[^.]+/}, - - icon: 'carrd', - }, - - { - match: {domain: 'cohost.org'}, - - platform: 'cohost', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'cohost', - }, - - { - match: {domain: 'music.deconreconstruction.com'}, - platform: 'deconreconstruction.music', - icon: 'globe', - }, - - { - match: {domain: 'deconreconstruction.com'}, - platform: 'deconreconstruction', - icon: 'globe', - }, - - { - match: {domain: '.deviantart.com'}, - - platform: 'deviantart', - handle: {domain: /^[^.]+/}, - - icon: 'deviantart', - }, - - { - match: {domain: 'deviantart.com'}, - - platform: 'deviantart', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'deviantart', - }, - - { - match: {domain: 'deviantart.com'}, - platform: 'deviantart', - icon: 'deviantart', - }, - - { - match: {domain: 'facebook.com'}, - - platform: 'facebook', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'facebook', - }, - - { - match: {domain: 'facebook.com'}, - - platform: 'facebook', - handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/}, - - icon: 'facebook', - }, - - { - match: {domain: 'facebook.com'}, - platform: 'facebook', - icon: 'facebook', - }, - - { - match: {domain: 'm.nintendo.com'}, - - platform: 'nintendoMusic', - - icon: 'nintendoMusic', - }, - - { - match: {domain: 'mspaintadventures.fandom.com'}, - - platform: 'fandom.mspaintadventures', - - detail: { - substring: 'page', - page: { - pathname: /^wiki\/(.+)\/?$/, - transform: [ - {command: 'decode-uri'}, - {command: 'find-replace', find: /_/g, replace: ' '}, - ], - }, - }, - - icon: 'globe', - }, - - { - match: {domain: 'mspaintadventures.fandom.com'}, - - platform: 'fandom.mspaintadventures', - - icon: 'globe', - }, - - { - 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: 'hsmusic', - icon: 'globe', - }, - - { - match: {domain: 'instagram.com'}, - - platform: 'instagram', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'instagram', - }, - - { - 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, - - 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', - }, - - { - match: {domain: 'patreon.com'}, - - platform: 'patreon', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'patreon', - }, - - { - match: {domain: 'patreon.com'}, - platform: 'patreon', - icon: 'patreon', - }, - - { - match: {domain: 'poetryfoundation.org'}, - platform: 'poetryFoundation', - icon: 'globe', - }, - - { - match: {domain: 'soundcloud.com'}, - - platform: 'soundcloud', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'soundcloud', - }, - - { - match: {domain: 'soundcloud.com'}, - platform: 'soundcloud', - icon: 'soundcloud', - }, - - { - match: {domains: ['spotify.com', 'open.spotify.com']}, - platform: 'spotify', - icon: 'spotify', - }, - - { - match: {domains: ['store.steampowered.com', 'steamcommunity.com']}, - platform: 'steam', - icon: 'steam', - }, - - { - 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', - }, - - { - match: {domain: 'tumblr.com'}, - platform: 'tumblr', - icon: 'tumblr', - }, - - { - match: {domain: 'twitch.tv'}, - - platform: 'twitch', - handle: {pathname: /^(.+)\/?/}, - - icon: 'twitch', - }, - - { - match: {domain: 'twitter.com'}, - - platform: 'twitter', - handle: {pathname: /^@?([^/]+)\/?$/}, - - icon: 'twitter', - }, - - { - match: {domain: 'twitter.com'}, - platform: 'twitter', - icon: 'twitter', - }, - - { - 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', - }, -]; - -function urlParts(url) { - const { - hostname: domain, - pathname, - search: query, - } = new URL(url); - - return {domain, pathname, query}; -} - -function createEmptyResults() { - return Object.fromEntries(externalLinkStyles.map(style => [style, null])); -} - -export function getMatchingDescriptorsForExternalLink(url, descriptors, { - context = 'generic', -} = {}) { - const {domain, pathname, query} = urlParts(url); - - 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 - : [context]).filter(Boolean); - - const matchingDescriptors = - descriptors - .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, { - // 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 = []; - let tests = []; - let transform = []; - let prefix = ''; - - if (extract instanceof RegExp) { - regexen.push(extract); - tests.push(url); - } else { - for (const [key, value] of Object.entries(extract)) { - switch (key) { - case 'prefix': - prefix = value; - continue; - - case 'transform': - for (const entry of value) { - const command = - (typeof entry === 'string' - ? command - : entry.command); - - const options = - (typeof entry === 'string' - ? {} - : entry); - - switch (command) { - case 'decode-uri': - transform.push(value => - decodeURIComponent(value)); - break; - - case 'find-replace': - transform.push(value => - value.replace(options.find, options.replace)); - break; - } - } - continue; - - case 'url': - tests.push(url); - break; - - case 'domain': - tests.push(domain); - break; - - case 'pathname': - tests.push(pathname.slice(1)); - break; - - case 'query': - tests.push(query.slice(1)); - break; - - default: - tests.push(''); - break; - } - - regexen.push(value); - } - } - - let value; - for (const {regex, test} of stitchArrays({ - regex: regexen, - test: tests, - })) { - const match = test.match(regex); - if (match) { - 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); - } - - return value; -} - -export function extractAllCustomPartsFromExternalLink(url, custom) { - const customParts = {}; - - // All or nothing: if one part doesn't match, all results are scrapped. - for (const [key, value] of Object.entries(custom)) { - customParts[key] = extractPartFromExternalLink(url, value); - if (!customParts[key]) return null; - } - - return customParts; -} - -export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { - const prefix = 'misc.external'; - - function getDetail() { - if (!descriptor.detail) { - return null; - } - - if (typeof descriptor.detail === 'string') { - return language.$(prefix, descriptor.platform, descriptor.detail); - } else { - const {substring, ...rest} = descriptor.detail; - - const opts = - withEntries(rest, entries => entries - .map(([key, value]) => [ - key, - extractPartFromExternalLink(url, value), - ])); - - return language.$(prefix, descriptor.platform, substring, opts); - } - } - - 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 platform; - } - } - - case 'handle': { - if (descriptor.handle) { - return extractPartFromExternalLink(url, descriptor.handle); - } else { - return null; - } - } - - case 'icon-id': { - if (descriptor.icon) { - return descriptor.icon; - } else { - return null; - } - } - } -} - -export function couldDescriptorSupportStyle(descriptor, style) { - if (style === 'platform') { - return true; - } - - if (style === 'handle') { - return !!descriptor.handle; - } - - if (style === 'icon-id') { - return !!descriptor.icon; - } -} - -export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { - language, - context = 'generic', -}) { - const matchingDescriptors = - getMatchingDescriptorsForExternalLink(url, descriptors, {context}); - - const styleFilteredDescriptors = - matchingDescriptors.filter(descriptor => - couldDescriptorSupportStyle(descriptor, style)); - - for (const descriptor of styleFilteredDescriptors) { - const descriptorResult = - getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); - - if (descriptorResult) { - return descriptorResult; - } - } - - return null; -} - -export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) { - return ( - Object.fromEntries( - externalLinkStyles.map(style => - getExternalLinkStringOfStyleFromDescriptor( - url, - style, - descriptor, {language})))); -} - -export function getExternalLinkStringsFromDescriptors(url, descriptors, { - language, - context = 'generic', -}) { - const results = createEmptyResults(); - const remainingKeys = new Set(Object.keys(results)); - - const matchingDescriptors = - getMatchingDescriptorsForExternalLink(url, descriptors, {context}); - - for (const descriptor of matchingDescriptors) { - const descriptorResults = - getExternalLinkStringsFromDescriptor(url, descriptor, {language}); - - const descriptorKeys = - new Set( - Object.entries(descriptorResults) - .filter(entry => entry[1]) - .map(entry => entry[0])); - - for (const key of remainingKeys) { - if (descriptorKeys.has(key)) { - results[key] = descriptorResults[key]; - remainingKeys.delete(key); - } - } - - if (empty(remainingKeys)) { - return results; - } - } - - return results; -} diff --git a/src/util/html.js b/src/util/html.js deleted file mode 100644 index 0fe424df..00000000 --- a/src/util/html.js +++ /dev/null @@ -1,2017 +0,0 @@ -// Some really, really simple functions for formatting HTML content. - -import {inspect} from 'node:util'; - -import {withAggregate} from '#aggregate'; -import {colors} from '#cli'; -import {empty, typeAppearance, unique} from '#sugar'; -import * as commonValidators from '#validators'; - -const { - anyOf, - is, - isArray, - isBoolean, - isNumber, - isString, - isSymbol, - looseArrayOf, - validateAllPropertyValues, - validateArrayItems, - validateInstanceOf, -} = commonValidators; - -// COMPREHENSIVE! -// https://html.spec.whatwg.org/multipage/syntax.html#void-elements -export const selfClosingTags = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'source', - 'track', - 'wbr', -]; - -// Not so comprehensive!! -export const attributeSpec = { - 'class': { - arraylike: true, - join: ' ', - unique: true, - }, - - 'style': { - arraylike: true, - join: '; ', - }, -}; - -// Pass to tag() as an attributes key to make tag() return a 8lank tag if the -// provided content is empty. Useful for when you'll only 8e showing an element -// according to the presence of content that would 8elong there. -export const onlyIfContent = Symbol(); - -// Pass to tag() as an attributes key to make tag() return a blank tag if -// this tag doesn't get shown beside any siblings! (I.e, siblings who don't -// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank, -// tags with [html.onlyIfSiblings] never make the difference in counting as -// content for [html.onlyIfContent]. Useful for and such. -export const onlyIfSiblings = Symbol(); - -// Pass to tag() as an attributes key to make children be joined together by the -// provided string. This is handy, for example, for joining lines by
tags, -// or putting some other divider between each child. Note this will only have an -// effect if the tag content is passed as an array of children and not a single -// string. -export const joinChildren = Symbol(); - -// Pass to tag() as an attributes key to prevent additional whitespace from -// being added to the inner start and end of the tag's content - basically, -// ensuring that the start of the content begins immediately after the ">" -// ending the opening tag, and ends immediately before the "<" at the start of -// the closing tag. This has effect when a single child spans multiple lines, -// 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 -// metatag all stay together, line-breaking only if needed, and following -// text is displayed immediately after the last character of the last line of -// the metatag (provided there's room on that line for the following word or -// character). -export const blockwrap = Symbol(); - -// Don't pass this directly, use html.metatag('chunkwrap') instead. -// Causes *contained* content to be split by the metatag's "split" attribute, -// and each chunk to be considered its own unit for word wrapping. All these -// units are *not* wrapped in any containing element, so only the chunks are -// considered wrappable units, not the entire element! -export const chunkwrap = Symbol(); - -// Don't pass this directly, use html.metatag('imaginary-sibling') instead. -// A tag without any content, which is completely ignored when serializing, -// but makes siblings with [onlyIfSiblings] feel less shy and show up on -// their own, even without a non-blank (and non-onlyIfSiblings) sibling. -export const imaginarySibling = Symbol(); - -// Recursive helper function for isBlank, which basically flattens an array -// and returns as soon as it finds any content - a non-blank case - and doesn't -// traverse templates of its own accord. If it doesn't find directly non-blank -// content nor any templates, it returns true; if it saw templates, but no -// other content, then those templates are returned in a flat array, to be -// traversed externally. -function isBlankArrayHelper(content) { - // First look for string items. These are the easiest to - // test blankness. - - const nonStringContent = []; - - for (const item of content) { - if (typeof item === 'string') { - if (item.length > 0) { - return false; - } - } else { - nonStringContent.push(item); - } - } - - // Analyze the content more closely. Put arrays (and - // content of tags marked onlyIfContent) into one array, - // and templates into another. And if there's anything - // else, that's a non-blank condition we'll detect now. - // We'll flat-out skip items marked onlyIfSiblings, - // since they could never count as content alone - // (some other item will have to count). - - const arrayContent = []; - const templateContent = []; - - for (const item of nonStringContent) { - if (item instanceof Tag) { - if (item.onlyIfSiblings) { - continue; - } else if (item.onlyIfContent || item.contentOnly) { - arrayContent.push(item.content); - } else { - return false; - } - } else if (Array.isArray(item)) { - arrayContent.push(item); - } else if (item instanceof Template) { - templateContent.push(item); - } else { - return false; - } - } - - // Iterate over arrays and tag content recursively. - // The result will always be true/false (blank or not), - // or an array of templates. Defer accessing templates - // until later - we'll check on them from the outside - // end only if nothing else matches. - - for (const item of arrayContent) { - const result = isBlankArrayHelper(item); - if (result === false) { - return false; - } else if (Array.isArray(result)) { - templateContent.push(...result); - } - } - - // Return templates, if there are any. We don't actually - // handle the base case of evaluating these templates - // inside this recursive function - the topmost caller - // will handle that. - - if (!empty(templateContent)) { - return templateContent; - } - - // If there weren't any templates found (as direct or - // indirect descendants), then we're good to go! - // This content is definitely blank. - - return true; -} - -// Checks if the content provided would be represented as nothing if included -// on a page. This can be used on its own, and is the underlying "interface" -// layer for specific classes' `blank` getters, so its definition and usage -// tend to be recursive. -// -// Note that this shouldn't be used to infer anything about non-content values -// (e.g. attributes) - it's only suited for actual page content. -export function isBlank(content) { - if (typeof content === 'string') { - return content.length === 0; - } - - if (content instanceof Tag || content instanceof Template) { - return content.blank; - } - - if (Array.isArray(content)) { - const result = isBlankArrayHelper(content); - - // If the result is true or false, the helper came to - // a conclusive decision on its own. - if (typeof result === 'boolean') { - return result; - } - - // Otherwise, it couldn't immediately find any content, - // but did come across templates that prospectively - // could include content. These need to be checked too. - // Check each of the templates one at a time. - for (const template of result) { - const content = template.content; - - if (content instanceof Tag && content.onlyIfSiblings) { - continue; - } - - if (isBlank(content)) { - continue; - } - - return false; - } - - // If none of the templates included content either, - // then there really isn't any content to find in this - // tree at all. It's blank! - return true; - } - - return false; -} - -export const validators = { - isBlank(value) { - if (!isBlank(value)) { - throw new TypeError(`Expected blank content`); - } - - return true; - }, - - isTag(value) { - return isTag(value); - }, - - isTemplate(value) { - return isTemplate(value); - }, - - isHTML(value) { - return isHTML(value); - }, - - isAttributes(value) { - return isAttributesAdditionSinglet(value); - }, -}; - -export function blank() { - return []; -} - -export function blankAttributes() { - return new Attributes(); -} - -export function tag(tagName, ...args) { - const lastArg = args.at(-1); - - const lastArgIsAttributes = - typeof lastArg === 'object' && lastArg !== null && - !Array.isArray(lastArg) && - !(lastArg instanceof Tag) && - !(lastArg instanceof Template); - - const content = - (lastArgIsAttributes - ? null - : args.at(-1)); - - const attributes = - (lastArgIsAttributes - ? args - : args.slice(0, -1)); - - return new Tag(tagName, attributes, content); -} - -export function tags(content, ...attributes) { - return new Tag(null, attributes, content); -} - -export function metatag(identifier, ...args) { - let content; - let opts = {}; - - if ( - typeof args[0] === 'object' && - !(Array.isArray(args[0]) || - args[0] instanceof Tag || - args[0] instanceof Template) - ) { - opts = args[0]; - content = args[1]; - } else { - content = args[0]; - } - - switch (identifier) { - case 'blockwrap': - return new Tag(null, {[blockwrap]: true}, content); - - case 'chunkwrap': - return new Tag(null, {[chunkwrap]: true, ...opts}, content); - - case 'imaginary-sibling': - return new Tag(null, {[imaginarySibling]: true}, content); - - default: - throw new Error(`Unknown metatag "${identifier}"`); - } -} - -export function normalize(content) { - return Tag.normalize(content); -} - -export class Tag { - #tagName = ''; - #content = null; - #attributes = null; - - #traceError = null; - - constructor(tagName, attributes, content) { - this.tagName = tagName; - this.attributes = attributes; - this.content = content; - - this.#traceError = new Error(); - } - - clone() { - return Reflect.construct(this.constructor, [ - this.tagName, - this.attributes, - this.content, - ]); - } - - set tagName(value) { - if (value === undefined || value === null) { - this.tagName = ''; - return; - } - - if (typeof value !== 'string') { - throw new Error(`Expected tagName to be a string`); - } - - if (selfClosingTags.includes(value) && this.content.length) { - throw new Error(`Tag <${value}> is self-closing but this tag has content`); - } - - this.#tagName = value; - } - - get tagName() { - return this.#tagName; - } - - set attributes(attributes) { - if (attributes instanceof Attributes) { - this.#attributes = attributes; - } else { - this.#attributes = new Attributes(attributes); - } - } - - get attributes() { - if (this.#attributes === null) { - this.attributes = {}; - } - - return this.#attributes; - } - - set content(value) { - const contentful = - value !== null && - value !== undefined && - value && - (Array.isArray(value) - ? !empty(value.filter(Boolean)) - : true); - - if (this.selfClosing && contentful) { - throw new Error(`Tag <${this.tagName}> is self-closing but got content`); - } - - if (this.imaginarySibling && contentful) { - throw new Error(`html.metatag('imaginary-sibling') can't have content`); - } - - const contentArray = - (Array.isArray(value) - ? value.flat(Infinity).filter(Boolean) - : value - ? [value] - : []); - - if (this.chunkwrap) { - if (contentArray.some(content => content?.blockwrap)) { - throw new Error(`No support for blockwrap as a direct descendant of chunkwrap`); - } - } - - this.#content = contentArray; - this.#content.toString = () => this.#stringifyContent(); - } - - get content() { - if (this.#content === null) { - this.#content = []; - } - - return this.#content; - } - - get selfClosing() { - if (this.tagName) { - return selfClosingTags.includes(this.tagName); - } else { - return false; - } - } - - get blank() { - // Tags don't have a reference to their parent, so this only evinces - // something about this tag's own content or attributes. It does *not* - // account for [html.onlyIfSiblings]! - - if (this.imaginarySibling) { - return true; - } - - if (this.onlyIfContent && isBlank(this.content)) { - return true; - } - - if (this.contentOnly && isBlank(this.content)) { - return true; - } - - return false; - } - - get contentOnly() { - if (this.tagName !== '') return false; - if (this.chunkwrap) return true; - if (!this.attributes.blank) return false; - if (this.blockwrap) return false; - return true; - } - - #setAttributeFlag(attribute, value) { - if (value) { - this.attributes.set(attribute, true); - } else { - this.attributes.remove(attribute); - } - } - - #getAttributeFlag(attribute) { - return !!this.attributes.get(attribute); - } - - #setAttributeString(attribute, value) { - // Note: This function accepts and records the empty string ('') - // distinctly from null/undefined. - - if (value === undefined || value === null) { - this.attributes.remove(attribute); - return undefined; - } else { - this.attributes.set(attribute, String(value)); - } - } - - #getAttributeString(attribute) { - const value = this.attributes.get(attribute); - - if (value === undefined || value === null) { - return undefined; - } else { - return String(value); - } - } - - set onlyIfContent(value) { - this.#setAttributeFlag(onlyIfContent, value); - } - - get onlyIfContent() { - return this.#getAttributeFlag(onlyIfContent); - } - - set onlyIfSiblings(value) { - this.#setAttributeFlag(onlyIfSiblings, value); - } - - get onlyIfSiblings() { - return this.#getAttributeFlag(onlyIfSiblings); - } - - set joinChildren(value) { - this.#setAttributeString(joinChildren, value); - } - - get joinChildren() { - // A chunkwrap - which serves as the top layer of a smush() when - // stringifying that chunkwrap - is only meant to be an invisible - // layer, so its own children are never specially joined. - if (this.chunkwrap) { - return ''; - } - - return this.#getAttributeString(joinChildren); - } - - set noEdgeWhitespace(value) { - this.#setAttributeFlag(noEdgeWhitespace, value); - } - - get noEdgeWhitespace() { - return this.#getAttributeFlag(noEdgeWhitespace); - } - - set blockwrap(value) { - this.#setAttributeFlag(blockwrap, value); - } - - get blockwrap() { - return this.#getAttributeFlag(blockwrap); - } - - set chunkwrap(value) { - this.#setAttributeFlag(chunkwrap, value); - - try { - this.content = this.content; - } catch (error) { - this.#setAttributeFlag(chunkwrap, false); - throw error; - } - } - - get chunkwrap() { - return this.#getAttributeFlag(chunkwrap); - } - - set imaginarySibling(value) { - this.#setAttributeFlag(imaginarySibling, value); - - try { - this.content = this.content; - } catch (error) { - this.#setAttributeFlag(imaginarySibling, false); - } - } - - get imaginarySibling() { - return this.#getAttributeFlag(imaginarySibling); - } - - toString() { - if (this.onlyIfContent && isBlank(this.content)) { - return ''; - } - - const attributesString = this.attributes.toString(); - const contentString = this.content.toString(); - - if (!this.tagName) { - return contentString; - } - - const openTag = (attributesString - ? `<${this.tagName} ${attributesString}>` - : `<${this.tagName}>`); - - if (this.selfClosing) { - return openTag; - } - - const closeTag = ``; - - if (!this.content.length) { - return openTag + closeTag; - } - - if (!contentString.includes('\n')) { - return openTag + contentString + closeTag; - } - - const parts = [ - openTag, - contentString - .split('\n') - .map((line, i) => - (i === 0 && this.noEdgeWhitespace - ? line - : ' ' + line)) - .join('\n'), - closeTag, - ]; - - return parts.join( - (this.noEdgeWhitespace - ? '' - : '\n')); - } - - #getContentJoiner() { - if (this.joinChildren === undefined) { - return '\n'; - } - - if (this.joinChildren === '') { - return ''; - } - - return `\n${this.joinChildren}\n`; - } - - #stringifyContent() { - if (this.selfClosing) { - return ''; - } - - const joiner = this.#getContentJoiner(); - - let content = ''; - let blockwrapClosers = ''; - - let seenSiblingIndependentContent = false; - - const chunkwrapSplitter = - (this.chunkwrap - ? this.#getAttributeString('split') - : null); - - let seenChunkwrapSplitter = - (this.chunkwrap - ? false - : null); - - let contentItems; - - determineContentItems: { - if (this.chunkwrap) { - contentItems = smush(this).content; - break determineContentItems; - } - - contentItems = this.content; - } - - for (const [index, item] of contentItems.entries()) { - const nonTemplateItem = - Template.resolve(item); - - if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { - seenSiblingIndependentContent = true; - continue; - } - - let itemContent; - try { - itemContent = nonTemplateItem.toString(); - } catch (caughtError) { - const indexPart = colors.yellow(`child #${index + 1}`); - - const error = - new Error( - `Error in ${indexPart} ` + - `of ${inspect(this, {compact: true})}`, - {cause: caughtError}); - - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; - - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; - - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; - - throw error; - } - - if (!itemContent) { - continue; - } - - if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { - seenSiblingIndependentContent = true; - } - - const chunkwrapChunks = - (typeof nonTemplateItem === 'string' && chunkwrapSplitter - ? itemContent.split(chunkwrapSplitter) - : null); - - const itemIncludesChunkwrapSplit = - (chunkwrapChunks - ? chunkwrapChunks.length > 1 - : null); - - if (content) { - if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { - // The first time we see a chunkwrap splitter, backtrack and wrap - // the content *so far* in a chunk. This will be treated just like - // any other open chunkwrap, and closed after the first chunk of - // this item! (That means the existing content is part of the same - // chunk as the first chunk included in this content, which makes - // sense, because that first chink is really just more text that - // precedes the first split.) - content = `` + content; - } - - content += joiner; - } else if (itemIncludesChunkwrapSplit) { - // We've encountered a chunkwrap split before any other content. - // This means there's no content to wrap, no existing chunkwrap - // to close, and no reason to add a joiner, but we *do* need to - // enter a chunkwrap wrapper *now*, so the first chunk of this - // item will be properly wrapped. - content = ``; - } - - if (itemIncludesChunkwrapSplit) { - seenChunkwrapSplitter = true; - } - - // Blockwraps only apply if they actually contain some content whose - // words should be kept together, so it's okay to put them beneath the - // itemContent check. They also never apply at the very start of content, - // because at that point there aren't any preceding words from which the - // blockwrap would differentiate its content. - if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) { - content += ``; - blockwrapClosers += ``; - } - - appendItemContent: { - if (itemIncludesChunkwrapSplit) { - for (const [index, chunk] of chunkwrapChunks.entries()) { - if (index === 0) { - // The first chunk isn't actually a chunk all on its own, it's - // text that should be appended to the previous chunk. We will - // close this chunk as the first appended content as we process - // the next chunk. - content += chunk; - } else { - const whitespace = chunk.match(/^\s+/) ?? ''; - content += chunkwrapSplitter; - content += ''; - content += whitespace; - content += ''; - content += chunk.slice(whitespace.length); - } - } - - break appendItemContent; - } - - content += itemContent; - } - } - - // If we've only seen sibling-dependent content (or just no content), - // then the content in total is blank. - if (!seenSiblingIndependentContent) { - return ''; - } - - if (chunkwrapSplitter) { - if (seenChunkwrapSplitter) { - content += ''; - } else { - // Since chunkwraps take responsibility for wrapping *away* from the - // parent element, we generally always want there to be at least one - // chunk that gets wrapped as a single unit. So if no chunkwrap has - // been seen at all, just wrap everything in one now. - content = `${content}`; - } - } - - content += blockwrapClosers; - - return content; - } - - static normalize(content) { - // Normalizes contents that are valid from an `isHTML` perspective so - // that it's always a pure, single Tag object. - - if (content instanceof Template) { - return Tag.normalize(Template.resolve(content)); - } - - if (content instanceof Tag) { - return content; - } - - return new Tag(null, null, content); - } - - smush() { - if (!this.contentOnly) { - return tags([this]); - } - - const joiner = this.#getContentJoiner(); - - const result = []; - const attributes = {}; - - // Don't use built-in item joining, since we'll be handling it here - - // we need to account for descendants having custom joiners too, and - // simply using *this* tag's joiner would overwrite those descendants' - // differing joiners. - attributes[joinChildren] = ''; - - let workingText = ''; - - for (const item of this.content) { - const smushed = smush(item); - const smushedItems = smushed.content.slice(); - - if (empty(smushedItems)) { - continue; - } - - if (typeof smushedItems[0] === 'string') { - if (workingText) { - workingText += joiner; - } - - workingText += smushedItems.shift(); - } - - if (empty(smushedItems)) { - continue; - } - - if (workingText) { - result.push(workingText + joiner); - } else if (!empty(result)) { - result.push(joiner); - } - - if (typeof smushedItems.at(-1) === 'string') { - // The last smushed item already had its joiner processed from its own - // parent - this isn't an appropriate place for us to insert our own - // joiner. - workingText = smushedItems.pop(); - } else { - workingText = ''; - } - - result.push(...smushedItems); - } - - if (workingText) { - result.push(workingText); - } - - return new Tag(null, attributes, result); - } - - [inspect.custom](depth, opts) { - const lines = []; - - const niceAttributes = ['id', 'class']; - const attributes = blankAttributes(); - - for (const attribute of niceAttributes) { - if (this.attributes.has(attribute)) { - const value = this.attributes.get(attribute); - - if (!value) continue; - if (Array.isArray(value) && empty(value)) continue; - - let string; - let suffix = ''; - - if (Array.isArray(value)) { - string = value[0].toString(); - if (value.length > 1) { - suffix = ` (+${value.length - 1})`; - } - } else { - string = value.toString(); - } - - const trim = - (string.length > 15 - ? `${string.slice(0, 12)}...` - : string); - - attributes.set(attribute, trim + suffix); - } - } - - const attributesPart = - (attributes.blank - ? `` - : ` ${attributes.toString({color: true})}`); - - const tagNamePart = - (this.tagName - ? colors.bright(colors.blue(this.tagName)) - : ``); - - const tagPart = - (this.tagName - ? [ - `<`, - tagNamePart, - attributesPart, - (empty(this.content) ? ` />` : `>`), - ].join(``) - : ``); - - const accentText = - (this.tagName - ? (empty(this.content) - ? `` - : `(${this.content.length} items)`) - : (empty(this.content) - ? `(no name)` - : `(no name, ${this.content.length} items)`)); - - const accentPart = - (accentText - ? `${colors.dim(accentText)}` - : ``); - - const headingParts = [ - `Tag`, - tagPart, - accentPart, - ]; - - const heading = headingParts.filter(Boolean).join(` `); - - lines.push(heading); - - if (!opts.compact && (depth === null || depth >= 0)) { - const nextDepth = - (depth === null - ? null - : depth - 1); - - for (const child of this.content) { - const childLines = []; - - if (typeof child === 'string') { - const childFlat = child.replace(/\n/g, String.raw`\n`); - const childTrim = - (childFlat.length >= 40 - ? childFlat.slice(0, 37) + '...' - : childFlat); - - childLines.push( - ` Text: ${opts.stylize(`"${childTrim}"`, 'string')}`); - } else { - childLines.push(... - inspect(child, {depth: nextDepth}) - .split('\n') - .map(line => ` ${line}`)); - } - - lines.push(...childLines); - } - } - - return lines.join('\n'); - } -} - -export function attributes(attributes) { - return new Attributes(attributes); -} - -export function parseAttributes(string) { - return Attributes.parse(string); -} - -export class Attributes { - #attributes = Object.create(null); - - constructor(attributes) { - this.attributes = attributes; - } - - clone() { - return new Attributes(this); - } - - set attributes(value) { - this.#attributes = Object.create(null); - - if (value === undefined || value === null) { - return; - } - - this.add(value); - } - - get attributes() { - return this.#attributes; - } - - get blank() { - const keepAnyAttributes = - Object.entries(this.attributes).some(([attribute, value]) => - this.#keepAttributeValue(attribute, value)); - - return !keepAnyAttributes; - } - - set(attribute, value) { - if (value instanceof Template) { - value = Template.resolve(value); - } - - if (Array.isArray(value)) { - value = value.flat(Infinity); - } - - if (value === null || value === undefined) { - this.remove(attribute); - } else { - this.#attributes[attribute] = value; - } - - return value; - } - - add(...args) { - switch (args.length) { - case 1: - isAttributesAdditionSinglet(args[0]); - return this.#addMultipleAttributes(args[0]); - - case 2: - isAttributesAdditionPair(args); - return this.#addOneAttribute(args[0], args[1]); - - default: - throw new Error( - `Expected array or object, or attribute and value`); - } - } - - with(...args) { - const clone = this.clone(); - clone.add(...args); - return clone; - } - - #addMultipleAttributes(attributes) { - const flatInputAttributes = - [attributes].flat(Infinity).filter(Boolean); - - const attributeSets = - flatInputAttributes.map(attributes => this.#getAttributeSet(attributes)); - - const resultList = []; - - for (const set of attributeSets) { - const setResults = {}; - - for (const key of Reflect.ownKeys(set)) { - if (key === blessAttributes) continue; - - const value = set[key]; - setResults[key] = this.#addOneAttribute(key, value); - } - - resultList.push(setResults); - } - - return resultList; - } - - #getAttributeSet(attributes) { - if (attributes instanceof Attributes) { - return attributes.attributes; - } - - if (attributes instanceof Template) { - const resolved = Template.resolve(attributes); - isAttributesAdditionSinglet(resolved); - return resolved; - } - - if (typeof attributes === 'object') { - return attributes; - } - - throw new Error( - `Expected Attributes, Template, or object, ` + - `got ${typeAppearance(attributes)}`); - } - - #addOneAttribute(attribute, value) { - if (value === null || value === undefined) { - return; - } - - if (value instanceof Template) { - return this.#addOneAttribute(attribute, Template.resolve(value)); - } - - if (Array.isArray(value)) { - value = value.flat(Infinity); - } - - if (!this.has(attribute)) { - return this.set(attribute, value); - } - - const descriptor = attributeSpec[attribute]; - const existingValue = this.get(attribute); - - let newValue = value; - - if (descriptor?.arraylike) { - const valueArray = - (Array.isArray(value) - ? value - : [value]); - - const existingValueArray = - (Array.isArray(existingValue) - ? existingValue - : [existingValue]); - - newValue = existingValueArray.concat(valueArray); - - if (descriptor.unique) { - newValue = unique(newValue); - } - - if (newValue.length === 1) { - newValue = newValue[0]; - } - } - - return this.set(attribute, newValue); - } - - get(attribute) { - return this.#attributes[attribute]; - } - - 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) { - return delete this.#attributes[attribute]; - } - - push(attribute, ...values) { - const oldValue = this.get(attribute); - const newValue = - (Array.isArray(oldValue) - ? oldValue.concat(values) - : oldValue - ? [oldValue, ...values] - : values); - this.set(attribute, newValue); - return newValue; - } - - toString({color = false} = {}) { - const attributeKeyValues = - Object.entries(this.attributes) - .map(([key, value]) => - (this.#keepAttributeValue(key, value) - ? [key, this.#transformAttributeValue(key, value), true] - : [key, undefined, false])) - .filter(([_key, _value, keep]) => keep) - .map(([key, value]) => [key, value]); - - const attributeParts = - attributeKeyValues - .map(([key, value]) => { - const keyPart = key; - const escapedValue = this.#escapeAttributeValue(value); - const valuePart = - (color - ? colors.green(`"${escapedValue}"`) - : `"${escapedValue}"`); - - return ( - (typeof value === 'boolean' - ? `${keyPart}` - : `${keyPart}=${valuePart}`)); - }); - - return attributeParts.join(' '); - } - - #keepAttributeValue(attribute, value) { - switch (typeof value) { - case 'undefined': - return false; - - case 'object': - if (Array.isArray(value)) { - return value.some(Boolean); - } else if (value === null) { - return false; - } else { - // Other objects are an error. - break; - } - - case 'boolean': - return value; - - case 'string': - case 'number': - return true; - - case 'array': - return value.some(Boolean); - } - - throw new Error( - `Value for attribute "${attribute}" should be primitive or array, ` + - `got ${typeAppearance(value)}: ${inspect(value)}`); - } - - #transformAttributeValue(attribute, value) { - const descriptor = attributeSpec[attribute]; - - switch (typeof value) { - case 'boolean': - return value; - - case 'number': - return value.toString(); - - // If it's a kept object, it's an array. - case 'object': { - const joiner = - (descriptor?.arraylike && descriptor?.join) - ?? ' '; - - return value.filter(Boolean).join(joiner); - } - - default: - return value; - } - } - - #escapeAttributeValue(value) { - return value - .toString() - .replaceAll('"', '"') - .replaceAll("'", '''); - } - - static parse(string) { - const attributes = Object.create(null); - - const skipWhitespace = i => { - if (!/\s/.test(string[i])) { - return i; - } - - const match = string.slice(i).match(/[^\s]/); - if (match) { - return i + match.index; - } - - return string.length; - }; - - for (let i = 0; i < string.length; ) { - i = skipWhitespace(i); - const aStart = i; - const aEnd = i + string.slice(i).match(/[\s=]|$/).index; - const attribute = string.slice(aStart, aEnd); - i = skipWhitespace(aEnd); - if (string[i] === '=') { - i = skipWhitespace(i + 1); - let end, endOffset; - if (string[i] === '"' || string[i] === "'") { - end = string[i]; - endOffset = 1; - i++; - } else { - end = '\\s'; - endOffset = 0; - } - const vStart = i; - const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; - const value = string.slice(vStart, vEnd); - i = vEnd + endOffset; - attributes[attribute] = value; - } else { - attributes[attribute] = attribute; - } - } - - return ( - Reflect.construct(this, [ - Object.fromEntries( - Object.entries(attributes) - .map(([key, val]) => [ - key, - (val === 'true' - ? true - : val === 'false' - ? false - : val === key - ? true - : val), - ])), - ])); - } - - [inspect.custom]() { - const visiblePart = this.toString({color: true}); - - const numSymbols = Object.getOwnPropertySymbols(this.#attributes).length; - const numSymbolsPart = - (numSymbols >= 2 - ? `${numSymbols} symbol` - : numSymbols === 1 - ? `1 symbol` - : ``); - - const symbolPart = - (visiblePart && numSymbolsPart - ? `(+${numSymbolsPart})` - : numSymbols - ? `(${numSymbolsPart})` - : ``); - - const contentPart = - (visiblePart && symbolPart - ? `<${visiblePart} ${symbolPart}>` - : visiblePart || symbolPart - ? `<${visiblePart || symbolPart}>` - : ``); - - return `Attributes ${contentPart}`; - } -} - -export function resolve(tagOrTemplate, { - normalize = null, - slots = null, -} = {}) { - if (slots) { - return Template.resolveForSlots(tagOrTemplate, slots); - } else if (normalize === 'tag') { - return Tag.normalize(tagOrTemplate); - } else if (normalize === 'string') { - return Tag.normalize(tagOrTemplate).toString(); - } else if (normalize) { - throw new TypeError(`Expected normalize to be 'tag', 'string', or null`); - } else { - return Template.resolve(tagOrTemplate); - } -} - -export function smush(smushee) { - if ( - typeof smushee === 'string' || - typeof smushee === 'number' - ) { - return tags([smushee.toString()]); - } - - if (smushee instanceof Template) { - // Smushing is only really useful if the contents are resolved, because - // otherwise we can't actually inspect the boundaries. However, as usual - // for smushing, we don't care at all about the contents of tags (which - // aren't contentOnly) *within* the content we're smushing, so this won't - // for example smush a template nested within a *tag* within the contents - // of this template. - return smush(Template.resolve(smushee)); - } - - if (smushee instanceof Tag) { - return smushee.smush(); - } - - 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); -} - -export class Template { - #description = {}; - #slotValues = {}; - - constructor(description) { - if (!description[Stationery.validated]) { - Template.validateDescription(description); - } - - this.#description = description; - } - - clone() { - const clone = Reflect.construct(this.constructor, [ - this.#description, - ]); - - // getSlotValue(), called via #getReadySlotValues(), is responsible for - // preparing slot values for consumption, which includes cloning mutable - // html/attributes. We reuse that behavior here, in a recursive manner, - // so that clone() is effectively "deep" - slots that may be mutated are - // cloned, so that this template and its clones will never mutate the same - // identities. - clone.setSlots(this.#getReadySlotValues()); - - return clone; - } - - static validateDescription(description) { - if (typeof description !== 'object') { - throw new TypeError(`Expected object, got ${typeAppearance(description)}`); - } - - if (description === null) { - throw new TypeError(`Expected object, got null`); - } - - const topErrors = []; - - if (!('content' in description)) { - topErrors.push(new TypeError(`Expected description.content`)); - } else if (typeof description.content !== 'function') { - topErrors.push(new TypeError(`Expected description.content to be function`)); - } - - if ('annotation' in description) { - if (typeof description.annotation !== 'string') { - topErrors.push(new TypeError(`Expected annotation to be string`)); - } - } - - if ('slots' in description) validateSlots: { - if (typeof description.slots !== 'object') { - topErrors.push(new TypeError(`Expected description.slots to be object`)); - break validateSlots; - } - - try { - this.validateSlotsDescription(description.slots); - } catch (slotError) { - topErrors.push(slotError); - } - } - - if (!empty(topErrors)) { - throw new AggregateError(topErrors, - (typeof description.annotation === 'string' - ? `Errors validating template "${description.annotation}" description` - : `Errors validating template description`)); - } - - return true; - } - - static validateSlotsDescription(slots) { - const slotErrors = []; - - for (const [slotName, slotDescription] of Object.entries(slots)) { - if (typeof slotDescription !== 'object' || slotDescription === null) { - slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`)); - continue; - } - - if ('default' in slotDescription) validateDefault: { - if ( - slotDescription.default === undefined || - slotDescription.default === null - ) { - slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`)); - break validateDefault; - } - - try { - Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription); - } catch (error) { - error.message = `(${slotName}) Error validating slot default value: ${error.message}`; - slotErrors.push(error); - } - } - - if ('validate' in slotDescription && 'type' in slotDescription) { - slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`)); - } else if (!('validate' in slotDescription || 'type' in slotDescription)) { - slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`)); - } else if ('validate' in slotDescription) { - if (typeof slotDescription.validate !== 'function') { - slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`)); - } - } else if ('type' in slotDescription) { - const acceptableSlotTypes = [ - 'string', - 'number', - 'bigint', - 'boolean', - 'symbol', - 'html', - 'attributes', - ]; - - if (slotDescription.type === 'function') { - slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`)); - } else if (slotDescription.type === 'object') { - slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`)); - } else if ( - (slotDescription.type === 'html' || slotDescription.type === 'attributes') && - !('mutable' in slotDescription) - ) { - slotErrors.push(new TypeError(`(${slotName}) Specify mutable: true/false alongside type: ${slotDescription.type}`)); - } else if (!acceptableSlotTypes.includes(slotDescription.type)) { - slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); - } - } - - if ('mutable' in slotDescription) { - if (slotDescription.type !== 'html' && slotDescription.type !== 'attributes') { - slotErrors.push(new TypeError(`(${slotName}) Only specify mutable alongside type: html or attributes`)); - } - - if (typeof slotDescription.mutable !== 'boolean') { - slotErrors.push(new TypeError(`(${slotName}) Expected slot mutable to be boolean`)); - } - } - } - - if (!empty(slotErrors)) { - throw new AggregateError(slotErrors, `Errors in slot descriptions`); - } - - return true; - } - - slot(slotName, value) { - this.setSlot(slotName, value); - return this; - } - - slots(slotNamesToValues) { - this.setSlots(slotNamesToValues); - return this; - } - - setSlot(slotName, value) { - const description = this.#getSlotDescriptionOrError(slotName); - - try { - Template.validateSlotValueAgainstDescription(value, description); - } catch (error) { - error.message = - (this.description.annotation - ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}` - : `Error validating template slot "${slotName}" value: ${error.message}`); - throw error; - } - - this.#slotValues[slotName] = value; - } - - setSlots(slotNamesToValues) { - if ( - typeof slotNamesToValues !== 'object' || - Array.isArray(slotNamesToValues) || - slotNamesToValues === null - ) { - throw new TypeError(`Expected object mapping of slot names to values`); - } - - const slotErrors = []; - - for (const [slotName, value] of Object.entries(slotNamesToValues)) { - const description = this.#getSlotDescriptionNoError(slotName); - if (!description) { - slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`)); - continue; - } - - try { - Template.validateSlotValueAgainstDescription(value, description); - } catch (error) { - error.message = `(${slotName}) ${error.message}`; - slotErrors.push(error); - } - } - - if (!empty(slotErrors)) { - throw new AggregateError(slotErrors, - (this.description.annotation - ? `Error validating template "${this.description.annotation}" slots` - : `Error validating template slots`)); - } - - Object.assign(this.#slotValues, slotNamesToValues); - } - - static validateSlotValueAgainstDescription(value, description) { - if (value === undefined) { - throw new TypeError(`Specify value as null or don't specify at all`); - } - - // Null is always an acceptable slot value. - if (value === null) { - return true; - } - - if (Object.hasOwn(description, 'validate')) { - description.validate({ - ...commonValidators, - ...validators, - })(value); - - return true; - } - - if (Object.hasOwn(description, 'type')) { - switch (description.type) { - case 'html': { - return isHTML(value); - } - - case 'attributes': { - return isAttributesAdditionSinglet(value); - } - - 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; - - return true; - } - - default: { - if (typeof value !== description.type) - throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`); - - return true; - } - } - } - - return true; - } - - getSlotValue(slotName) { - const description = this.#getSlotDescriptionOrError(slotName); - const providedValue = this.#slotValues[slotName] ?? null; - - if (description.type === 'html') { - if (!providedValue) { - return blank(); - } - - if ( - (providedValue instanceof Tag || providedValue instanceof Template) && - description.mutable - ) { - return providedValue.clone(); - } - - return providedValue; - } - - if (description.type === 'attributes') { - if (!providedValue) { - return blankAttributes(); - } - - if (providedValue instanceof Attributes) { - if (description.mutable) { - return providedValue.clone(); - } else { - return providedValue; - } - } - - return new Attributes(providedValue); - } - - if (description.type === 'string') { - if (providedValue instanceof Tag || providedValue instanceof Template) { - return providedValue.toString(); - } - - if (isBlank(providedValue)) { - return null; - } - } - - if (providedValue !== null) { - return providedValue; - } - - if ('default' in description) { - return description.default; - } - - return null; - } - - getSlotDescription(slotName) { - return this.#getSlotDescriptionOrError(slotName); - } - - #getSlotDescriptionNoError(slotName) { - if (this.#description.slots) { - if (Object.hasOwn(this.#description.slots, slotName)) { - return this.#description.slots[slotName]; - } - } - - return null; - } - - #getSlotDescriptionOrError(slotName) { - const description = this.#getSlotDescriptionNoError(slotName); - - if (!description) { - throw new TypeError( - (this.description.annotation - ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot` - : `Template doesn't have a "${slotName}" slot`)); - } - - return description; - } - - #getReadySlotValues() { - const slots = {}; - - for (const slotName of Object.keys(this.description.slots ?? {})) { - slots[slotName] = this.getSlotValue(slotName); - } - - return slots; - } - - set content(_value) { - throw new Error(`Template content can't be changed after constructed`); - } - - get content() { - const slots = this.#getReadySlotValues(); - - try { - return this.description.content(slots); - } catch (caughtError) { - throw new Error( - `Error in content of ${inspect(this, {compact: true})}`, - {cause: caughtError}); - } - } - - set description(_value) { - throw new Error(`Template description can't be changed after constructed`); - } - - get description() { - return this.#description; - } - - get blank() { - return isBlank(this.content); - } - - toString() { - return this.content.toString(); - } - - static resolve(tagOrTemplate) { - // Flattens contents of a template, recursively "resolving" until a - // non-template is ready (or just returns a provided non-template - // argument as-is). - - if (!(tagOrTemplate instanceof Template)) { - return tagOrTemplate; - } - - let {content} = tagOrTemplate; - - while (content instanceof Template) { - content = content.content; - } - - return content; - } - - static resolveForSlots(tagOrTemplate, slots) { - if (!slots || typeof slots !== 'object') { - throw new Error( - `Expected slots to be an object or array, ` + - `got ${typeAppearance(slots)}`); - } - - if (!Array.isArray(slots)) { - return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots); - } - - while (tagOrTemplate && tagOrTemplate instanceof Template) { - try { - for (const slot of slots) { - tagOrTemplate.getSlotDescription(slot); - } - - return tagOrTemplate; - } catch { - tagOrTemplate = tagOrTemplate.content; - } - } - - throw new Error( - `Didn't find slots ${inspect(slots, {compact: true})} ` + - `resolving ${inspect(tagOrTemplate, {compact: true})}`); - } - - [inspect.custom]() { - const {annotation} = this.description; - - return ( - (annotation - ? `Template ${colors.bright(colors.blue(`"${annotation}"`))}` - : `Template ${colors.dim(`(no annotation)`)}`)); - } -} - -export function stationery(description) { - return new Stationery(description); -} - -export class Stationery { - #templateDescription = null; - - static validated = Symbol('Stationery.validated'); - - constructor(templateDescription) { - Template.validateDescription(templateDescription); - templateDescription[Stationery.validated] = true; - this.#templateDescription = templateDescription; - } - - template() { - return new Template(this.#templateDescription); - } - - [inspect.custom]() { - const {annotation} = this.description; - - return ( - (annotation - ? `Stationery ${colors.bright(colors.blue(`"${annotation}"`))}` - : `Stationery ${colors.dim(`(no annotation)`)}`)); - } -} - -export const isTag = - validateInstanceOf(Tag); - -export const isTemplate = - validateInstanceOf(Template); - -export const isArrayOfHTML = - validateArrayItems(value => isHTML(value)); - -export const isHTML = - anyOf( - is(null, undefined, false), - isString, - isTag, - isTemplate, - - value => { - isArray(value); - return value.length === 0; - }, - - isArrayOfHTML); - -export const isAttributeKey = - anyOf(isString, isSymbol); - -export const isAttributeValue = - anyOf( - isString, isNumber, isBoolean, isArray, - isTag, isTemplate, - validateArrayItems(item => isAttributeValue(item))); - -export const isAttributesAdditionPair = pair => { - isArray(pair); - - if (pair.length !== 2) { - throw new TypeError(`Expected attributes pair to have two items`); - } - - withAggregate({message: `Error validating attributes pair`}, ({push}) => { - try { - isAttributeKey(pair[0]); - } catch (caughtError) { - push(new Error(`Error validating key`, {cause: caughtError})); - } - - try { - isAttributeValue(pair[1]); - } catch (caughtError) { - push(new Error(`Error validating value`, {cause: caughtError})); - } - }); - - return true; -}; - -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/node-utils.js b/src/util/node-utils.js deleted file mode 100644 index 345d10aa..00000000 --- a/src/util/node-utils.js +++ /dev/null @@ -1,102 +0,0 @@ -// Utility functions which are only relevant to particular Node.js constructs. - -import {readdir, stat} from 'node:fs/promises'; -import * as path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -import _commandExists from 'command-exists'; - -// This package throws an error instead of returning false when the command -// doesn't exist, for some reason. Yay for making logic more difficult! -// Here's a straightforward workaround. -export function commandExists(command) { - return _commandExists(command).then( - () => true, - () => false - ); -} - -// Very cool function origin8ting in... http-music pro8a8ly! -// Sorry if we happen to 8e violating past-us's copyright, lmao. -export function promisifyProcess(proc, showLogging = true) { - // Takes a process (from the child_process module) and returns a promise - // that resolves when the process exits (or rejects, if the exit code is - // non-zero). - // - // Ayy look, no alpha8etical second letter! Couldn't tell this was written - // like three years ago 8efore I was me. 8888) - - return new Promise((resolve, reject) => { - if (showLogging) { - proc.stdout.pipe(process.stdout); - proc.stderr.pipe(process.stderr); - } - - proc.on('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject(code); - } - }); - }); -} - -// Handy-dandy utility function for detecting whether the passed URL is the -// running JavaScript file. This takes `import.meta.url` from ES6 modules, which -// is great 'cuz (module === require.main) doesn't work without CommonJS -// modules. -export function isMain(importMetaURL) { - const metaPath = fileURLToPath(importMetaURL); - const relative = path.relative(process.argv[1], metaPath); - const isIndexJS = path.basename(metaPath) === 'index.js'; - return [ - '', - isIndexJS && 'index.js' - ].includes(relative); -} - -// Like readdir... but it's recursive! This returns a flat list of file paths. -// By default, the paths include the provided top/root path, but this can be -// changed with prefixPath to prefix some other path, or to just return paths -// relative to the root. Change pathStyle to specify posix or win32, or leave -// it as the default device-correct style. Provide a filterDir function to -// control which directory names are traversed at all, and filterFile to -// select which filenames are included in the final list. -export async function traverse(rootPath, { - pathStyle = 'device', - filterFile = () => true, - filterDir = () => true, - prefixPath = rootPath, -} = {}) { - const pathJoinDevice = path.join; - const pathJoinStyle = { - 'device': path.join, - 'posix': path.posix.join, - 'win32': path.win32.join, - }[pathStyle]; - - if (!pathJoinStyle) { - throw new Error(`Expected pathStyle to be device, posix, or win32`); - } - - const recursive = (names, ...subdirectories) => - Promise.all(names.map(async name => { - const devicePath = pathJoinDevice(rootPath, ...subdirectories, name); - const stats = await stat(devicePath); - - if (stats.isDirectory() && !filterDir(name)) return []; - else if (stats.isFile() && !filterFile(name)) return []; - else if (!stats.isDirectory() && !stats.isFile()) return []; - - if (stats.isDirectory()) { - return recursive(await readdir(devicePath), ...subdirectories, name); - } else { - return pathJoinStyle(prefixPath, ...subdirectories, name); - } - })); - - const names = await readdir(rootPath); - const results = await recursive(names); - return results.flat(Infinity); -} diff --git a/src/util/replacer.js b/src/util/replacer.js deleted file mode 100644 index e3f5623e..00000000 --- a/src/util/replacer.js +++ /dev/null @@ -1,852 +0,0 @@ -// Regex-based forward parser for wiki content, breaking up text input into -// text and (possibly nested) tag nodes. -// -// The behavior here is quite tied into the `transformContent` content -// 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'; - -export const replacerSpec = { - 'album': { - find: 'album', - link: 'linkAlbumDynamically', - }, - - 'album-commentary': { - find: 'album', - link: 'linkAlbumCommentary', - }, - - 'album-gallery': { - find: 'album', - link: 'linkAlbumGallery', - }, - - 'artist': { - find: 'artist', - link: 'linkArtist', - }, - - 'artist-gallery': { - find: 'artist', - link: 'linkArtistGallery', - }, - - 'commentary-index': { - find: null, - link: 'linkCommentaryIndex', - }, - - 'date': { - find: null, - value: (ref) => new Date(ref), - html: (date, {html, language}) => - html.tag('time', - {datetime: date.toUTCString()}, - language.formatDate(date)), - }, - - 'flash-index': { - find: null, - link: 'linkFlashIndex', - }, - - 'flash': { - find: 'flash', - link: 'linkFlash', - transformName(name, node, input) { - const nextCharacter = input[node.iEnd]; - const lastCharacter = name[name.length - 1]; - if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') { - return name.slice(0, -1); - } else { - return name; - } - }, - }, - - 'flash-act': { - find: 'flashAct', - link: 'linkFlashAct', - }, - - 'group': { - find: 'group', - link: 'linkGroup', - }, - - 'group-gallery': { - find: 'group', - link: 'linkGroupGallery', - }, - - 'home': { - find: null, - link: 'linkWikiHome', - }, - - 'listing-index': { - find: null, - link: 'linkListingIndex', - }, - - 'listing': { - find: 'listing', - link: 'linkListing', - }, - - 'media': { - find: null, - link: 'linkPathFromMedia', - }, - - 'news-index': { - find: null, - link: 'linkNewsIndex', - }, - - 'news-entry': { - find: 'newsEntry', - link: 'linkNewsEntry', - }, - - 'root': { - find: null, - link: 'linkPathFromRoot', - }, - - 'site': { - find: null, - link: 'linkPathFromSite', - }, - - 'static': { - find: 'staticPage', - link: 'linkStaticPage', - }, - - 'string': { - find: null, - value: (ref) => ref, - html: (ref, {language, args}) => language.$(ref, args), - }, - - 'tag': { - find: 'artTag', - link: 'linkArtTag', - }, - - 'track': { - find: 'track', - link: 'linkTrackDynamically', - }, -}; - -// Syntax literals. -const tagBeginning = '[['; -const tagEnding = ']]'; -const tagReplacerValue = ':'; -const tagHash = '#'; -const tagArgument = '*'; -const tagArgumentValue = '='; -const tagLabel = '|'; - -const noPrecedingWhitespace = '(? ({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_iParse, stop_literal; - -function parseOneTextNode(input, i, stopAt) { - return parseNodes(input, i, stopAt, true)[0]; -} - -function parseNodes(input, i, stopAt, textOnly) { - let nodes = []; - 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(); - } - - string = cleanRawText(string); - - 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 = `(?-])/g, '$1'); -} - -export function restoreRawHTMLTags(text) { - // Replace stuff like with ; these signal that - // the tag shouldn't be processed by the replacer system, - // and should just be embedded into the content as raw HTML. - return text.replace(/])/g, '<$1'); -} - -export function cleanRawText(text) { - text = squashBackslashes(text); - text = restoreRawHTMLTags(text); - return text; -} - -export function postprocessComments(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const commentRegexp = - new RegExp( - (// Remove comments which occupy entire lines, trimming the line break - // leading into them. These comments never include the ending of a - // comment which does not end a line, which is a regex way of saying - // "please fail early if we hit a --> that doesn't happen at the end - // of the line". - String.raw`\n(?!$))[\s\S])*?-->(?=$)` - + '|' + - - // Remove comments which appear at the start of a line, and any - // following spaces. - String.raw`^ *` + - + '|' + - - // Remove comments which appear anywhere else, including in the - // middle of a line or at the end of a line, and any leading spaces. - String.raw` *`), - - 'gm'); - - outputNodes.push({ - type: 'text', - - data: - node.data.replace(commentRegexp, ''), - - i: node.i, - iEnd: node.iEnd, - }); - } - - return outputNodes; -} - -export function postprocessImages(inputNodes) { - const outputNodes = []; - - let atStartOfLine = true; - - const lastNode = inputNodes.at(-1); - - for (const node of inputNodes) { - if (node.type === 'tag') { - atStartOfLine = false; - } - - if (node.type === 'text') { - const imageRegexp = //g; - - 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, - i: node.i + parseFrom, - iEnd: node.i + parseFrom + match.index, - }); - - parseFrom = match.index + match[0].length; - - const imageNode = {type: 'image'}; - const attributes = html.parseAttributes(match[1]); - - imageNode.src = attributes.get('src'); - - if (previousText.endsWith('\n')) { - atStartOfLine = true; - } else if (previousText.length) { - atStartOfLine = false; - } - - imageNode.inline = (() => { - // Images can force themselves to be rendered inline using a custom - // attribute - this style just works better for certain embeds, - // usually jokes or small images. - if (attributes.get('inline')) return true; - - // If we've already determined we're in the middle of a line, - // we're inline. (Of course!) - if (!atStartOfLine) { - return true; - } - - // If there's more text to go in this text node, and what's - // remaining doesn't start with a line break, we're inline. - if ( - parseFrom !== node.data.length && - node.data[parseFrom] !== '\n' - ) { - return true; - } - - // If we're at the end of this text node, but this text node - // isn't the last node overall, we're inline. - if ( - parseFrom === node.data.length && - node !== lastNode - ) { - return true; - } - - // If no other condition matches, this image is on its own line. - return false; - })(); - - if (attributes.get('link')) imageNode.link = attributes.get('link'); - if (attributes.get('style')) imageNode.style = attributes.get('style'); - if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width')); - if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height')); - if (attributes.get('align')) imageNode.align = attributes.get('align'); - if (attributes.get('pixelate')) imageNode.pixelate = true; - - if (attributes.get('warning')) { - imageNode.warnings = - attributes.get('warning').split(', '); - } - - outputNodes.push(imageNode); - - // No longer at the start of a line after an image - there will at - // least be a text node with only '\n' before the next image that's - // on its own line. - atStartOfLine = false; - } - - if (parseFrom !== node.data.length) { - outputNodes.push({ - type: 'text', - data: node.data.slice(parseFrom), - i: node.i + parseFrom, - iEnd: node.iEnd, - }); - } - - continue; - } - - outputNodes.push(node); - } - - return outputNodes; -} - -export function postprocessVideos(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const videoRegexp = /