From c11edada828dc734cce6988e5819630a73326085 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 21 Jul 2023 20:06:32 -0300 Subject: content, test: linkContribution: tooltip icons --- .../generateReleaseInfoContributionsLine.js | 1 + src/content/dependencies/linkContribution.js | 79 ++++++++--- src/static/client3.js | 150 +++++++++++++++++++++ src/static/site5.css | 42 ++++++ .../snapshot/generateAlbumReleaseInfo.js.test.cjs | 16 +-- .../snapshot/generateTrackReleaseInfo.js.test.cjs | 2 +- .../test/snapshot/linkContribution.js.test.cjs | 144 ++++++++++++++++---- test/snapshot/linkContribution.js | 37 ++++- 8 files changed, 411 insertions(+), 60 deletions(-) diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js index 1fa8dcca..2e6c4709 100644 --- a/src/content/dependencies/generateReleaseInfoContributionsLine.js +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -35,6 +35,7 @@ export default { link.slots({ showContribution: slots.showContribution, showIcons: slots.showIcons, + iconMode: 'tooltip', }))), }); }, diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index 8e42f247..5bc398de 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -20,7 +20,6 @@ export default { if (!empty(contribution.who.urls)) { relations.artistIcons = contribution.who.urls - .slice(0, 4) .map(url => relation('linkExternalAsIcon', url)); } @@ -37,37 +36,79 @@ export default { showContribution: {type: 'boolean', default: false}, showIcons: {type: 'boolean', default: false}, preventWrapping: {type: 'boolean', default: true}, + + iconMode: { + validate: v => v.is('inline', 'tooltip'), + default: 'inline' + }, }, generate(data, relations, slots, {html, language}) { - const hasContributionPart = !!(slots.showContribution && data.what); - const hasExternalPart = !!(slots.showIcons && relations.artistIcons); - - const externalLinks = hasExternalPart && - html.tag('span', - {[html.noEdgeWhitespace]: true, class: 'icons'}, - language.formatUnitList(relations.artistIcons)); + const hasContribution = !!(slots.showContribution && data.what); + const hasExternalIcons = !!(slots.showIcons && relations.artistIcons); const parts = ['misc.artistLink']; const options = {artist: relations.artistLink}; - if (hasContributionPart) { + if (hasContribution) { parts.push('withContribution'); options.contrib = data.what; } - if (hasExternalPart) { + if (hasExternalIcons && slots.iconMode === 'inline') { parts.push('withExternalLinks'); - options.links = externalLinks; + options.links = + html.tag('span', + { + [html.noEdgeWhitespace]: true, + class: ['icons', 'icons-inline'], + }, + language.formatUnitList( + relations.artistIcons + .slice(0, 4))); } - const content = language.formatString(parts.join('.'), options); + let content = language.formatString(parts.join('.'), options); - return ( - (parts.length > 1 && slots.preventWrapping - ? html.tag('span', - {[html.noEdgeWhitespace]: true, class: 'nowrap'}, - content) - : content)); - }, + if (hasExternalIcons && slots.iconMode === 'tooltip') { + content = [ + content, + html.tag('span', + { + [html.noEdgeWhitespace]: true, + class: ['icons', 'icons-tooltip'], + inert: true, + }, + html.tag('span', + { + [html.noEdgeWhitespace]: true, + [html.joinChildren]: '', + class: 'icons-tooltip-content', + }, + relations.artistIcons)), + ]; + } + + if (hasContribution || hasExternalIcons) { + content = + html.tag('span', { + [html.noEdgeWhitespace]: true, + [html.joinChildren]: '', + + class: [ + 'contribution', + + hasExternalIcons && + slots.iconMode === 'tooltip' && + 'has-tooltip', + + parts.length > 1 && + slots.preventWrapping && + 'nowrap', + ], + }, content); + } + + return content; + } }; diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..091d1fcf 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -958,6 +958,8 @@ clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); // Image overlay ------------------------------------------ +// TODO: Update to clientSteps style. + function addImageOverlayClickHandlers() { const container = document.getElementById('image-overlay-container'); @@ -1245,6 +1247,8 @@ function loadImage(imageUrl, onprogress) { // Group contributions table ------------------------------ +// TODO: Update to clientSteps style. + const groupContributionsTableInfo = Array.from(document.querySelectorAll('#content dl')) .filter(dl => dl.querySelector('a.group-contributions-sort-button')) @@ -1277,6 +1281,152 @@ for (const info of groupContributionsTableInfo) { }); } +// Artist link icon tooltips ------------------------------ + +// TODO: Update to clientSteps style. + +const linkIconTooltipInfo = + Array.from(document.querySelectorAll('span.contribution.has-tooltip')) + .map(span => ({ + mainLink: span.querySelector('a'), + iconsContainer: span.querySelector('span.icons-tooltip'), + iconLinks: span.querySelectorAll('span.icons-tooltip a'), + })); + +for (const info of linkIconTooltipInfo) { + const focusElements = + [info.mainLink, ...info.iconLinks]; + + const hoverElements = + [info.mainLink, info.iconsContainer]; + + let hidden = true; + + const show = () => { + info.iconsContainer.classList.add('visible'); + info.iconsContainer.inert = false; + hidden = false; + }; + + const hide = () => { + info.iconsContainer.classList.remove('visible'); + info.iconsContainer.inert = true; + hidden = true; + }; + + const considerHiding = () => { + if (hoverElements.some(el => el.matches(':hover'))) { + return; + } + + if (focusElements.includes(document.activeElement)) { + return; + } + + if (justTouched) { + return; + } + + hide(); + }; + + // Hover (pointer) + + let hoverTimeout; + + info.mainLink.addEventListener('mouseenter', () => { + if (hidden) { + hoverTimeout = setTimeout(show, 250); + } + }); + + info.mainLink.addEventListener('mouseout', () => { + if (hidden) { + clearTimeout(hoverTimeout); + } else { + considerHiding(); + } + }); + + info.iconsContainer.addEventListener('mouseout', () => { + if (!hidden) { + considerHiding(); + } + }); + + // Focus (keyboard) + + let focusTimeout; + + info.mainLink.addEventListener('focus', () => { + focusTimeout = setTimeout(show, 750); + }); + + info.mainLink.addEventListener('blur', () => { + clearTimeout(focusTimeout); + }); + + info.iconsContainer.addEventListener('focusout', () => { + requestAnimationFrame(considerHiding); + }); + + info.mainLink.addEventListener('blur', () => { + requestAnimationFrame(considerHiding); + }); + + // Touch (finger) + + let justTouched = false; + let touchTimeout; + + info.mainLink.addEventListener('touchend', event => { + let wasTarget = false; + + for (const touch of event.changedTouches) { + if (touch.target === info.mainLink) { + wasTarget = true; + break; + } + } + + if (!wasTarget) { + return; + } + + justTouched = true; + + clearTimeout(touchTimeout); + touchTimeout = setTimeout(() => { + justTouched = false; + }, 250); + + show(); + }); + + info.mainLink.addEventListener('click', event => { + if (hidden && justTouched) { + event.preventDefault(); + event.target.focus(); + show(); + } + }); + + document.body.addEventListener('touchend', event => { + const touches = [...event.changedTouches, ...event.touches]; + for (const {clientX, clientY} of touches) { + const touchEl = document.elementFromPoint(clientX, clientY); + if (!touchEl) continue; + + for (const hoverEl of hoverElements) { + if (touchEl === hoverEl) return; + if (hoverEl.contains(touchEl)) return; + } + } + + hide(); + }); +} + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { diff --git a/src/static/site5.css b/src/static/site5.css index bb83fe67..06696799 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -427,6 +427,7 @@ a { a:hover { text-decoration: underline; + text-decoration-style: solid !important; } a.current { @@ -472,11 +473,52 @@ a:not([href]):hover { white-space: nowrap; } +.contribution { + position: relative; +} + +.contribution.has-tooltip a { + text-decoration: underline; + text-decoration-style: dotted; +} + .icons { font-style: normal; white-space: nowrap; } +.icons-tooltip { + position: absolute; + z-index: 999; + left: -12px; + top: calc(1em - 2px); + padding: 4px 12px 6px 8px; +} + +.icons-tooltip:not(.visible) { + display: none; +} + +.icons-tooltip-content { + display: block; + padding: 6px 2px 2px 2px; + background: var(--bg-black-color); + border: 1px dotted var(--primary-color); + border-radius: 4px; + + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +.icons a:hover { + filter: brightness(1.4); +} + +.icons a { + padding: 0 3px; +} + .icon { display: inline-block; width: 24px; diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs index 9702cad8..3335a2eb 100644 --- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs @@ -7,18 +7,18 @@ 'use strict' exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `

- By Toby Fox (music probably) and Tensei (hot jams) ( - - Bandcamp - - - ). + By Toby Fox (music probably) and Tensei (hot jams) + + Bandcamp + + + .
Cover art by Hanni Brosh.
- Wallpaper art by Hanni Brosh and Niklink (edits). + Wallpaper art by Hanni Brosh and Niklink (edits).
- Banner art by Hanni Brosh and Niklink (edits). + Banner art by Hanni Brosh and Niklink (edits).
Released 3/14/2011.
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs index 2add28ed..3d988dce 100644 --- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs @@ -25,7 +25,7 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI

By Toby Fox.
- Cover art by Alpaca (🔥). + Cover art by Alpaca (🔥).

This wiki doesn't have any listening links for Suspicious Track.

` diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs index 75b9d273..4cf3aa3f 100644 --- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs @@ -5,8 +5,8 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links 1`] = ` -Lorem Ipsum Lover ( +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (inline) 1`] = ` +Lorem Ipsum Lover ( External (loremipsum.io) @@ -29,6 +29,50 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ) ` +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = ` +Lorem Ipsum Lover + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + + + External (loremipsum.io) + + + +` + exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no accents 1`] = ` Clark Powell Grounder & Scratch @@ -36,41 +80,41 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no preventWrapping 1`] = ` -Clark Powell ( - - SoundCloud - - - ) -Grounder & Scratch (Snooping) -Toby Fox (Arrangement) ( - - Bandcamp - - - , - - External (toby.fox) - - - ) +Clark Powell ( + + SoundCloud + + + ) +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) ( + + Bandcamp + + + , + + External (toby.fox) + + + ) ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showContribution 1`] = ` Clark Powell -Grounder & Scratch (Snooping) -Toby Fox (Arrangement) +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) ` -exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons 1`] = ` -Clark Powell ( +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (inline) 1`] = ` +Clark Powell ( SoundCloud ) Grounder & Scratch -Toby Fox ( +Toby Fox ( Bandcamp @@ -83,15 +127,36 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ) ` -exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons 1`] = ` -Clark Powell ( +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = ` +Clark Powell + + SoundCloud + + + +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) + + Bandcamp + + + + + External (toby.fox) + + + +` + +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (inline) 1`] = ` +Clark Powell ( SoundCloud ) -Grounder & Scratch (Snooping) -Toby Fox (Arrangement) ( +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) ( Bandcamp @@ -103,3 +168,24 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ) ` + +exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = ` +Clark Powell + + SoundCloud + + + +Grounder & Scratch (Snooping) +Toby Fox (Arrangement) + + Bandcamp + + + + + External (toby.fox) + + + +` diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js index ad5fb416..ebd3be58 100644 --- a/test/snapshot/linkContribution.js +++ b/test/snapshot/linkContribution.js @@ -33,22 +33,36 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => { slots, }); - quickSnapshot('showContribution & showIcons', { + quickSnapshot('showContribution & showIcons (inline)', { showContribution: true, showIcons: true, + iconMode: 'inline', + }); + + quickSnapshot('showContribution & showIcons (tooltip)', { + showContribution: true, + showIcons: true, + iconMode: 'tooltip', }); quickSnapshot('only showContribution', { showContribution: true, }); - quickSnapshot('only showIcons', { + quickSnapshot('only showIcons (inline)', { + showIcons: true, + iconMode: 'inline', + }); + + quickSnapshot('only showIcons (tooltip)', { + showContribution: true, showIcons: true, + iconMode: 'tooltip', }); quickSnapshot('no accents', {}); - evaluate.snapshot('loads of links', { + evaluate.snapshot('loads of links (inline)', { name: 'linkContribution', args: [ {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [ @@ -65,6 +79,23 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => { slots: {showIcons: true}, }); + evaluate.snapshot('loads of links (tooltip)', { + name: 'linkContribution', + args: [ + {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [ + 'https://loremipsum.io', + 'https://loremipsum.io/generator/', + 'https://loremipsum.io/#meaning', + 'https://loremipsum.io/#usage-and-examples', + 'https://loremipsum.io/#controversy', + 'https://loremipsum.io/#when-to-use-lorem-ipsum', + 'https://loremipsum.io/#lorem-ipsum-all-the-things', + 'https://loremipsum.io/#original-source', + ]}, what: null}, + ], + slots: {showIcons: true, iconMode: 'tooltip'}, + }); + quickSnapshot('no preventWrapping', { showContribution: true, showIcons: true, -- cgit 1.3.0-6-gf8a5 From 10140f5b90e0fa9b38cdacfa23b10d96fb6fd189 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:30:23 -0400 Subject: client: dispatchInternalEvent utility --- src/static/client3.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/static/client3.js b/src/static/client3.js index 091d1fcf..84a66e3b 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -81,6 +81,31 @@ function fetchData(type, directory) { ); } +function dispatchInternalEvent(event, eventName, ...args) { + const [infoName] = + Object.entries(clientInfo) + .find(pair => pair[1].event === event); + + if (!infoName) { + throw new Error(`Expected event to be stored on clientInfo`); + } + + const {[eventName]: listeners} = event; + + if (!listeners) { + throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); + } + + for (const listener of listeners) { + try { + listener(...args); + } catch (error) { + console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); + console.debug(error); + } + } +} + // JS-based links ----------------------------------------- const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { @@ -672,6 +697,8 @@ function addHashLinkListeners() { processScrollingAfterHashLinkClicked(); + dispatchInternalEvent(event, 'whenHashLinkClicked', {link: hashLink}); + for (const handler of event.whenHashLinkClicked) { handler({ link: hashLink, -- cgit 1.3.0-6-gf8a5 From c34da87fb949c7797a1f273264720798dc7341ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:32:08 -0400 Subject: client: add hoverable tooltip system, logic pulled from info cards While this system comprehensively covers everything that info cards did (which was generally smarter hovering logic than newer code for external icon tooltips), it isn't focus- and touch-capable yet, so isn't quite done within this commit. However, the interface this system provides to others is baked and fully implemented here. --- src/static/client3.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 8 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 84a66e3b..acd85880 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -354,17 +354,209 @@ if ( }); } -// Data & info card --------------------------------------- +// Tooltip-style hover (infrastructure) ------------------- -/* -const NORMAL_HOVER_INFO_DELAY = 750; -const FAST_HOVER_INFO_DELAY = 250; -const END_FAST_HOVER_DELAY = 500; -const HIDE_HOVER_DELAY = 250; +const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { + settings: { + normalHoverInfoDelay: 400, + fastHoveringInfoDelay: 150, + + endFastHoveringDelay: 500, + + hideTooltipDelay: 500, + }, + + state: { + // These maps store a record for each registered element and related state + // and registration info, if applicable. + registeredTooltips: new Map(), + registeredHoverables: new Map(), + + // These are common across all tooltips, rather than stored individually, + // based on the principles that 1) only a single tooltip can be displayed + // at once, and 2) only a single hoverable can be hovered at a once. + hoverTimeout: null, + hideTimeout: null, + currentlyShownTooltip: null, + + // Fast hovering is a global mode which is activated as soon as any tooltip + // is displayed and turns off after a delay of no hoverables being hovered. + // Note that fast hovering may be turned off while hovering a tooltip, but + // it will never be turned off while idling over a hoverable. + fastHovering: false, + endFastHoveringTimeout: false, + }, + + event: { + whenTooltipShouldBeShown: [], + whenTooltipShouldBeHidden: [], + }, +}; + +// Adds DOM event listeners, so must be called during addPageListeners step. +function registerTooltipElement(tooltip) { + const {state} = hoverableTooltipInfo; + + if (!tooltip) + throw new Error(`Expected tooltip`); + + if (state.registeredTooltips.has(tooltip)) + throw new Error(`This tooltip is already registered`); + + // No state or registration info here. + state.registeredTooltips.set(tooltip, {}); + + tooltip.addEventListener('mouseenter', () => { + handleTooltipMouseEntered(tooltip); + }); -let fastHover = false; -let endFastHoverTimeout = null; + tooltip.addEventListener('mouseleave', () => { + handleTooltipMouseLeft(tooltip); + }); +} + +// Adds DOM event listeners, so must be called during addPageListeners step. +function registerTooltipHoverableElement(hoverable, tooltip) { + const {state} = hoverableTooltipInfo; + + if (!hoverable || !tooltip) + if (hoverable) + throw new Error(`Expected hoverable and tooltip, got only hoverable`); + else + throw new Error(`Expected hoverable and tooltip, got neither`); + + if (!state.registeredTooltips.has(tooltip)) + throw new Error(`Register tooltip before registering hoverable`); + + if (state.registeredHoverables.has(hoverable)) + throw new Error(`This hoverable is already registered`); + + state.registeredHoverables.set(hoverable, {tooltip}); + + hoverable.addEventListener('mouseenter', () => { + handleTooltipHoverableMouseEntered(hoverable); + }); + + hoverable.addEventListener('mouseleave', () => { + handleTooltipHoverableMouseLeft(hoverable); + }); +} + +function handleTooltipMouseEntered(tooltip) { + const {state} = hoverableTooltipInfo; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Don't time out the current tooltip while hovering it. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} +function handleTooltipMouseLeft(tooltip) { + const {state, settings} = hoverableTooltipInfo; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Start timing out the current tooltip when it's left. This could be + // canceled by mousing over a hoverable, or back over the tooltip again. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function handleTooltipHoverableMouseEntered(hoverable) { + const {event, settings, state} = hoverableTooltipInfo; + + const hoverTimeoutDelay = + (state.fastHovering + ? settings.fastHoveringInfoDelay + : settings.normalHoverInfoDelay); + + // Start a timer to show the corresponding tooltip, with the delay depending + // on whether fast hovering or not. This could be canceled by mousing out of + // the hoverable. + state.hoverTimeout = + setTimeout(() => { + state.hoverTimeout = null; + state.fastHovering = true; + showTooltipFromHoverable(hoverable); + }, hoverTimeoutDelay); + + // Don't stop fast hovering while over any hoverable. + if (state.endFastHoveringTimeout) { + clearTimeout(state.endFastHoveringTimeout); + state.endFastHoveringTimeout = null; + } + + // Don't time out the current tooltip while over any hoverable. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipHoverableMouseLeft(hoverable) { + const {state, settings} = hoverableTooltipInfo; + + // Don't show a tooltip when not over a hoverable! + if (state.hoverTimeout) { + clearTimeout(state.hoverTimeout); + state.hoverTimeout = null; + } + + // Start timing out fast hovering (if active) when not over a hoverable. + // This will only be canceled by mousing over another hoverable. + if (state.fastHovering && !state.endFastHoveringTimeout) { + state.endFastHoveringTimeout = + setTimeout(() => { + state.endFastHoveringTimeout = null; + state.fastHovering = false; + }, settings.endFastHoveringDelay); + } + + // Start timing out the current tooltip when mousing not over a hoverable. + // This could be canceled by mousing over another hoverable, or over the + // currently shown tooltip. + if (state.currentlyShownTooltip && !state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function hideCurrentlyShownTooltip() { + const {event, state} = hoverableTooltipInfo; + const {currentlyShownTooltip: tooltip} = state; + + if (!tooltip) return; + + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + + state.currentlyShownTooltip = null; +} + +function showTooltipFromHoverable(hoverable) { + const {event, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + hideCurrentlyShownTooltip(); + + dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); + + state.currentlyShownTooltip = tooltip; +} + +// Data & info card --------------------------------------- + +/* function colorLink(a, color) { console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); return; -- cgit 1.3.0-6-gf8a5 From 15f72dcf7bec602b979621d6c9e9c6d11617ffbb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:35:18 -0400 Subject: client: integrate new tooltip system into external link icons Reference code currently retained, waiting for focus and touch support in the new tooltip system. But this commit should fully cover all the new integration needed! --- src/static/client3.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index acd85880..57922022 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1502,8 +1502,67 @@ for (const info of groupContributionsTableInfo) { // Artist link icon tooltips ------------------------------ -// TODO: Update to clientSteps style. +const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = { + hoverableLinks: null, + iconContainers: null, +}; + +function getExternalIconTooltipReferences() { + const info = externalIconTooltipInfo; + + const spans = + Array.from(document.querySelectorAll('span.contribution.has-tooltip')); + + info.hoverableLinks = + spans + .map(span => span.querySelector('a')); + + info.iconContainers = + spans + .map(span => span.querySelector('span.icons-tooltip')); +} + +function addExternalIconTooltipInternalListeners() { + const info = externalIconTooltipInfo; + + hoverableTooltipInfo.event.whenTooltipShouldBeShown.push(({tooltip}) => { + if (!info.iconContainers.includes(tooltip)) return; + showExternalIconTooltip(tooltip); + }); + + hoverableTooltipInfo.event.whenTooltipShouldBeHidden.push(({tooltip}) => { + if (!info.iconContainers.includes(tooltip)) return; + hideExternalIconTooltip(tooltip); + }); +} +function showExternalIconTooltip(iconContainer) { + iconContainer.classList.add('visible'); + iconContainer.inert = false; +} + +function hideExternalIconTooltip(iconContainer) { + iconContainer.classList.remove('visible'); + iconContainer.inert = true; +} + +function addExternalIconTooltipPageListeners() { + const info = externalIconTooltipInfo; + + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverableLinks, + tooltip: info.iconContainers, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} + +clientSteps.getPageReferences.push(getExternalIconTooltipReferences); +clientSteps.addInternalListeners.push(addExternalIconTooltipInternalListeners); +clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); + +/* const linkIconTooltipInfo = Array.from(document.querySelectorAll('span.contribution.has-tooltip')) .map(span => ({ @@ -1538,7 +1597,7 @@ for (const info of linkIconTooltipInfo) { return; } - if (focusElements.includes(document.activeElement)) { + if () { return; } @@ -1645,6 +1704,7 @@ for (const info of linkIconTooltipInfo) { hide(); }); } +*/ // Sticky commentary sidebar ------------------------------ -- cgit 1.3.0-6-gf8a5 From 4c319007bdf151064ffed7d275001414b95f24d6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 15:48:10 -0400 Subject: client: add basic tooltip focus behavior --- src/static/client3.js | 135 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 57922022..57bc21a8 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -361,6 +361,8 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { normalHoverInfoDelay: 400, fastHoveringInfoDelay: 150, + focusInfoDelay: 750, + endFastHoveringDelay: 500, hideTooltipDelay: 500, @@ -374,10 +376,13 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // These are common across all tooltips, rather than stored individually, // based on the principles that 1) only a single tooltip can be displayed - // at once, and 2) only a single hoverable can be hovered at a once. + // at once, and 2) likewise, only a single hoverable can be hovered, + // focused, or otherwise active at once. hoverTimeout: null, + focusTimeout: null, hideTimeout: null, currentlyShownTooltip: null, + currentlyActiveHoverable: null, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -413,6 +418,19 @@ function registerTooltipElement(tooltip) { tooltip.addEventListener('mouseleave', () => { handleTooltipMouseLeft(tooltip); }); + + tooltip.addEventListener('focusin', () => { + handleTooltipReceivedFocus(tooltip); + }); + + tooltip.addEventListener('focusout', event => { + // This event gets activated for tabbing *between* links inside the + // tooltip, which is no good and certainly doesn't represent the focus + // leaving the tooltip. + if (tooltip.contains(event.relatedTarget)) return; + + handleTooltipLostFocus(tooltip); + }); } // Adds DOM event listeners, so must be called during addPageListeners step. @@ -440,6 +458,14 @@ function registerTooltipHoverableElement(hoverable, tooltip) { hoverable.addEventListener('mouseleave', () => { handleTooltipHoverableMouseLeft(hoverable); }); + + hoverable.addEventListener('focusin', () => { + handleTooltipHoverableReceivedFocus(hoverable); + }); + + hoverable.addEventListener('focusout', () => { + handleTooltipHoverableLostFocus(hoverable); + }); } function handleTooltipMouseEntered(tooltip) { @@ -455,7 +481,7 @@ function handleTooltipMouseEntered(tooltip) { } function handleTooltipMouseLeft(tooltip) { - const {state, settings} = hoverableTooltipInfo; + const {settings, state} = hoverableTooltipInfo; if (state.currentlyShownTooltip !== tooltip) return; @@ -470,6 +496,34 @@ function handleTooltipMouseLeft(tooltip) { } } +function handleTooltipReceivedFocus(tooltip) { + const {state} = hoverableTooltipInfo; + + // Cancel the tooltip-hiding timeout if it exists. The tooltip will never + // be hidden while it contains the focus anyway, but this ensures the timeout + // will be suitably reset when the tooltip loses focus. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipLostFocus(tooltip) { + const {settings, state} = hoverableTooltipInfo; + + // Start timing out the current tooltip when it loses focus. This will be + // canceled if the tooltip receives focus again. Another tooltip might also + // display before this timeout runs, but since this is the same timeout name + // as all tooltip interactions, it'll get cleared appropriately. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + function handleTooltipHoverableMouseEntered(hoverable) { const {event, settings, state} = hoverableTooltipInfo; @@ -502,7 +556,7 @@ function handleTooltipHoverableMouseEntered(hoverable) { } function handleTooltipHoverableMouseLeft(hoverable) { - const {state, settings} = hoverableTooltipInfo; + const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip when not over a hoverable! if (state.hoverTimeout) { @@ -532,26 +586,93 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } +function handleTooltipHoverableReceivedFocus(hoverable) { + const {settings, state} = hoverableTooltipInfo; + + // Start a timer to show the corresponding tooltip. + state.focusTimeout = + setTimeout(() => { + state.focusTimeout = null; + showTooltipFromHoverable(hoverable); + }, settings.focusInfoDelay); +} + +function handleTooltipHoverableLostFocus(hoverable) { + const {settings, state} = hoverableTooltipInfo; + + // Don't show a tooltip from focusing a hoverable if it isn't focused + // anymore! If another hoverable is receiving focus, that will be evaluated + // and set its own focus timeout after we clear the previous one here. + if (state.focusTimeout) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + } + + // Start timing out the current tooltip when the hoverable loses focus. + // Yes, even if focus is going *into* that very tooltip! This timeout will + // be immediately canceled, in that case. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function currentlyShownTooltipHasFocus() { + const {state} = hoverableTooltipInfo; + + const { + currentlyShownTooltip: tooltip, + currentlyActiveHoverable: hoverable, + } = state; + + // If there's no tooltip, it can't possibly have focus. + if (!tooltip) return false; + + // If the tooltip literally contains (or is) the focused element, then that's + // the principle condition we're looking for. + if (tooltip.contains(document.activeElement)) return true; + + // If the hoverable *which opened the tooltip* is focused, then that also + // represents the tooltip being focused (in its currently shown state). + if (hoverable.contains(document.activeElement)) return true; + + return false; +} + function hideCurrentlyShownTooltip() { const {event, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; - if (!tooltip) return; + // If there was no tooltip to begin with, we're functionally in the desired + // state already, so return true. + if (!tooltip) return true; - dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + // Never hide the tooltip if it's focused. + if (currentlyShownTooltipHasFocus()) return false; state.currentlyShownTooltip = null; + state.currentlyActiveHoverable = null; + + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + + return true; } function showTooltipFromHoverable(hoverable) { const {event, state} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); - hideCurrentlyShownTooltip(); + if (!hideCurrentlyShownTooltip()) return false; + + state.currentlyShownTooltip = tooltip; + state.currentlyActiveHoverable = hoverable; dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); - state.currentlyShownTooltip = tooltip; + return true; } // Data & info card --------------------------------------- -- cgit 1.3.0-6-gf8a5 From 7a234a0b80f5db5d84388f661f473b561b2b0953 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 11 Nov 2023 17:42:40 -0400 Subject: client: more specialized tooltip focus behavior --- src/static/client3.js | 95 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 57bc21a8..d4e47f0a 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -358,13 +358,26 @@ if ( const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { settings: { + // Hovering has two speed settings. The normal setting is used by default, + // and once a tooltip is displayed as a result of hover, the entire tooltip + // system will enter a "fast hover mode" - hovering will activate tooltips + // sooner. "Fast hover mode" is disabled after a sustained duration of not + // hovering over any hoverables; it's meant only to accelerate switching + // tooltips while still deciding, or getting a quick overview across more + // than one tooltip. normalHoverInfoDelay: 400, fastHoveringInfoDelay: 150, + endFastHoveringDelay: 500, + // Focusing has a single speed setting, which is how long it will take to + // enter a functional "focus mode" (though it's not actually implemented + // in terms of this state). As soon as "focus mode" is entered, the tooltip + // for the current hoverable is displayed, and focusing another hoverable + // will cause the current tooltip to be swapped for that one immediately. + // "Focus mode" ends as soon as anything apart from a tooltip or hoverable + // is focused, and it will be necessary to wait on this delay again. focusInfoDelay: 750, - endFastHoveringDelay: 500, - hideTooltipDelay: 500, }, @@ -383,6 +396,7 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { hideTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, + tooltipWasJustHidden: false, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -419,17 +433,17 @@ function registerTooltipElement(tooltip) { handleTooltipMouseLeft(tooltip); }); - tooltip.addEventListener('focusin', () => { - handleTooltipReceivedFocus(tooltip); + tooltip.addEventListener('focusin', event => { + handleTooltipReceivedFocus(tooltip, event.relatedTarget); }); tooltip.addEventListener('focusout', event => { // This event gets activated for tabbing *between* links inside the // tooltip, which is no good and certainly doesn't represent the focus // leaving the tooltip. - if (tooltip.contains(event.relatedTarget)) return; + if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; - handleTooltipLostFocus(tooltip); + handleTooltipLostFocus(tooltip, event.relatedTarget); }); } @@ -459,12 +473,12 @@ function registerTooltipHoverableElement(hoverable, tooltip) { handleTooltipHoverableMouseLeft(hoverable); }); - hoverable.addEventListener('focusin', () => { - handleTooltipHoverableReceivedFocus(hoverable); + hoverable.addEventListener('focusin', event => { + handleTooltipHoverableReceivedFocus(hoverable, event.relatedTarget); }); - hoverable.addEventListener('focusout', () => { - handleTooltipHoverableLostFocus(hoverable); + hoverable.addEventListener('focusout', event => { + handleTooltipHoverableLostFocus(hoverable, event.relatedTarget); }); } @@ -508,20 +522,11 @@ function handleTooltipReceivedFocus(tooltip) { } } -function handleTooltipLostFocus(tooltip) { +function handleTooltipLostFocus(tooltip, newlyFocusedElement) { const {settings, state} = hoverableTooltipInfo; - // Start timing out the current tooltip when it loses focus. This will be - // canceled if the tooltip receives focus again. Another tooltip might also - // display before this timeout runs, but since this is the same timeout name - // as all tooltip interactions, it'll get cleared appropriately. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); - } + // Hide the current tooltip right away when it loses focus. + hideCurrentlyShownTooltip(); } function handleTooltipHoverableMouseEntered(hoverable) { @@ -586,18 +591,30 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } -function handleTooltipHoverableReceivedFocus(hoverable) { +function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement) { const {settings, state} = hoverableTooltipInfo; - // Start a timer to show the corresponding tooltip. + // By default, display the corresponding tooltip after a delay. + state.focusTimeout = setTimeout(() => { state.focusTimeout = null; showTooltipFromHoverable(hoverable); }, settings.focusInfoDelay); + + // If a tooltip was just hidden - which is almost certainly a result of the + // focus changing - then display this tooltip immediately, canceling the + // above timeout. + + if (state.tooltipWasJustHidden) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + + showTooltipFromHoverable(hoverable); + } } -function handleTooltipHoverableLostFocus(hoverable) { +function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip from focusing a hoverable if it isn't focused @@ -608,19 +625,15 @@ function handleTooltipHoverableLostFocus(hoverable) { state.focusTimeout = null; } - // Start timing out the current tooltip when the hoverable loses focus. - // Yes, even if focus is going *into* that very tooltip! This timeout will - // be immediately canceled, in that case. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); + // Unless focus is entering the tooltip itself, hide the tooltip immediately. + // This will set the tooltipWasJustHidden flag, which is detected by a newly + // focused hoverable, if applicable. + if (!currentlyShownTooltipHasFocus(newlyFocusedElement)) { + hideCurrentlyShownTooltip(); } } -function currentlyShownTooltipHasFocus() { +function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { const {state} = hoverableTooltipInfo; const { @@ -633,11 +646,11 @@ function currentlyShownTooltipHasFocus() { // If the tooltip literally contains (or is) the focused element, then that's // the principle condition we're looking for. - if (tooltip.contains(document.activeElement)) return true; + if (tooltip.contains(focusElement)) return true; // If the hoverable *which opened the tooltip* is focused, then that also // represents the tooltip being focused (in its currently shown state). - if (hoverable.contains(document.activeElement)) return true; + if (hoverable.contains(focusElement)) return true; return false; } @@ -656,6 +669,12 @@ function hideCurrentlyShownTooltip() { state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; + // Set this for one tick of the event cycle. + state.tooltipWasJustHidden = true; + setTimeout(() => { + state.tooltipWasJustHidden = false; + }); + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); return true; @@ -670,6 +689,8 @@ function showTooltipFromHoverable(hoverable) { state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; + state.tooltipWasJustHidden = false; + dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); return true; -- cgit 1.3.0-6-gf8a5 From db44a5ea5fd8cb3be1d491687acb64eba966abea Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 10:59:17 -0400 Subject: client: most of touch implementation for tooltips --- src/static/client3.js | 195 +++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 105 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index d4e47f0a..5cc34461 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -393,10 +393,12 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // focused, or otherwise active at once. hoverTimeout: null, focusTimeout: null, + touchTimeout: null, hideTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, tooltipWasJustHidden: false, + hoverableWasRecentlyTouched: false, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -474,11 +476,19 @@ function registerTooltipHoverableElement(hoverable, tooltip) { }); hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event.relatedTarget); + handleTooltipHoverableReceivedFocus(hoverable, event); }); hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event.relatedTarget); + handleTooltipHoverableLostFocus(hoverable, event); + }); + + hoverable.addEventListener('touchend', event => { + handleTooltipHoverableTouchEnded(hoverable, event); + }); + + hoverable.addEventListener('click', event => { + handleTooltipHoverableClicked(hoverable, event); }); } @@ -522,7 +532,7 @@ function handleTooltipReceivedFocus(tooltip) { } } -function handleTooltipLostFocus(tooltip, newlyFocusedElement) { +function handleTooltipLostFocus(tooltip) { const {settings, state} = hoverableTooltipInfo; // Hide the current tooltip right away when it loses focus. @@ -591,7 +601,7 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } -function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement) { +function handleTooltipHoverableReceivedFocus(hoverable) { const {settings, state} = hoverableTooltipInfo; // By default, display the corresponding tooltip after a delay. @@ -614,7 +624,7 @@ function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement } } -function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { +function handleTooltipHoverableLostFocus(hoverable, domEvent) { const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip from focusing a hoverable if it isn't focused @@ -628,11 +638,63 @@ function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { // Unless focus is entering the tooltip itself, hide the tooltip immediately. // This will set the tooltipWasJustHidden flag, which is detected by a newly // focused hoverable, if applicable. - if (!currentlyShownTooltipHasFocus(newlyFocusedElement)) { + if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { hideCurrentlyShownTooltip(); } } +function handleTooltipHoverableTouchEnded(hoverable, domEvent) { + const {settings, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't proceed if this hoverable's tooltip is already visible - in that + // case touching the hoverable again should behave just like a normal click. + if (state.currentlyShownTooltip === tooltip) return; + + const touchEndedOverHoverable = + Array.from(domEvent.changedTouches) + .some(touch => + hoverable.contains( + document.elementFromPoint(touch.clientX, touch.clientY))); + + if (!touchEndedOverHoverable) { + return; + } + + if (state.touchTimeout) { + clearTimeout(state.touchTimeout); + state.touchTimeout = null; + } + + // Show the tooltip right away. + showTooltipFromHoverable(hoverable); + + // Set a state, for a brief but not instantaneous period, indicating that a + // hoverable was recently touched. The touchend event may precede the click + // event by some time, and we don't want to navigate away from the page as + // a result of the click event which this touch precipitated. + state.hoverableWasRecentlyTouched = true; + state.touchTimeout = + setTimeout(() => { + state.hoverableWasRecentlyTouched = false; + }, 250); +} + +function handleTooltipHoverableClicked(hoverable, domEvent) { + const {state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't navigate away from the page if the this hoverable was recently + // touched (and had its tooltip activated). That flag won't be set if its + // tooltip was already open before the touch. + if ( + state.currentlyActiveHoverable === hoverable && + state.hoverableWasRecentlyTouched + ) { + event.preventDefault(); + } +} + function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { const {state} = hoverableTooltipInfo; @@ -696,6 +758,28 @@ function showTooltipFromHoverable(hoverable) { return true; } +function addHoverableTooltipPageListeners() { + document.body.addEventListener('touchend', domEvent => { + const {state} = hoverableTooltipInfo; + + const touches = [...domEvent.changedTouches, ...domEvent.touches]; + const hoverables = Array.from(state.registeredHoverables.keys()); + + // TODO: https://github.com/tc39/proposal-iterator-helpers + const anyTouchOverAnyHoverable = + touches.some(({clientX, clientY}) => { + const element = document.elementFromPoint(clientX, clientY); + return hoverables.some(hoverable => hoverable.contains(element)); + }); + + if (!anyTouchOverAnyHoverable) { + hideCurrentlyShownTooltip(); + } + }); +} + +clientSteps.addPageListeners.push(addHoverableTooltipPageListeners); + // Data & info card --------------------------------------- /* @@ -864,53 +948,6 @@ const infoCard = (() => { }; })(); -function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout( - () => { - fastHover = true; - infoCard.show(type, evt.target); - }, - fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); - - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; - - infoCard.cancelHide(); - }, - - mouseleave() { - clearTimeout(hoverTimeout); - - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } - - infoCard.readyHide(); - }, - }; -} - -const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track'), -}; - -function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [eventName, handler] of Object.entries( - infoCardLinkHandlers[type] - )) { - a.addEventListener(eventName, handler); - } - } -} - // Info cards are disa8led for now since they aren't quite ready for release, // 8ut you can try 'em out 8y setting this localStorage flag! // @@ -1793,58 +1830,6 @@ for (const info of linkIconTooltipInfo) { info.mainLink.addEventListener('blur', () => { requestAnimationFrame(considerHiding); }); - - // Touch (finger) - - let justTouched = false; - let touchTimeout; - - info.mainLink.addEventListener('touchend', event => { - let wasTarget = false; - - for (const touch of event.changedTouches) { - if (touch.target === info.mainLink) { - wasTarget = true; - break; - } - } - - if (!wasTarget) { - return; - } - - justTouched = true; - - clearTimeout(touchTimeout); - touchTimeout = setTimeout(() => { - justTouched = false; - }, 250); - - show(); - }); - - info.mainLink.addEventListener('click', event => { - if (hidden && justTouched) { - event.preventDefault(); - event.target.focus(); - show(); - } - }); - - document.body.addEventListener('touchend', event => { - const touches = [...event.changedTouches, ...event.touches]; - for (const {clientX, clientY} of touches) { - const touchEl = document.elementFromPoint(clientX, clientY); - if (!touchEl) continue; - - for (const hoverEl of hoverElements) { - if (touchEl === hoverEl) return; - if (hoverEl.contains(touchEl)) return; - } - } - - hide(); - }); } */ -- cgit 1.3.0-6-gf8a5 From 2d31d6daa66d711a6dc22b84ec0d4b79d776c4ba Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 11:29:45 -0400 Subject: client: avoid processing touch events related to scrolling --- src/static/client3.js | 76 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 5cc34461..db9e5505 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -406,6 +406,15 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // it will never be turned off while idling over a hoverable. fastHovering: false, endFastHoveringTimeout: false, + + // These track the identifiers of current touches and a record of current + // identifiers that are "banished" by scrolling - that is, touches which + // existed while the page scrolled and were probably responsible for that + // scrolling. This is a bit loose (we can't actually tell which touches + // caused the page to scroll) but it's intended to keep scrolling the page + // from causing the current tooltip to be hidden. + currentTouchIdentifiers: new Set(), + touchIdentifiersBanishedByScrolling: new Set(), }, event: { @@ -651,13 +660,25 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // case touching the hoverable again should behave just like a normal click. if (state.currentlyShownTooltip === tooltip) return; - const touchEndedOverHoverable = - Array.from(domEvent.changedTouches) - .some(touch => - hoverable.contains( - document.elementFromPoint(touch.clientX, touch.clientY))); + const endedTouches = Array.from(domEvent.changedTouches); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + const unbanishedTouches = + endedTouches.filter(touch => + !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + + if (empty(unbanishedTouches)) return; - if (!touchEndedOverHoverable) { + // Don't proceed if none of the (just-ended) touches ended over the + // hoverable. + const anyTouchEndedOverHoverable = + unbanishedTouches.some(touch => + hoverable.contains( + document.elementFromPoint(touch.clientX, touch.clientY))); + + if (!anyTouchEndedOverHoverable) { return; } @@ -759,15 +780,54 @@ function showTooltipFromHoverable(hoverable) { } function addHoverableTooltipPageListeners() { + document.body.addEventListener('touchstart', domEvent => { + const {state} = hoverableTooltipInfo; + for (const {identifier} of domEvent.changedTouches) { + state.currentTouchIdentifiers.add(identifier); + } + }); + + window.addEventListener('scroll', domEvent => { + const {state} = hoverableTooltipInfo; + for (const identifier of state.currentTouchIdentifiers) { + state.touchIdentifiersBanishedByScrolling.add(identifier); + } + }); + + document.body.addEventListener('touchend', domEvent => { + const {state} = hoverableTooltipInfo; + + const identifiers = + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier); + + setTimeout(() => { + for (const identifier of identifiers) { + state.currentTouchIdentifiers.delete(identifier); + state.touchIdentifiersBanishedByScrolling.delete(identifier); + } + }); + }); + document.body.addEventListener('touchend', domEvent => { const {state} = hoverableTooltipInfo; - const touches = [...domEvent.changedTouches, ...domEvent.touches]; const hoverables = Array.from(state.registeredHoverables.keys()); + const endedTouches = Array.from(domEvent.changedTouches); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + const unbanishedTouches = + endedTouches.filter(touch => + !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + + if (empty(unbanishedTouches)) return; + // TODO: https://github.com/tc39/proposal-iterator-helpers const anyTouchOverAnyHoverable = - touches.some(({clientX, clientY}) => { + unbanishedTouches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); return hoverables.some(hoverable => hoverable.contains(element)); }); -- cgit 1.3.0-6-gf8a5 From 5127fd36dcf5987f402cce0353768b1421d9b7b4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 11:34:43 -0400 Subject: client, css: minor tooltip fixes --- src/static/client3.js | 10 ++++++---- src/static/site5.css | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index db9e5505..88df58de 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -813,6 +813,7 @@ function addHoverableTooltipPageListeners() { const {state} = hoverableTooltipInfo; const hoverables = Array.from(state.registeredHoverables.keys()); + const tooltips = Array.from(state.registeredTooltips.keys()); const endedTouches = Array.from(domEvent.changedTouches); @@ -825,14 +826,15 @@ function addHoverableTooltipPageListeners() { if (empty(unbanishedTouches)) return; - // TODO: https://github.com/tc39/proposal-iterator-helpers - const anyTouchOverAnyHoverable = + const anyTouchOverAnyHoverableOrTooltip = unbanishedTouches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); - return hoverables.some(hoverable => hoverable.contains(element)); + if (hoverables.some(el => el.contains(element))) return true; + if (tooltips.some(el => el.contains(element))) return true; + return false; }); - if (!anyTouchOverAnyHoverable) { + if (!anyTouchOverAnyHoverableOrTooltip) { hideCurrentlyShownTooltip(); } }); diff --git a/src/static/site5.css b/src/static/site5.css index 06696799..582681bb 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -489,7 +489,7 @@ a:not([href]):hover { .icons-tooltip { position: absolute; - z-index: 999; + z-index: 1; left: -12px; top: calc(1em - 2px); padding: 4px 12px 6px 8px; -- cgit 1.3.0-6-gf8a5 From 834a087643306090905a2c2f080324b1100c0710 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 12:28:04 -0400 Subject: client: tooltip touch syntax cleanup --- src/static/client3.js | 49 ++++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index 88df58de..9db9fc6c 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -660,21 +660,22 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // case touching the hoverable again should behave just like a normal click. if (state.currentlyShownTooltip === tooltip) return; - const endedTouches = Array.from(domEvent.changedTouches); + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); // Don't process touch events that were "banished" because the page was // scrolled while those touches were active, and most likely as a result of // them. - const unbanishedTouches = - endedTouches.filter(touch => - !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); - if (empty(unbanishedTouches)) return; + if (empty(touches)) return; // Don't proceed if none of the (just-ended) touches ended over the // hoverable. const anyTouchEndedOverHoverable = - unbanishedTouches.some(touch => + touches.some(touch => hoverable.contains( document.elementFromPoint(touch.clientX, touch.clientY))); @@ -780,29 +781,28 @@ function showTooltipFromHoverable(hoverable) { } function addHoverableTooltipPageListeners() { + const {state} = hoverableTooltipInfo; + + const getTouchIdentifiers = domEvent => + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier) + .filter(identifier => typeof identifier !== 'undefined'); + document.body.addEventListener('touchstart', domEvent => { - const {state} = hoverableTooltipInfo; - for (const {identifier} of domEvent.changedTouches) { + for (const identifier of getTouchIdentifiers(domEvent)) { state.currentTouchIdentifiers.add(identifier); } }); - window.addEventListener('scroll', domEvent => { - const {state} = hoverableTooltipInfo; + window.addEventListener('scroll', () => { for (const identifier of state.currentTouchIdentifiers) { state.touchIdentifiersBanishedByScrolling.add(identifier); } }); document.body.addEventListener('touchend', domEvent => { - const {state} = hoverableTooltipInfo; - - const identifiers = - Array.from(domEvent.changedTouches) - .map(touch => touch.identifier); - setTimeout(() => { - for (const identifier of identifiers) { + for (const identifier of getTouchIdentifiers(domEvent)) { state.currentTouchIdentifiers.delete(identifier); state.touchIdentifiersBanishedByScrolling.delete(identifier); } @@ -810,24 +810,23 @@ function addHoverableTooltipPageListeners() { }); document.body.addEventListener('touchend', domEvent => { - const {state} = hoverableTooltipInfo; - const hoverables = Array.from(state.registeredHoverables.keys()); const tooltips = Array.from(state.registeredTooltips.keys()); - const endedTouches = Array.from(domEvent.changedTouches); + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); // Don't process touch events that were "banished" because the page was // scrolled while those touches were active, and most likely as a result of // them. - const unbanishedTouches = - endedTouches.filter(touch => - !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); - if (empty(unbanishedTouches)) return; + if (empty(touches)) return; const anyTouchOverAnyHoverableOrTooltip = - unbanishedTouches.some(({clientX, clientY}) => { + touches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); if (hoverables.some(el => el.contains(element))) return true; if (tooltips.some(el => el.contains(element))) return true; -- cgit 1.3.0-6-gf8a5 From d443e32d044dd74cd1923e3538af0a63ff6c6835 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 15:01:22 -0400 Subject: css: quick tooltip tweaks --- src/static/site5.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/static/site5.css b/src/static/site5.css index 582681bb..8c2b07a1 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -506,8 +506,12 @@ a:not([href]):hover { border: 1px dotted var(--primary-color); border-radius: 4px; + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + -webkit-user-select: none; - user-select: none; + user-select: none; + cursor: default; } -- cgit 1.3.0-6-gf8a5 From a1d50400b858e40471bc1bb78408d69d39907c5f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Nov 2023 20:05:48 -0400 Subject: content: generateContributionList: use tooltip style contrib icons --- src/content/dependencies/generateContributionList.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js index 731cfba5..6401e65e 100644 --- a/src/content/dependencies/generateContributionList.js +++ b/src/content/dependencies/generateContributionList.js @@ -16,5 +16,6 @@ export default { showIcons: true, showContribution: true, preventWrapping: false, + iconMode: 'tooltip', })))), }; -- cgit 1.3.0-6-gf8a5 From 0202375db8ccd03d98ed6c2ffbb800b67c026639 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 09:16:23 -0400 Subject: content, css: vertical tooltips + basic external parsing --- src/content/dependencies/linkContribution.js | 14 +- src/content/dependencies/linkExternalAsIcon.js | 265 +++++++++++++++++++++---- src/static/site5.css | 36 +++- src/strings-default.yaml | 24 +-- 4 files changed, 272 insertions(+), 67 deletions(-) diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index 5bc398de..ef61c766 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -1,15 +1,8 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'linkArtist', - 'linkExternalAsIcon', - ], - - extraDependencies: [ - 'html', - 'language', - ], + contentDependencies: ['linkArtist', 'linkExternalAsIcon'], + extraDependencies: ['html', 'language'], relations(relation, contribution) { const relations = {}; @@ -85,7 +78,8 @@ export default { [html.joinChildren]: '', class: 'icons-tooltip-content', }, - relations.artistIcons)), + relations.artistIcons + .map(icon => icon.slot('withText', true)))), ]; } diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js index cd168992..d3ed9122 100644 --- a/src/content/dependencies/linkExternalAsIcon.js +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -1,6 +1,202 @@ -// TODO: Define these as extra dependencies and pass them somewhere -const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; -const MASTODON_DOMAINS = ['types.pl']; +import {stitchArrays} from '#sugar'; + +const fallbackDescriptor = { + icon: 'globe', + string: 'external', + + normal: 'domain', + compact: 'domain', +}; + +// TODO: Define all this stuff in data! +const externalSpec = [ + { + matchDomain: 'bandcamp.com', + + icon: 'bandcamp', + string: 'bandcamp', + + compact: 'handle', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomains: ['bc.s3m.us', 'music.solatrux.com'], + + icon: 'bandcamp', + string: 'bandcamp', + + normal: 'domain', + compact: 'domain', + }, + + { + matchDomains: ['types.pl'], + + icon: 'mastodon', + string: 'mastodon', + + compact: 'domain', + }, + + { + matchDomains: ['youtube.com', 'youtu.be'], + + icon: 'youtube', + string: 'youtube', + + compact: 'handle', + + handle: { + pathname: /^(@.*?)\/?$/, + }, + }, + + { + matchDomain: 'soundcloud.com', + + icon: 'soundcloud', + string: 'soundcloud', + + compact: 'handle', + + handle: /[^/]*\/?$/, + }, + + { + matchDomain: 'tumblr.com', + + icon: 'tumblr', + string: 'tumblr', + + compact: 'handle', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomain: 'twitter.com', + + icon: 'twitter', + string: 'twitter', + + compact: 'handle', + + handle: { + prefix: '@', + pathname: /^@?.*\/?$/, + }, + }, + + { + matchDomain: 'deviantart.com', + + icon: 'deviantart', + string: 'deviantart', + }, + + { + matchDomain: 'instagram.com', + + icon: 'instagram', + string: 'instagram', + }, + + { + matchDomain: 'newgrounds.com', + + icon: 'newgrounds', + string: 'newgrounds', + }, +]; + +function determineLinkText(url, descriptor, {language}) { + const prefix = 'misc.external'; + + const { + hostname: domain, + pathname, + } = new URL(url); + + let normal = null; + let compact = null; + + const place = language.$(prefix, descriptor.string); + + if (descriptor.normal === 'domain') { + normal = language.$(prefix, 'withDomain', {place, domain}); + } + + if (descriptor.compact === 'domain') { + compact = domain.replace(/^www\./, ''); + } + + let handle = null; + + if (descriptor.handle) { + let regexen = []; + let tests = []; + + let handlePrefix = ''; + + if (descriptor.handle instanceof RegExp) { + regexen.push(descriptor.handle); + tests.push(url); + } else { + for (const [key, value] of Object.entries(descriptor.handle)) { + switch (key) { + case 'prefix': + handlePrefix = value; + continue; + + case 'url': + tests.push(url); + break; + + case 'domain': + case 'hostname': + tests.push(domain); + break; + + case 'path': + case 'pathname': + tests.push(pathname.slice(1)); + break; + + default: + tests.push(''); + break; + } + + regexen.push(value); + } + } + + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + handle = handlePrefix + (match[1] ?? match[0]); + break; + } + } + } + + if (descriptor.compact === 'handle') { + compact = handle; + } + + if (normal === 'handle' && handle) { + normal = language.$(prefix, 'withHandle', {place, handle}); + } + + normal ??= language.$(prefix, descriptor.string); + + return {normal, compact}; +} export default { extraDependencies: ['html', 'language', 'to'], @@ -9,38 +205,39 @@ export default { return {url}; }, - generate(data, {html, language, to}) { - const domain = new URL(data.url).hostname; - const [id, msg] = ( - domain.includes('bandcamp.com') - ? ['bandcamp', language.$('misc.external.bandcamp')] - : BANDCAMP_DOMAINS.includes(domain) - ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] - : MASTODON_DOMAINS.includes(domain) - ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] - : domain.includes('youtu') - ? ['youtube', language.$('misc.external.youtube')] - : domain.includes('soundcloud') - ? ['soundcloud', language.$('misc.external.soundcloud')] - : domain.includes('tumblr.com') - ? ['tumblr', language.$('misc.external.tumblr')] - : domain.includes('twitter.com') - ? ['twitter', language.$('misc.external.twitter')] - : domain.includes('deviantart.com') - ? ['deviantart', language.$('misc.external.deviantart')] - : domain.includes('instagram.com') - ? ['instagram', language.$('misc.external.bandcamp')] - : domain.includes('newgrounds.com') - ? ['newgrounds', language.$('misc.external.newgrounds')] - : ['globe', language.$('misc.external.domain', {domain})]); + slots: { + withText: {type: 'boolean'}, + }, + + generate(data, slots, {html, language, to}) { + const {hostname: domain} = new URL(data.url); + + const descriptor = + externalSpec.find(({matchDomain, matchDomains}) => { + const compare = d => domain.includes(d); + if (matchDomain && compare(matchDomain)) return true; + if (matchDomains && matchDomains.some(compare)) return true; + return false; + }) ?? fallbackDescriptor; + + const {normal: normalText, compact: compactText} = + determineLinkText(data.url, descriptor, {language}); return html.tag('a', - {href: data.url, class: 'icon'}, - html.tag('svg', [ - html.tag('title', msg), - html.tag('use', { - href: to('shared.staticIcon', id), - }), - ])); + {href: data.url, class: ['icon', slots.withText && 'has-text']}, + [ + html.tag('svg', [ + !slots.withText && + html.tag('title', normalText), + + html.tag('use', { + href: to('shared.staticIcon', descriptor.icon), + }), + ]), + + slots.withText && + html.tag('span', {class: 'icon-text'}, + compactText ?? normalText), + ]); }, }; diff --git a/src/static/site5.css b/src/static/site5.css index 8c2b07a1..c1dfff82 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -477,7 +477,7 @@ a:not([href]):hover { position: relative; } -.contribution.has-tooltip a { +.contribution.has-tooltip > a { text-decoration: underline; text-decoration-style: dotted; } @@ -489,8 +489,8 @@ a:not([href]):hover { .icons-tooltip { position: absolute; - z-index: 1; - left: -12px; + z-index: 3; + left: -36px; top: calc(1em - 2px); padding: 4px 12px 6px 8px; } @@ -504,14 +504,21 @@ a:not([href]):hover { padding: 6px 2px 2px 2px; background: var(--bg-black-color); border: 1px dotted var(--primary-color); - border-radius: 4px; + border-radius: 6px; - -webkit-backdrop-filter: blur(2px); - backdrop-filter: blur(2px); + -webkit-backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); -webkit-user-select: none; user-select: none; + box-shadow: + 0 3px 4px 4px #000000aa, + 0 -2px 4px -2px var(--primary-color) inset; + cursor: default; } @@ -538,6 +545,23 @@ a:not([href]):hover { fill: var(--primary-color); } +.icon.has-text { + display: block; + width: unset; + height: 1.4em; +} + +.icon.has-text > svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.icon.has-text > .icon-text { + margin-left: 24px; + padding-right: 8px; +} + .rerelease, .other-group-accent { opacity: 0.7; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index e6b8d6db..9fdf0182 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -404,19 +404,15 @@ misc: # wiki - sorry! external: + external: "External" - # domain: - # General domain when one the URL doesn't match one of the - # sites below. + withDomain: + "{PLACE} ({DOMAIN})" - domain: "External ({DOMAIN})" - - # local: - # Files which are locally available on the wiki (under its media - # directory). + withHandle: + "{PLACE} ({HANDLE})" local: "Wiki Archive (local upload)" - deviantart: "DeviantArt" instagram: "Instagram" newgrounds: "Newgrounds" @@ -427,14 +423,8 @@ misc: tumblr: "Tumblr" twitter: "Twitter" wikipedia: "Wikipedia" - - bandcamp: - _: "Bandcamp" - domain: "Bandcamp ({DOMAIN})" - - mastodon: - _: "Mastodon" - domain: "Mastodon ({DOMAIN})" + bandcamp: "Bandcamp" + mastodon: "Mastodon" youtube: _: "YouTube" -- cgit 1.3.0-6-gf8a5 From 8b81a3aa4e266548ef2c8083391f6bb859915133 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:22:40 -0400 Subject: sugar: fix async decorateError not providing calling arguments --- src/util/sugar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/sugar.js b/src/util/sugar.js index 9646be37..3f0eb2ea 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -685,7 +685,7 @@ export function asyncAdaptiveDecorateError(fn, callback) { try { return await fn(...args); } catch (caughtError) { - throw callback(caughtError); + throw callback(caughtError, ...args); } }; -- cgit 1.3.0-6-gf8a5 From 82bb115d8404b88fe8b1af1bf714b3c70969f99b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:23:58 -0400 Subject: css: allow custom scroll margin offset per-element --- src/static/site5.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/static/site5.css b/src/static/site5.css index c1dfff82..fb3cf057 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1380,6 +1380,10 @@ h3.content-heading { /* Sticky heading */ +[id] { + --custom-scroll-offset: 0px; +} + #content [id] { /* Adjust scroll margin. */ scroll-margin-top: calc( @@ -1387,6 +1391,7 @@ h3.content-heading { + 33px /* Sticky subheading */ - 1em /* One line of text (align bottom) */ - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ ); } -- cgit 1.3.0-6-gf8a5 From 921f2d421d6ffb87fab1a2059a6c313b9c81f4f8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:25:30 -0400 Subject: css: allow sticky heading text to occupy blank area in thin layout --- src/static/site5.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/static/site5.css b/src/static/site5.css index fb3cf057..5a769545 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1836,6 +1836,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content z-index: 2; } + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + /* Disable grid features, just line header children up vertically */ #header { -- cgit 1.3.0-6-gf8a5 From 8f17782a5f2adbafd031b269195879eb7f79e05f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 11:16:48 -0400 Subject: data, content: extract external link parsing into nicer interface --- package.json | 1 + src/content/dependencies/linkExternalAsIcon.js | 223 +----------------- src/data/language.js | 14 +- src/data/things/language.js | 34 ++- src/util/external-links.js | 308 +++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 225 deletions(-) create mode 100644 src/util/external-links.js diff --git a/package.json b/package.json index 194c4060..abf9e1df 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "#content-dependencies": "./src/content/dependencies/index.js", "#content-function": "./src/content-function.js", "#cli": "./src/util/cli.js", + "#external-links": "./src/util/external-links.js", "#find": "./src/find.js", "#html": "./src/util/html.js", "#language": "./src/data/language.js", diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js index d3ed9122..58bd896d 100644 --- a/src/content/dependencies/linkExternalAsIcon.js +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -1,237 +1,28 @@ -import {stitchArrays} from '#sugar'; - -const fallbackDescriptor = { - icon: 'globe', - string: 'external', - - normal: 'domain', - compact: 'domain', -}; - -// TODO: Define all this stuff in data! -const externalSpec = [ - { - matchDomain: 'bandcamp.com', - - icon: 'bandcamp', - string: 'bandcamp', - - compact: 'handle', - - handle: {domain: /^[^.]*/}, - }, - - { - matchDomains: ['bc.s3m.us', 'music.solatrux.com'], - - icon: 'bandcamp', - string: 'bandcamp', - - normal: 'domain', - compact: 'domain', - }, - - { - matchDomains: ['types.pl'], - - icon: 'mastodon', - string: 'mastodon', - - compact: 'domain', - }, - - { - matchDomains: ['youtube.com', 'youtu.be'], - - icon: 'youtube', - string: 'youtube', - - compact: 'handle', - - handle: { - pathname: /^(@.*?)\/?$/, - }, - }, - - { - matchDomain: 'soundcloud.com', - - icon: 'soundcloud', - string: 'soundcloud', - - compact: 'handle', - - handle: /[^/]*\/?$/, - }, - - { - matchDomain: 'tumblr.com', - - icon: 'tumblr', - string: 'tumblr', - - compact: 'handle', - - handle: {domain: /^[^.]*/}, - }, - - { - matchDomain: 'twitter.com', - - icon: 'twitter', - string: 'twitter', - - compact: 'handle', - - handle: { - prefix: '@', - pathname: /^@?.*\/?$/, - }, - }, - - { - matchDomain: 'deviantart.com', - - icon: 'deviantart', - string: 'deviantart', - }, - - { - matchDomain: 'instagram.com', - - icon: 'instagram', - string: 'instagram', - }, - - { - matchDomain: 'newgrounds.com', - - icon: 'newgrounds', - string: 'newgrounds', - }, -]; - -function determineLinkText(url, descriptor, {language}) { - const prefix = 'misc.external'; - - const { - hostname: domain, - pathname, - } = new URL(url); - - let normal = null; - let compact = null; - - const place = language.$(prefix, descriptor.string); - - if (descriptor.normal === 'domain') { - normal = language.$(prefix, 'withDomain', {place, domain}); - } - - if (descriptor.compact === 'domain') { - compact = domain.replace(/^www\./, ''); - } - - let handle = null; - - if (descriptor.handle) { - let regexen = []; - let tests = []; - - let handlePrefix = ''; - - if (descriptor.handle instanceof RegExp) { - regexen.push(descriptor.handle); - tests.push(url); - } else { - for (const [key, value] of Object.entries(descriptor.handle)) { - switch (key) { - case 'prefix': - handlePrefix = value; - continue; - - case 'url': - tests.push(url); - break; - - case 'domain': - case 'hostname': - tests.push(domain); - break; - - case 'path': - case 'pathname': - tests.push(pathname.slice(1)); - break; - - default: - tests.push(''); - break; - } - - regexen.push(value); - } - } - - for (const {regex, test} of stitchArrays({ - regex: regexen, - test: tests, - })) { - const match = test.match(regex); - if (match) { - handle = handlePrefix + (match[1] ?? match[0]); - break; - } - } - } - - if (descriptor.compact === 'handle') { - compact = handle; - } - - if (normal === 'handle' && handle) { - normal = language.$(prefix, 'withHandle', {place, handle}); - } - - normal ??= language.$(prefix, descriptor.string); - - return {normal, compact}; -} - export default { extraDependencies: ['html', 'language', 'to'], - data(url) { - return {url}; - }, + data: (url) => ({url}), slots: { withText: {type: 'boolean'}, }, generate(data, slots, {html, language, to}) { - const {hostname: domain} = new URL(data.url); - - const descriptor = - externalSpec.find(({matchDomain, matchDomains}) => { - const compare = d => domain.includes(d); - if (matchDomain && compare(matchDomain)) return true; - if (matchDomains && matchDomains.some(compare)) return true; - return false; - }) ?? fallbackDescriptor; + const {url} = data; - const {normal: normalText, compact: compactText} = - determineLinkText(data.url, descriptor, {language}); + const normalText = language.formatExternalLink(url, {style: 'normal'}); + const compactText = language.formatExternalLink(url, {style: 'compact'}); + const iconId = language.formatExternalLink(url, {style: 'icon-id'}); return html.tag('a', - {href: data.url, class: ['icon', slots.withText && 'has-text']}, + {href: url, class: ['icon', slots.withText && 'has-text']}, [ html.tag('svg', [ !slots.withText && html.tag('title', normalText), html.tag('use', { - href: to('shared.staticIcon', descriptor.icon), + href: to('shared.staticIcon', iconId), }), ]), diff --git a/src/data/language.js b/src/data/language.js index 3fc14da7..6f774f27 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -7,15 +7,11 @@ import chokidar from 'chokidar'; import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. import yaml from 'js-yaml'; -import T from '#things'; +import {externalLinkSpec} from '#external-links'; import {colors, logWarn} from '#cli'; - -import { - annotateError, - annotateErrorWithFile, - showAggregate, - withAggregate, -} from '#sugar'; +import {annotateError, annotateErrorWithFile, showAggregate, withAggregate} + from '#sugar'; +import T from '#things'; const {Language} = T; @@ -114,6 +110,8 @@ export function initializeLanguageObject() { language.escapeHTML = string => he.encode(string, {useNamedReferences: true}); + language.externalLinkSpec = externalLinkSpec; + return language; } diff --git a/src/data/things/language.js b/src/data/things/language.js index 646eb6d1..185488e2 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,5 +1,11 @@ -import {Tag} from '#html'; import {isLanguageCode} from '#validators'; +import {Tag} from '#html'; + +import { + getExternalLinkStringsFromDescriptors, + isExternalLinkSpec, + isExternalLinkStyle, +} from '#external-links'; import { externalFunction, @@ -72,6 +78,13 @@ export class Language extends Thing { update: {validate: (t) => typeof t === 'object'}, }, + // List of descriptors for providing to external link utilities when using + // language.formatExternalLink - refer to util/external-links.js for info. + externalLinkSpec: { + flags: {update: true, expose: true}, + update: {validate: isExternalLinkSpec}, + }, + // Update only escapeHTML: externalFunction(), @@ -299,6 +312,25 @@ export class Language extends Thing { : duration; } + formatExternalLink(url, {style = 'normal'} = {}) { + if (!this.externalLinkSpec) { + throw new TypeError(`externalLinkSpec unavailable`); + } + + if (style !== 'all') { + isExternalLinkStyle(style); + } + + const results = + getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, this); + + if (style === 'all') { + return results; + } else { + return results[style]; + } + } + formatIndex(value) { this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); diff --git a/src/util/external-links.js b/src/util/external-links.js new file mode 100644 index 00000000..8e1c3ca9 --- /dev/null +++ b/src/util/external-links.js @@ -0,0 +1,308 @@ +import {empty, stitchArrays} from '#sugar'; + +import { + is, + isStringNonEmpty, + optional, + validateArrayItems, + validateInstanceOf, + validateProperties, +} from '#validators'; + +export const externalLinkStyles = [ + 'normal', + 'compact', + 'icon-id', +]; + +export const isExternalLinkStyle = is(...externalLinkStyles); + +// This might need to be adjusted for YAML importing... +const isExternalLinkSpecRegex = + validateInstanceOf(RegExp); + +export const isExternalLinkHandleSpec = + validateProperties({ + prefix: optional(isStringNonEmpty), + + url: optional(isExternalLinkSpecRegex), + + // TODO: Don't allow specifying both of these (they're aliases) + domain: optional(isExternalLinkSpecRegex), + hostname: optional(isExternalLinkSpecRegex), + + // TODO: Don't allow specifying both of these (they're aliases) + path: optional(isExternalLinkSpecRegex), + pathname: optional(isExternalLinkSpecRegex), + }); + +export const isExternalLinkSpec = + validateArrayItems( + validateProperties({ + // TODO: Don't allow providing both of these, and require providing one + matchDomain: optional(isStringNonEmpty), + matchDomains: optional(validateArrayItems(isStringNonEmpty)), + + string: isStringNonEmpty, + + // TODO: Don't allow 'handle' options if handle isn't provided + normal: optional(is('domain', 'handle')), + compact: optional(is('domain', 'handle')), + icon: optional(isStringNonEmpty), + + handle: optional(isExternalLinkHandleSpec), + })); + +export const fallbackDescriptor = { + string: 'external', + + normal: 'domain', + compact: 'domain', + icon: 'globe', +}; + +// TODO: Define all this stuff in data as YAML! +export const externalLinkSpec = [ + { + matchDomain: 'bandcamp.com', + + string: 'bandcamp', + + compact: 'handle', + icon: 'bandcamp', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomains: ['bc.s3m.us', 'music.solatrux.com'], + + icon: 'bandcamp', + string: 'bandcamp', + + normal: 'domain', + compact: 'domain', + }, + + { + matchDomains: ['types.pl'], + + icon: 'mastodon', + string: 'mastodon', + + compact: 'domain', + }, + + { + matchDomains: ['youtube.com', 'youtu.be'], + + icon: 'youtube', + string: 'youtube', + + compact: 'handle', + + handle: { + pathname: /^(@.*?)\/?$/, + }, + }, + + { + matchDomain: 'soundcloud.com', + + icon: 'soundcloud', + string: 'soundcloud', + + compact: 'handle', + + handle: /[^/]*\/?$/, + }, + + { + matchDomain: 'tumblr.com', + + icon: 'tumblr', + string: 'tumblr', + + compact: 'handle', + + handle: {domain: /^[^.]*/}, + }, + + { + matchDomain: 'twitter.com', + + icon: 'twitter', + string: 'twitter', + + compact: 'handle', + + handle: { + prefix: '@', + pathname: /^@?.*\/?$/, + }, + }, + + { + matchDomain: 'deviantart.com', + + icon: 'deviantart', + string: 'deviantart', + }, + + { + matchDomain: 'instagram.com', + + icon: 'instagram', + string: 'instagram', + }, + + { + matchDomain: 'newgrounds.com', + + icon: 'newgrounds', + string: 'newgrounds', + }, +]; + +export function getMatchingDescriptorsForExternalLink(url, descriptors) { + const {hostname: domain} = new URL(url); + const compare = d => domain.includes(d); + + const matchingDescriptors = + descriptors.filter(spec => { + if (spec.matchDomain && compare(spec.matchDomain)) return true; + if (spec.matchDomains && spec.matchDomains.some(compare)) return true; + return false; + }); + + return [...matchingDescriptors, fallbackDescriptor]; +} + +export function getExternalLinkStringsFromDescriptor(url, descriptor, language) { + const prefix = 'misc.external'; + + const results = { + 'normal': null, + 'compact': null, + 'icon-id': null, + }; + + const {hostname: domain, pathname} = new URL(url); + + const place = language.$(prefix, descriptor.string); + + if (descriptor.icon) { + results['icon-id'] = descriptor.icon; + } + + if (descriptor.normal === 'domain') { + results['normal'] = language.$(prefix, 'withDomain', {place, domain}); + } + + if (descriptor.compact === 'domain') { + results['compact'] = language.sanitize(domain.replace(/^www\./, '')); + } + + let handle = null; + + if (descriptor.handle) { + let regexen = []; + let tests = []; + + let handlePrefix = ''; + + if (descriptor.handle instanceof RegExp) { + regexen.push(descriptor.handle); + tests.push(url); + } else { + for (const [key, value] of Object.entries(descriptor.handle)) { + switch (key) { + case 'prefix': + handlePrefix = value; + continue; + + case 'url': + tests.push(url); + break; + + case 'domain': + case 'hostname': + tests.push(domain); + break; + + case 'path': + case 'pathname': + tests.push(pathname.slice(1)); + break; + + default: + tests.push(''); + break; + } + + regexen.push(value); + } + } + + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + handle = handlePrefix + (match[1] ?? match[0]); + break; + } + } + } + + if (descriptor.compact === 'handle') { + results.compact = language.sanitize(handle); + } + + if (descriptor.normal === 'handle' && handle) { + results.normal = language.$(prefix, 'withHandle', {place, handle}); + } + + results.normal ??= language.$(prefix, descriptor.string); + + return results; +} + +export function getExternalLinkStringsFromDescriptors(url, descriptors, language) { + const results = { + 'normal': null, + 'compact': null, + 'icon-id': null, + }; + + const remainingKeys = + new Set(Object.keys(results)); + + const matchingDescriptors = + getMatchingDescriptorsForExternalLink(url, descriptors); + + 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; +} -- cgit 1.3.0-6-gf8a5 From c5e02f9d314118a534fd0e942d87e74864674498 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 17:42:49 -0400 Subject: content: *mostly* port linkExternal to language.formatExternalLink --- src/content/dependencies/linkExternal.js | 64 ++++---------------------------- src/util/external-links.js | 17 ++++----- 2 files changed, 15 insertions(+), 66 deletions(-) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 5de612e2..7f090084 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,7 +1,3 @@ -// TODO: Define these as extra dependencies and pass them somewhere -const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; -const MASTODON_DOMAINS = ['types.pl']; - export default { extraDependencies: ['html', 'language', 'wikiData'], @@ -27,6 +23,13 @@ export default { }, generate(data, slots, {html, language}) { + return ( + html.tag('a', + {href: data.url, class: 'nowrap'}, + language.formatExternalLink(data.url, {style: 'platform'}))); + }, + + /* let isLocal; let domain; let pathname; @@ -49,25 +52,6 @@ export default { isLocal = true; } - const link = html.tag('a', - { - href: data.url, - class: 'nowrap', - }, - - // truly unhinged indentation here - isLocal - ? language.$('misc.external.local') - - : domain.includes('bandcamp.com') - ? language.$('misc.external.bandcamp') - - : BANDCAMP_DOMAINS.includes(domain) - ? language.$('misc.external.bandcamp.domain', {domain}) - - : MASTODON_DOMAINS.includes(domain) - ? language.$('misc.external.mastodon.domain', {domain}) - : domain.includes('youtu') ? slots.mode === 'album' ? data.url.includes('list=') @@ -75,38 +59,6 @@ export default { : language.$('misc.external.youtube.fullAlbum') : language.$('misc.external.youtube') - : domain.includes('soundcloud') - ? language.$('misc.external.soundcloud') - - : domain.includes('tumblr.com') - ? language.$('misc.external.tumblr') - - : domain.includes('twitter.com') - ? language.$('misc.external.twitter') - - : domain.includes('deviantart.com') - ? language.$('misc.external.deviantart') - - : domain.includes('wikipedia.org') - ? language.$('misc.external.wikipedia') - - : domain.includes('poetryfoundation.org') - ? language.$('misc.external.poetryFoundation') - - : domain.includes('instagram.com') - ? language.$('misc.external.instagram') - - : domain.includes('patreon.com') - ? language.$('misc.external.patreon') - - : domain.includes('spotify.com') - ? language.$('misc.external.spotify') - - : domain.includes('newgrounds.com') - ? language.$('misc.external.newgrounds') - - : domain); - switch (slots.mode) { case 'flash': { const wrap = content => @@ -136,5 +88,5 @@ export default { default: return link; } - } + */ }; diff --git a/src/util/external-links.js b/src/util/external-links.js index 8e1c3ca9..2047a720 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -12,6 +12,7 @@ import { export const externalLinkStyles = [ 'normal', 'compact', + 'platform', 'icon-id', ]; @@ -181,16 +182,15 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors) { export function getExternalLinkStringsFromDescriptor(url, descriptor, language) { const prefix = 'misc.external'; - const results = { - 'normal': null, - 'compact': null, - 'icon-id': null, - }; + const results = + Object.fromEntries(externalLinkStyles.map(style => [style, null])); const {hostname: domain, pathname} = new URL(url); const place = language.$(prefix, descriptor.string); + results['platform'] = place; + if (descriptor.icon) { results['icon-id'] = descriptor.icon; } @@ -270,11 +270,8 @@ export function getExternalLinkStringsFromDescriptor(url, descriptor, language) } export function getExternalLinkStringsFromDescriptors(url, descriptors, language) { - const results = { - 'normal': null, - 'compact': null, - 'icon-id': null, - }; + const results = + Object.fromEntries(externalLinkStyles.map(style => [style, null])); const remainingKeys = new Set(Object.keys(results)); -- cgit 1.3.0-6-gf8a5 From 0ee5269cd196cd14f06aac6c586e7104159eac74 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 17:47:18 -0400 Subject: content: implement "local" links much more rudimentarily --- src/content/dependencies/linkExternal.js | 22 ---------------------- src/util/external-links.js | 8 ++++++++ 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 7f090084..1b81efcc 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -30,28 +30,6 @@ export default { }, /* - let isLocal; - let domain; - let pathname; - - try { - const url = new URL(data.url); - domain = url.hostname; - pathname = url.pathname; - } catch (error) { - // No support for relative local URLs yet, sorry! (I.e, local URLs must - // be absolute relative to the domain name in order to work.) - isLocal = true; - domain = null; - pathname = null; - } - - // isLocal also applies for URLs which match the 'Canonical Base' under - // wiki-info.yaml, if present. - if (data.canonicalDomain && domain === data.canonicalDomain) { - isLocal = true; - } - : domain.includes('youtu') ? slots.mode === 'album' ? data.url.includes('list=') diff --git a/src/util/external-links.js b/src/util/external-links.js index 2047a720..7a34fa9e 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -64,6 +64,14 @@ export const fallbackDescriptor = { // TODO: Define all this stuff in data as YAML! export const externalLinkSpec = [ + { + matchDomain: 'hsmusic.wiki', + + string: 'local', + + icon: 'globe', + }, + { matchDomain: 'bandcamp.com', -- cgit 1.3.0-6-gf8a5 From cf08893d48db6f8082a176f54d0d92cb82716b3a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 18:50:59 -0400 Subject: external-links: general support for page-contextual formatting --- src/data/things/language.js | 16 ++- src/strings-default.yaml | 25 +++-- src/util/external-links.js | 263 +++++++++++++++++++++++++++++++++----------- 3 files changed, 224 insertions(+), 80 deletions(-) diff --git a/src/data/things/language.js b/src/data/things/language.js index 185488e2..f83b4218 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -3,6 +3,7 @@ import {Tag} from '#html'; import { getExternalLinkStringsFromDescriptors, + isExternalLinkContext, isExternalLinkSpec, isExternalLinkStyle, } from '#external-links'; @@ -312,17 +313,22 @@ export class Language extends Thing { : duration; } - formatExternalLink(url, {style = 'normal'} = {}) { + formatExternalLink(url, { + style = 'normal', + context = 'generic', + } = {}) { if (!this.externalLinkSpec) { throw new TypeError(`externalLinkSpec unavailable`); } - if (style !== 'all') { - isExternalLinkStyle(style); - } + if (style !== 'all') isExternalLinkStyle(style); + isExternalLinkContext(context); const results = - getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, this); + getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + language: this, + context, + }); if (style === 'all') { return results; diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 9fdf0182..698e3c9f 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -413,8 +413,17 @@ misc: "{PLACE} ({HANDLE})" local: "Wiki Archive (local upload)" + + bandcamp: "Bandcamp" + + bgreco: + _: "bgreco.net" + flash: "bgreco.net (high quality audio)" + deviantart: "DeviantArt" + homestuck: "Homestuck" instagram: "Instagram" + mastodon: "Mastodon" newgrounds: "Newgrounds" patreon: "Patreon" poetryFoundation: "Poetry Foundation" @@ -423,20 +432,20 @@ misc: tumblr: "Tumblr" twitter: "Twitter" wikipedia: "Wikipedia" - bandcamp: "Bandcamp" - mastodon: "Mastodon" youtube: _: "YouTube" + flash: "YouTube (on any device)" playlist: "YouTube (playlist)" fullAlbum: "YouTube (full album)" - flash: - bgreco: "{LINK} (HQ Audio)" - youtube: "{LINK} (on any device)" - homestuck: - page: "{LINK} (page {PAGE})" - secret: "{LINK} (secret page)" + # flashLink: + # Flashes can be positioned by page! They're accented with this + # information, if available. + + flashLink: + page: "{LINK} (page {PAGE})" + secret: "{LINK} (secret page)" # missingImage: # Fallback text displayed in an image when it's sourced to a file diff --git a/src/util/external-links.js b/src/util/external-links.js index 7a34fa9e..07f46bd3 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -18,31 +18,48 @@ export const externalLinkStyles = [ export const isExternalLinkStyle = is(...externalLinkStyles); +export const externalLinkContexts = [ + 'album', + 'artist', + 'flash', + 'generic', + 'group', + 'track', +]; + +export const isExternalLinkContext = is(...externalLinkContexts); + // This might need to be adjusted for YAML importing... -const isExternalLinkSpecRegex = +const isRegExp = validateInstanceOf(RegExp); export const isExternalLinkHandleSpec = validateProperties({ prefix: optional(isStringNonEmpty), - url: optional(isExternalLinkSpecRegex), - - // TODO: Don't allow specifying both of these (they're aliases) - domain: optional(isExternalLinkSpecRegex), - hostname: optional(isExternalLinkSpecRegex), - - // TODO: Don't allow specifying both of these (they're aliases) - path: optional(isExternalLinkSpecRegex), - pathname: optional(isExternalLinkSpecRegex), + url: optional(isRegExp), + domain: optional(isRegExp), + pathname: optional(isRegExp), }); export const isExternalLinkSpec = validateArrayItems( validateProperties({ - // TODO: Don't allow providing both of these, and require providing one - matchDomain: optional(isStringNonEmpty), - matchDomains: optional(validateArrayItems(isStringNonEmpty)), + 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), + }), string: isStringNonEmpty, @@ -64,27 +81,84 @@ export const fallbackDescriptor = { // TODO: Define all this stuff in data as YAML! export const externalLinkSpec = [ + // Special handling for album links + { - matchDomain: 'hsmusic.wiki', + match: { + context: 'album', + domain: 'youtube.com', + pathname: /^playlist/, + }, - string: 'local', + string: 'youtube.playlist', + icon: 'youtube', + }, - icon: 'globe', + { + match: { + context: 'album', + domain: 'youtube.com', + pathname: /^watch/, + }, + + string: 'youtube.fullAlbum', + icon: 'youtube', }, { - matchDomain: 'bandcamp.com', + match: { + context: 'album', + domain: 'youtu.be', + }, - string: 'bandcamp', + string: 'youtube.fullAlbum', + icon: 'youtube', + }, + + // Special handling for artist links + + { + match: { + context: 'artist', + domains: ['youtube.com', 'youtu.be'], + }, + + string: 'youtube', + icon: 'youtube', compact: 'handle', - icon: 'bandcamp', - handle: {domain: /^[^.]*/}, + handle: { + pathname: /^(@.*?)\/?$/, + }, + }, + + // Special handling for flash links + + { + match: { + context: 'flash', + domain: 'bgreco.net', + }, + + string: 'bgreco.flash', + icon: 'external', + }, + + { + match: { + context: 'flash', + domains: ['youtube.com', 'youtu.be'], + }, + + string: 'youtube.flash', + icon: 'youtube', }, + // Generic domains, sorted alphabetically (by string) + { - matchDomains: ['bc.s3m.us', 'music.solatrux.com'], + match: {domains: ['bc.s3m.us', 'music.solatrux.com']}, icon: 'bandcamp', string: 'bandcamp', @@ -94,7 +168,47 @@ export const externalLinkSpec = [ }, { - matchDomains: ['types.pl'], + match: {domain: 'bandcamp.com'}, + + string: 'bandcamp', + + compact: 'handle', + icon: 'bandcamp', + + handle: {domain: /^[^.]*/}, + }, + + { + match: {domain: 'deviantart.com'}, + + string: 'deviantart', + icon: 'deviantart', + }, + + { + match: {domain: 'instagram.com'}, + + string: 'instagram', + icon: 'instagram', + }, + + { + match: {domain: 'homestuck.com'}, + + string: 'homestuck', + icon: 'globe', // The horror! + }, + + { + match: {domain: 'hsmusic.wiki'}, + + string: 'local', + + icon: 'globe', + }, + + { + match: {domains: ['types.pl']}, icon: 'mastodon', string: 'mastodon', @@ -103,23 +217,17 @@ export const externalLinkSpec = [ }, { - matchDomains: ['youtube.com', 'youtu.be'], - - icon: 'youtube', - string: 'youtube', - - compact: 'handle', + match: {domain: 'newgrounds.com'}, - handle: { - pathname: /^(@.*?)\/?$/, - }, + string: 'newgrounds', + icon: 'newgrounds', }, { - matchDomain: 'soundcloud.com', + match: {domain: 'soundcloud.com'}, - icon: 'soundcloud', string: 'soundcloud', + icon: 'soundcloud', compact: 'handle', @@ -127,10 +235,10 @@ export const externalLinkSpec = [ }, { - matchDomain: 'tumblr.com', + match: {domain: 'tumblr.com'}, - icon: 'tumblr', string: 'tumblr', + icon: 'tumblr', compact: 'handle', @@ -138,62 +246,80 @@ export const externalLinkSpec = [ }, { - matchDomain: 'twitter.com', + match: {domain: 'twitter.com'}, - icon: 'twitter', string: 'twitter', + icon: 'twitter', compact: 'handle', handle: { prefix: '@', - pathname: /^@?.*\/?$/, + pathname: /^@?([a-zA-Z0-9_]*)\/?$/, }, }, { - matchDomain: 'deviantart.com', + match: {domains: ['youtube.com', 'youtu.be']}, - icon: 'deviantart', - string: 'deviantart', + string: 'youtube', + icon: 'youtube', }, +]; - { - matchDomain: 'instagram.com', - - icon: 'instagram', - string: 'instagram', - }, +function urlParts(url) { + const { + hostname: domain, + pathname, + search: query, + } = new URL(url); - { - matchDomain: 'newgrounds.com', + return {domain, pathname, query}; +} - icon: 'newgrounds', - string: 'newgrounds', - }, -]; +export function getMatchingDescriptorsForExternalLink(url, descriptors, { + context = 'generic', +} = {}) { + const {domain, pathname, query} = urlParts(url); -export function getMatchingDescriptorsForExternalLink(url, descriptors) { - const {hostname: domain} = new URL(url); - const compare = d => domain.includes(d); + const compareDomain = string => domain.includes(string); + const comparePathname = regex => regex.test(pathname.slice(1)); + const compareQuery = regex => regex.test(query.slice(1)); const matchingDescriptors = - descriptors.filter(spec => { - if (spec.matchDomain && compare(spec.matchDomain)) return true; - if (spec.matchDomains && spec.matchDomains.some(compare)) return true; - return false; - }); + descriptors + .filter(({match}) => { + if (match.domain) return compareDomain(match.domain); + if (match.domains) return match.domains.some(compareDomain); + return false; + }) + .filter(({match}) => { + if (match.context) return context === 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; + }); return [...matchingDescriptors, fallbackDescriptor]; } -export function getExternalLinkStringsFromDescriptor(url, descriptor, language) { +export function getExternalLinkStringsFromDescriptor(url, descriptor, { + language, +}) { const prefix = 'misc.external'; const results = Object.fromEntries(externalLinkStyles.map(style => [style, null])); - const {hostname: domain, pathname} = new URL(url); + const {domain, pathname, query} = urlParts(url); const place = language.$(prefix, descriptor.string); @@ -240,7 +366,7 @@ export function getExternalLinkStringsFromDescriptor(url, descriptor, language) case 'path': case 'pathname': - tests.push(pathname.slice(1)); + tests.push(pathname.slice(1) + query); break; default: @@ -277,7 +403,10 @@ export function getExternalLinkStringsFromDescriptor(url, descriptor, language) return results; } -export function getExternalLinkStringsFromDescriptors(url, descriptors, language) { +export function getExternalLinkStringsFromDescriptors(url, descriptors, { + language, + context = 'generic', +}) { const results = Object.fromEntries(externalLinkStyles.map(style => [style, null])); @@ -285,11 +414,11 @@ export function getExternalLinkStringsFromDescriptors(url, descriptors, language new Set(Object.keys(results)); const matchingDescriptors = - getMatchingDescriptorsForExternalLink(url, descriptors); + getMatchingDescriptorsForExternalLink(url, descriptors, {context}); for (const descriptor of matchingDescriptors) { const descriptorResults = - getExternalLinkStringsFromDescriptor(url, descriptor, language); + getExternalLinkStringsFromDescriptor(url, descriptor, {language}); const descriptorKeys = new Set( -- cgit 1.3.0-6-gf8a5 From 8c69ef2b14c4719fa0cd0c7daca27c613167b7ca Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 18:52:04 -0400 Subject: content: contextual external links --- .../dependencies/generateAlbumReleaseInfo.js | 2 +- .../dependencies/generateAlbumSidebarGroupBox.js | 5 ++++- src/content/dependencies/generateArtistInfoPage.js | 5 ++++- src/content/dependencies/generateFlashInfoPage.js | 2 +- src/content/dependencies/generateGroupInfoPage.js | 5 ++++- .../dependencies/generateTrackReleaseInfo.js | 5 ++++- src/content/dependencies/linkContribution.js | 5 +++-- src/content/dependencies/linkExternal.js | 14 +++++++++++--- src/content/dependencies/linkExternalAsIcon.js | 21 ++++++++++++++++----- src/content/dependencies/linkExternalFlash.js | 4 ++++ 10 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js index d6405283..4b819091 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -94,7 +94,7 @@ export default { links: language.formatDisjunctionList( relations.externalLinks - .map(link => link.slot('mode', 'album'))), + .map(link => link.slot('context', 'album'))), })), ]); }, diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js index 331ddaba..f3705450 100644 --- a/src/content/dependencies/generateAlbumSidebarGroupBox.js +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -66,7 +66,10 @@ export default { !empty(relations.externalLinks) && html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(relations.externalLinks), + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'group'))), })), slots.mode === 'album' && diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index 03bc0af5..ac9209a7 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -161,7 +161,10 @@ export default { sec.visit && html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(sec.visit.externalLinks), + links: + language.formatDisjunctionList( + sec.visit.externalLinks + .map(link => link.slot('context', 'artist'))), })), sec.artworks?.artistGalleryLink && diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 09c6b37c..c60f9696 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -133,7 +133,7 @@ export default { links: language.formatDisjunctionList( relations.externalLinks - .map(link => link.slot('mode', 'flash'))), + .map(link => link.slot('context', 'flash'))), })), sec.featuredTracks && [ diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 0583755e..05df33fb 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -107,7 +107,10 @@ export default { sec.info.visitLinks && html.tag('p', language.$('releaseInfo.visitOn', { - links: language.formatDisjunctionList(sec.info.visitLinks), + links: + language.formatDisjunctionList( + sec.info.visitLinks + .map(link => link.slot('context', 'group'))), })), html.tag('blockquote', diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js index 9a7478ca..c347dbce 100644 --- a/src/content/dependencies/generateTrackReleaseInfo.js +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -77,7 +77,10 @@ export default { html.tag('p', (relations.externalLinks ? language.$('releaseInfo.listenOn', { - links: language.formatDisjunctionList(relations.externalLinks), + links: + language.formatDisjunctionList( + relations.externalLinks + .map(link => link.slot('context', 'track'))), }) : language.$('releaseInfo.listenOn.noLinks', { name: html.tag('i', data.name), diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index ef61c766..790afa4f 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -58,7 +58,8 @@ export default { }, language.formatUnitList( relations.artistIcons - .slice(0, 4))); + .slice(0, 4) + .map(icon => icon.slot('context', 'artist')))); } let content = language.formatString(parts.join('.'), options); @@ -79,7 +80,7 @@ export default { class: 'icons-tooltip-content', }, relations.artistIcons - .map(icon => icon.slot('withText', true)))), + .map(icon => icon.slots({context: 'artist', withText: true})))), ]; } diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 1b81efcc..e51ea89e 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,3 +1,5 @@ +import {isExternalLinkContext} from '#external-links'; + export default { extraDependencies: ['html', 'language', 'wikiData'], @@ -16,8 +18,11 @@ export default { }, slots: { - mode: { - validate: v => v.is('generic', 'album', 'flash'), + context: { + // This awkward syntax is because the slot descriptor validator can't + // differentiate between a function that returns a validator (the usual + // syntax) and a function that is itself a validator. + validate: () => isExternalLinkContext, default: 'generic', }, }, @@ -26,7 +31,10 @@ export default { return ( html.tag('a', {href: data.url, class: 'nowrap'}, - language.formatExternalLink(data.url, {style: 'platform'}))); + language.formatExternalLink(data.url, { + style: 'platform', + context: slots.context, + }))); }, /* diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js index 58bd896d..357c835c 100644 --- a/src/content/dependencies/linkExternalAsIcon.js +++ b/src/content/dependencies/linkExternalAsIcon.js @@ -1,21 +1,32 @@ +import {isExternalLinkContext} from '#external-links'; + export default { extraDependencies: ['html', 'language', 'to'], data: (url) => ({url}), slots: { + context: { + // This awkward syntax is because the slot descriptor validator can't + // differentiate between a function that returns a validator (the usual + // syntax) and a function that is itself a validator. + validate: () => isExternalLinkContext, + default: 'generic', + }, + withText: {type: 'boolean'}, }, generate(data, slots, {html, language, to}) { - const {url} = data; + const format = style => + language.formatExternalLink(data.url, {style, context: slots.context}); - const normalText = language.formatExternalLink(url, {style: 'normal'}); - const compactText = language.formatExternalLink(url, {style: 'compact'}); - const iconId = language.formatExternalLink(url, {style: 'icon-id'}); + const normalText = format('normal'); + const compactText = format('compact'); + const iconId = format('icon-id'); return html.tag('a', - {href: url, class: ['icon', slots.withText && 'has-text']}, + {href: data.url, class: ['icon', slots.withText && 'has-text']}, [ html.tag('svg', [ !slots.withText && diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js index 65158ff8..e2147da1 100644 --- a/src/content/dependencies/linkExternalFlash.js +++ b/src/content/dependencies/linkExternalFlash.js @@ -1,6 +1,8 @@ // Note: This function is seriously hard-coded for HSMusic, with custom // presentation of links to Homestuck flashes hosted various places. +// This also appears to be dead code, apart from a single snapshot test?? + export default { contentDependencies: ['linkExternal'], extraDependencies: ['html', 'language'], @@ -22,6 +24,8 @@ export default { const {link} = relations; const {url, page} = data; + link.setSlot('context', 'flash'); + return html.tag('span', {class: 'nowrap'}, -- cgit 1.3.0-6-gf8a5 From ba6c4e043b3364481ac3beff1e2a141d1bfcf6fb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 20:47:34 -0400 Subject: external-links: cleaner per-style logic --- src/data/things/language.js | 16 +-- src/strings-default.yaml | 19 ++- src/util/external-links.js | 340 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 282 insertions(+), 93 deletions(-) diff --git a/src/data/things/language.js b/src/data/things/language.js index f83b4218..70481299 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -2,6 +2,7 @@ import {isLanguageCode} from '#validators'; import {Tag} from '#html'; import { + getExternalLinkStringOfStyleFromDescriptors, getExternalLinkStringsFromDescriptors, isExternalLinkContext, isExternalLinkSpec, @@ -321,20 +322,19 @@ export class Language extends Thing { throw new TypeError(`externalLinkSpec unavailable`); } - if (style !== 'all') isExternalLinkStyle(style); isExternalLinkContext(context); - const results = - getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + if (style === 'all') { + return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { language: this, context, }); - - if (style === 'all') { - return results; - } else { - return results[style]; } + + return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + language: this, + context, + }); } formatIndex(value) { diff --git a/src/strings-default.yaml b/src/strings-default.yaml index 698e3c9f..d0d46998 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -407,10 +407,10 @@ misc: external: "External" withDomain: - "{PLACE} ({DOMAIN})" + "{PLATFORM} ({DOMAIN})" withHandle: - "{PLACE} ({HANDLE})" + "{PLATFORM} ({HANDLE})" local: "Wiki Archive (local upload)" @@ -421,7 +421,12 @@ misc: flash: "bgreco.net (high quality audio)" deviantart: "DeviantArt" - homestuck: "Homestuck" + + homestuck: + _: "Homestuck" + page: "Homestuck (page {PAGE})" + secretPage: "Homestuck (secret page)" + instagram: "Instagram" mastodon: "Mastodon" newgrounds: "Newgrounds" @@ -439,14 +444,6 @@ misc: playlist: "YouTube (playlist)" fullAlbum: "YouTube (full album)" - # flashLink: - # Flashes can be positioned by page! They're accented with this - # information, if available. - - flashLink: - page: "{LINK} (page {PAGE})" - secret: "{LINK} (secret page)" - # missingImage: # Fallback text displayed in an image when it's sourced to a file # that isn't available under the wiki's media directory. While it diff --git a/src/util/external-links.js b/src/util/external-links.js index 07f46bd3..a0301c9c 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -2,6 +2,7 @@ import {empty, stitchArrays} from '#sugar'; import { is, + isObject, isStringNonEmpty, optional, validateArrayItems, @@ -33,13 +34,14 @@ export const isExternalLinkContext = is(...externalLinkContexts); const isRegExp = validateInstanceOf(RegExp); -export const isExternalLinkHandleSpec = +export const isExternalLinkExtractSpec = validateProperties({ prefix: optional(isStringNonEmpty), url: optional(isRegExp), domain: optional(isRegExp), pathname: optional(isRegExp), + query: optional(isRegExp), }); export const isExternalLinkSpec = @@ -63,12 +65,16 @@ export const isExternalLinkSpec = string: isStringNonEmpty, - // TODO: Don't allow 'handle' options if handle isn't provided - normal: optional(is('domain', 'handle')), - compact: optional(is('domain', 'handle')), + // 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(isExternalLinkHandleSpec), + handle: optional(isExternalLinkExtractSpec), + + // TODO: This should validate each value with isExternalLinkExtractSpec. + custom: optional(isObject), })); export const fallbackDescriptor = { @@ -145,6 +151,38 @@ export const externalLinkSpec = [ icon: 'external', }, + // This takes precedence over the secretPage match below. + { + match: { + context: 'flash', + domain: 'homestuck.com', + pathname: /^story\/[0-9]+\/?$/, + }, + + platform: 'homestuck', + string: 'homestuck.page', + icon: 'globe', + + normal: 'custom', + + custom: { + page: { + pathname: /[0-9]+/, + }, + }, + }, + + { + match: { + context: 'flash', + domain: 'homestuck.com', + pathname: /^story\/.+\/?$/, + }, + + string: 'homestuck.secretPage', + icon: 'globe', + }, + { match: { context: 'flash', @@ -277,6 +315,10 @@ function urlParts(url) { return {domain, pathname, query}; } +function createEmptyResults() { + return Object.fromEntries(externalLinkStyles.map(style => [style, null])); +} + export function getMatchingDescriptorsForExternalLink(url, descriptors, { context = 'generic', } = {}) { @@ -311,107 +353,257 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { return [...matchingDescriptors, fallbackDescriptor]; } -export function getExternalLinkStringsFromDescriptor(url, descriptor, { - language, -}) { - const prefix = 'misc.external'; +export function extractPartFromExternalLink(url, extract) { + const {domain, pathname, query} = urlParts(url); - const results = - Object.fromEntries(externalLinkStyles.map(style => [style, null])); + let regexen = []; + let tests = []; + let prefix = ''; + + if (extract instanceof RegExp) { + regexen.push(descriptor.handle); + tests.push(url); + } else { + for (const [key, value] of Object.entries(extract)) { + switch (key) { + case 'prefix': + prefix = value; + 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)); + + default: + tests.push(''); + break; + } - const {domain, pathname, query} = urlParts(url); + regexen.push(value); + } + } - const place = language.$(prefix, descriptor.string); + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + return prefix + (match[1] ?? match[0]); + } + } - results['platform'] = place; + return null; +} - if (descriptor.icon) { - results['icon-id'] = descriptor.icon; +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; } - if (descriptor.normal === 'domain') { - results['normal'] = language.$(prefix, 'withDomain', {place, domain}); + return customParts; +} + +const prefix = 'misc.external'; + +export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { + function getPlatform() { + if (descriptor.custom) { + return null; + } + + return language.$(prefix, descriptor.string); } - if (descriptor.compact === 'domain') { - results['compact'] = language.sanitize(domain.replace(/^www\./, '')); + function getDomain() { + return urlParts(url).domain; } - let handle = null; + function getCustom() { + if (!descriptor.custom) { + return null; + } - if (descriptor.handle) { - let regexen = []; - let tests = []; + const customParts = + extractAllCustomPartsFromExternalLink(url, descriptor.custom); - let handlePrefix = ''; + if (!customParts) { + return null; + } - if (descriptor.handle instanceof RegExp) { - regexen.push(descriptor.handle); - tests.push(url); - } else { - for (const [key, value] of Object.entries(descriptor.handle)) { - switch (key) { - case 'prefix': - handlePrefix = value; - continue; - - case 'url': - tests.push(url); - break; - - case 'domain': - case 'hostname': - tests.push(domain); - break; - - case 'path': - case 'pathname': - tests.push(pathname.slice(1) + query); - break; - - default: - tests.push(''); - break; - } - - regexen.push(value); + return language.$(prefix, descriptor.string, customParts); + } + + function getHandle() { + if (!descriptor.handle) { + return null; + } + + return extractPartFromExternalLink(url, descriptor.handle); + } + + function getNormal() { + if (descriptor.custom) { + if (descriptor.normal === 'custom') { + return getCustom(); + } else { + return null; + } + } + + if (descriptor.normal === 'domain') { + const platform = getPlatform(); + const domain = getDomain(); + + if (!platform || !domain) { + return null; } + + return language.$(prefix, 'withDomain', {platform, domain}); } - for (const {regex, test} of stitchArrays({ - regex: regexen, - test: tests, - })) { - const match = test.match(regex); - if (match) { - handle = handlePrefix + (match[1] ?? match[0]); - break; + 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.string); } - if (descriptor.compact === 'handle') { - results.compact = language.sanitize(handle); + function getCompact() { + if (descriptor.custom) { + if (descriptor.compact === 'custom') { + return getCustom(); + } else { + return null; + } + } + + if (descriptor.compact === 'domain') { + const domain = getDomain(); + + if (!domain) { + 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; } - if (descriptor.normal === 'handle' && handle) { - results.normal = language.$(prefix, 'withHandle', {place, handle}); + switch (style) { + case 'normal': return getNormal(); + case 'compact': return getCompact(); + case 'platform': return getPlatform(); + case 'icon-id': return getIconId(); } +} - results.normal ??= language.$(prefix, descriptor.string); +export function couldDescriptorSupportStyle(descriptor, style) { + if (style === 'platform') { + return !descriptor.custom; + } - return results; + if (style === 'icon-id') { + return !!descriptor.icon; + } + + 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; + } + } } -export function getExternalLinkStringsFromDescriptors(url, descriptors, { +export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { language, context = 'generic', }) { - const results = - Object.fromEntries(externalLinkStyles.map(style => [style, null])); + const matchingDescriptors = + getMatchingDescriptorsForExternalLink(url, descriptors, {context}); + + console.log('match-filtered:', matchingDescriptors); + + const styleFilteredDescriptors = + matchingDescriptors.filter(descriptor => + couldDescriptorSupportStyle(descriptor, style)); + + console.log('style-filtered:', styleFilteredDescriptors); + + for (const descriptor of styleFilteredDescriptors) { + const descriptorResult = + getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); - const remainingKeys = - new Set(Object.keys(results)); + if (descriptorResult) { + return descriptorResult; + } + } + + return null; +} + +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'), + }; +} + +export function getExternalLinkStringsFromDescriptors(url, descriptors, { + language, + context = 'generic', +}) { + const results = createEmptyResults(); + const remainingKeys = new Set(Object.keys(results)); const matchingDescriptors = getMatchingDescriptorsForExternalLink(url, descriptors, {context}); -- cgit 1.3.0-6-gf8a5 From face11b98dbaa866055718b7731f61a21fcf9088 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:01:27 -0400 Subject: content: linkExternal: make direct wrapper for formatExternalLink --- src/content/dependencies/linkExternal.js | 78 ++++++-------------------------- 1 file changed, 15 insertions(+), 63 deletions(-) diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index e51ea89e..4941e48a 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,78 +1,30 @@ -import {isExternalLinkContext} from '#external-links'; +import {isExternalLinkContext, isExternalLinkStyle} from '#external-links'; export default { extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({wikiInfo}), - - data(sprawl, url) { - const data = {url}; - - const {canonicalBase} = sprawl.wikiInfo; - if (canonicalBase) { - const {hostname: canonicalDomain} = new URL(canonicalBase); - Object.assign(data, {canonicalDomain}); - } - - return data; - }, + data: (url) => ({url}), slots: { - context: { + style: { // This awkward syntax is because the slot descriptor validator can't // differentiate between a function that returns a validator (the usual // syntax) and a function that is itself a validator. + validate: () => isExternalLinkStyle, + default: 'platform', + }, + + context: { validate: () => isExternalLinkContext, default: 'generic', }, }, - generate(data, slots, {html, language}) { - return ( - html.tag('a', - {href: data.url, class: 'nowrap'}, - language.formatExternalLink(data.url, { - style: 'platform', - context: slots.context, - }))); - }, - - /* - : domain.includes('youtu') - ? slots.mode === 'album' - ? data.url.includes('list=') - ? language.$('misc.external.youtube.playlist') - : language.$('misc.external.youtube.fullAlbum') - : language.$('misc.external.youtube') - - switch (slots.mode) { - case 'flash': { - const wrap = content => - html.tag('span', {class: 'nowrap'}, content); - - if (domain.includes('homestuck.com')) { - const match = pathname.match(/\/story\/(.*)\/?/); - if (match) { - if (isNaN(Number(match[1]))) { - return wrap(language.$('misc.external.flash.homestuck.secret', {link})); - } else { - return wrap(language.$('misc.external.flash.homestuck.page', { - link, - page: match[1], - })); - } - } - } else if (domain.includes('bgreco.net')) { - return wrap(language.$('misc.external.flash.bgreco', {link})); - } else if (domain.includes('youtu')) { - return wrap(language.$('misc.external.flash.youtube', {link})); - } - - return link; - } - - default: - return link; - } - */ + generate: (data, slots, {html, language}) => + html.tag('a', + {href: data.url, class: 'nowrap'}, + language.formatExternalLink(data.url, { + style: slots.style, + context: slots.context, + })), }; -- cgit 1.3.0-6-gf8a5 From 370aab15cb6ba60c95b33f7c4a1ed9b6daf51d98 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:02:07 -0400 Subject: content: generateFlashInfoPage: use 'normal' style links --- src/content/dependencies/generateFlashInfoPage.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index c60f9696..919996a2 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -132,8 +132,11 @@ export default { language.$('releaseInfo.playOn', { links: language.formatDisjunctionList( - relations.externalLinks - .map(link => link.slot('context', 'flash'))), + relations.externalLinks.map(link => + link.slots({ + context: 'flash', + style: 'normal', + }))), })), sec.featuredTracks && [ -- cgit 1.3.0-6-gf8a5 From 841daeb4a29657485488ac55a743492b010658de Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:03:17 -0400 Subject: external-links: spec in terms of platform + substring --- src/util/external-links.js | 115 +++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/src/util/external-links.js b/src/util/external-links.js index a0301c9c..c8cb1670 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -63,7 +63,8 @@ export const isExternalLinkSpec = context: optional(isExternalLinkContext), }), - string: isStringNonEmpty, + platform: isStringNonEmpty, + substring: optional(isStringNonEmpty), // TODO: Don't allow 'handle' or 'custom' options if the corresponding // properties aren't provided @@ -78,7 +79,7 @@ export const isExternalLinkSpec = })); export const fallbackDescriptor = { - string: 'external', + platform: 'external', normal: 'domain', compact: 'domain', @@ -96,7 +97,9 @@ export const externalLinkSpec = [ pathname: /^playlist/, }, - string: 'youtube.playlist', + platform: 'youtube', + substring: 'playlist', + icon: 'youtube', }, @@ -107,7 +110,9 @@ export const externalLinkSpec = [ pathname: /^watch/, }, - string: 'youtube.fullAlbum', + platform: 'youtube', + substring: 'fullAlbum', + icon: 'youtube', }, @@ -117,7 +122,9 @@ export const externalLinkSpec = [ domain: 'youtu.be', }, - string: 'youtube.fullAlbum', + platform: 'youtube', + substring: 'fullAlbum', + icon: 'youtube', }, @@ -129,10 +136,11 @@ export const externalLinkSpec = [ domains: ['youtube.com', 'youtu.be'], }, - string: 'youtube', - icon: 'youtube', + platform: 'youtube', + normal: 'handle', compact: 'handle', + icon: 'youtube', handle: { pathname: /^(@.*?)\/?$/, @@ -147,7 +155,9 @@ export const externalLinkSpec = [ domain: 'bgreco.net', }, - string: 'bgreco.flash', + platform: 'bgreco', + substring: 'flash', + icon: 'external', }, @@ -160,10 +170,10 @@ export const externalLinkSpec = [ }, platform: 'homestuck', - string: 'homestuck.page', - icon: 'globe', + substring: 'page', normal: 'custom', + icon: 'globe', custom: { page: { @@ -179,7 +189,9 @@ export const externalLinkSpec = [ pathname: /^story\/.+\/?$/, }, - string: 'homestuck.secretPage', + platform: 'homestuck', + substring: 'secretPage', + icon: 'globe', }, @@ -189,7 +201,9 @@ export const externalLinkSpec = [ domains: ['youtube.com', 'youtu.be'], }, - string: 'youtube.flash', + platform: 'youtube', + substring: 'flash', + icon: 'youtube', }, @@ -198,17 +212,17 @@ export const externalLinkSpec = [ { match: {domains: ['bc.s3m.us', 'music.solatrux.com']}, - icon: 'bandcamp', - string: 'bandcamp', + platform: 'bandcamp', normal: 'domain', compact: 'domain', + icon: 'bandcamp', }, { match: {domain: 'bandcamp.com'}, - string: 'bandcamp', + platform: 'bandcamp', compact: 'handle', icon: 'bandcamp', @@ -219,28 +233,31 @@ export const externalLinkSpec = [ { match: {domain: 'deviantart.com'}, - string: 'deviantart', + platform: 'deviantart', + icon: 'deviantart', }, { match: {domain: 'instagram.com'}, - string: 'instagram', + platform: 'instagram', + icon: 'instagram', }, { match: {domain: 'homestuck.com'}, - string: 'homestuck', - icon: 'globe', // The horror! + platform: 'homestuck', + + icon: 'globe', }, { match: {domain: 'hsmusic.wiki'}, - string: 'local', + platform: 'local', icon: 'globe', }, @@ -248,37 +265,38 @@ export const externalLinkSpec = [ { match: {domains: ['types.pl']}, - icon: 'mastodon', - string: 'mastodon', + platform: 'mastodon', compact: 'domain', + icon: 'mastodon', }, { match: {domain: 'newgrounds.com'}, - string: 'newgrounds', + platform: 'newgrounds', + icon: 'newgrounds', }, { match: {domain: 'soundcloud.com'}, - string: 'soundcloud', - icon: 'soundcloud', + platform: 'soundcloud', compact: 'handle', + icon: 'soundcloud', - handle: /[^/]*\/?$/, + handle: /([^/]*)\/?$/, }, { match: {domain: 'tumblr.com'}, - string: 'tumblr', - icon: 'tumblr', + platform: 'tumblr', compact: 'handle', + icon: 'tumblr', handle: {domain: /^[^.]*/}, }, @@ -286,10 +304,10 @@ export const externalLinkSpec = [ { match: {domain: 'twitter.com'}, - string: 'twitter', - icon: 'twitter', + platform: 'twitter', compact: 'handle', + icon: 'twitter', handle: { prefix: '@', @@ -300,7 +318,8 @@ export const externalLinkSpec = [ { match: {domains: ['youtube.com', 'youtu.be']}, - string: 'youtube', + platform: 'youtube', + icon: 'youtube', }, ]; @@ -419,15 +438,11 @@ export function extractAllCustomPartsFromExternalLink(url, custom) { return customParts; } -const prefix = 'misc.external'; - export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { - function getPlatform() { - if (descriptor.custom) { - return null; - } + const prefix = 'misc.external'; - return language.$(prefix, descriptor.string); + function getPlatform() { + return language.$(prefix, descriptor.platform); } function getDomain() { @@ -446,7 +461,7 @@ export function getExternalLinkStringOfStyleFromDescriptor(url, style, descripto return null; } - return language.$(prefix, descriptor.string, customParts); + return language.$(prefix, descriptor.platform, descriptor.substring, customParts); } function getHandle() { @@ -488,7 +503,7 @@ export function getExternalLinkStringOfStyleFromDescriptor(url, style, descripto return language.$(prefix, 'withHandle', {platform, handle}); } - return language.$(prefix, descriptor.string); + return language.$(prefix, descriptor.platform, descriptor.substring); } function getCompact() { @@ -534,14 +549,6 @@ export function getExternalLinkStringOfStyleFromDescriptor(url, style, descripto } export function couldDescriptorSupportStyle(descriptor, style) { - if (style === 'platform') { - return !descriptor.custom; - } - - if (style === 'icon-id') { - return !!descriptor.icon; - } - if (style === 'normal') { if (descriptor.custom) { return descriptor.normal === 'custom'; @@ -557,6 +564,14 @@ export function couldDescriptorSupportStyle(descriptor, style) { return !!descriptor.compact; } } + + if (style === 'platform') { + return true; + } + + if (style === 'icon-id') { + return !!descriptor.icon; + } } export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { @@ -566,14 +581,10 @@ export function getExternalLinkStringOfStyleFromDescriptors(url, style, descript const matchingDescriptors = getMatchingDescriptorsForExternalLink(url, descriptors, {context}); - console.log('match-filtered:', matchingDescriptors); - const styleFilteredDescriptors = matchingDescriptors.filter(descriptor => couldDescriptorSupportStyle(descriptor, style)); - console.log('style-filtered:', styleFilteredDescriptors); - for (const descriptor of styleFilteredDescriptors) { const descriptorResult = getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); -- cgit 1.3.0-6-gf8a5 From db786c25a9fafc4cac37b108b4ea433019741c07 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 21:33:15 -0400 Subject: content, external-links: minor fixes --- src/content/dependencies/generateAlbumReleaseInfo.js | 6 +++++- src/util/external-links.js | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js index 4b819091..dd5baab9 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -94,7 +94,11 @@ export default { links: language.formatDisjunctionList( relations.externalLinks - .map(link => link.slot('context', 'album'))), + .map(link => + link.slots({ + context: 'album', + style: 'normal', + }))), })), ]); }, diff --git a/src/util/external-links.js b/src/util/external-links.js index c8cb1670..07a83bc1 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -133,7 +133,7 @@ export const externalLinkSpec = [ { match: { context: 'artist', - domains: ['youtube.com', 'youtu.be'], + domain: 'youtube.com', }, platform: 'youtube', @@ -158,7 +158,7 @@ export const externalLinkSpec = [ platform: 'bgreco', substring: 'flash', - icon: 'external', + icon: 'globe', }, // This takes precedence over the secretPage match below. -- cgit 1.3.0-6-gf8a5 From 3898fbe9380c7a8bc745eff548b112ad2e9c605b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:40:52 -0400 Subject: content, test: remove unused linkExternalFlash function --- src/content/dependencies/linkExternalFlash.js | 45 ---------------------- .../test/snapshot/linkExternalFlash.js.test.cjs | 18 --------- test/snapshot/linkExternalFlash.js | 24 ------------ 3 files changed, 87 deletions(-) delete mode 100644 src/content/dependencies/linkExternalFlash.js delete mode 100644 tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs delete mode 100644 test/snapshot/linkExternalFlash.js diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js deleted file mode 100644 index e2147da1..00000000 --- a/src/content/dependencies/linkExternalFlash.js +++ /dev/null @@ -1,45 +0,0 @@ -// Note: This function is seriously hard-coded for HSMusic, with custom -// presentation of links to Homestuck flashes hosted various places. - -// This also appears to be dead code, apart from a single snapshot test?? - -export default { - contentDependencies: ['linkExternal'], - extraDependencies: ['html', 'language'], - - relations(relation, url) { - return { - link: relation('linkExternal', url), - }; - }, - - data(url, flash) { - return { - url, - page: flash.page, - }; - }, - - generate(data, relations, {html, language}) { - const {link} = relations; - const {url, page} = data; - - link.setSlot('context', 'flash'); - - return html.tag('span', - {class: 'nowrap'}, - - url.includes('homestuck.com') - ? isNaN(Number(page)) - ? language.$('misc.external.flash.homestuck.secret', {link}) - : language.$('misc.external.flash.homestuck.page', {link, page}) - - : url.includes('bgreco.net') - ? language.$('misc.external.flash.bgreco', {link}) - - : url.includes('youtu') - ? language.$('misc.external.flash.youtube', {link}) - - : link); - }, -}; diff --git a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs b/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs deleted file mode 100644 index d29d0dde..00000000 --- a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs +++ /dev/null @@ -1,18 +0,0 @@ -/* IMPORTANT - * This snapshot file is auto-generated, but designed for humans. - * It should be checked into source control and tracked carefully. - * Re-generate by setting TAP_SNAPSHOT=1 and running tests. - * Make sure to inspect the output below. Do not ignore changes! - */ -'use strict' -exports[`test/snapshot/linkExternalFlash.js > TAP > linkExternalFlash (snapshot) > basic behavior 1`] = ` -homestuck.com (page 4109) -YouTube (on any device) -www.bgreco.net (HQ Audio) -Newgrounds -` - -exports[`test/snapshot/linkExternalFlash.js > TAP > linkExternalFlash (snapshot) > secret page 1`] = ` -homestuck.com (secret page) -YouTube (on any device) -` diff --git a/test/snapshot/linkExternalFlash.js b/test/snapshot/linkExternalFlash.js deleted file mode 100644 index a4d44aff..00000000 --- a/test/snapshot/linkExternalFlash.js +++ /dev/null @@ -1,24 +0,0 @@ -import t from 'tap'; -import {testContentFunctions} from '#test-lib'; - -testContentFunctions(t, 'linkExternalFlash (snapshot)', async (t, evaluate) => { - await evaluate.load(); - - evaluate.snapshot('basic behavior', { - name: 'linkExternalFlash', - multiple: [ - {args: ['https://homestuck.com/story/4109/', {page: '4109'}]}, - {args: ['https://youtu.be/FDt-SLyEcjI', {page: '4109'}]}, - {args: ['https://www.bgreco.net/hsflash/006009.html', {page: '4109'}]}, - {args: ['https://www.newgrounds.com/portal/view/582345', {page: '4109'}]}, - ], - }); - - evaluate.snapshot('secret page', { - name: 'linkExternalFlash', - multiple: [ - {args: ['https://homestuck.com/story/pony/', {page: 'pony'}]}, - {args: ['https://youtu.be/USB1pj6hAjU', {page: 'pony'}]}, - ], - }); -}); -- cgit 1.3.0-6-gf8a5 From 153a9f927073d69773af9bf30a53e94cac91d5a9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:42:14 -0400 Subject: test: generateAlbumReleaseInfo: update snapshot --- tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs index 3335a2eb..e9dbfea2 100644 --- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs @@ -7,11 +7,9 @@ 'use strict' exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `

- By Toby Fox (music probably) and Tensei (hot jams) - - Bandcamp - - + By Toby Fox (music probably) and Tensei (hot jams) + + tenseimusic .
Cover art by Hanni Brosh. -- cgit 1.3.0-6-gf8a5 From 803a17296249e1521089451c9d077cc524b4acf5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:43:34 -0400 Subject: external-links: minor code fixes --- src/util/external-links.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/external-links.js b/src/util/external-links.js index 07a83bc1..dee65cc5 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -380,7 +380,7 @@ export function extractPartFromExternalLink(url, extract) { let prefix = ''; if (extract instanceof RegExp) { - regexen.push(descriptor.handle); + regexen.push(extract); tests.push(url); } else { for (const [key, value] of Object.entries(extract)) { @@ -403,6 +403,7 @@ export function extractPartFromExternalLink(url, extract) { case 'query': tests.push(query.slice(1)); + break; default: tests.push(''); -- cgit 1.3.0-6-gf8a5 From 4507d7a8e42d3f7fd8c6cf4b1d9f270370078b08 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 13:50:27 -0400 Subject: test: linkContribution: update snapshot --- .../test/snapshot/linkContribution.js.test.cjs | 112 ++++++++------------- 1 file changed, 42 insertions(+), 70 deletions(-) diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs index 4cf3aa3f..acb8faf4 100644 --- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs @@ -30,46 +30,30 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = ` -Lorem Ipsum Lover - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - - - - External (loremipsum.io) - - +Lorem Ipsum Lover + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io + + + loremipsum.io ` @@ -128,23 +112,17 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = ` -Clark Powell - - SoundCloud - - +Clark Powell + + plazmataz Grounder & Scratch (Snooping) -Toby Fox (Arrangement) - - Bandcamp - - - - - External (toby.fox) - - +Toby Fox (Arrangement) + + tobyfox + + + toby.fox ` @@ -170,22 +148,16 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > ` exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = ` -Clark Powell - - SoundCloud - - +Clark Powell + + plazmataz Grounder & Scratch (Snooping) -Toby Fox (Arrangement) - - Bandcamp - - - - - External (toby.fox) - - +Toby Fox (Arrangement) + + tobyfox + + + toby.fox ` -- cgit 1.3.0-6-gf8a5 From ad1ae12ab182dd50cf3ca6ec653d371d77b5fabb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:14:27 -0400 Subject: external-links: quick spec tweaks --- src/util/external-links.js | 77 +++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/util/external-links.js b/src/util/external-links.js index dee65cc5..0a4a77cf 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -4,6 +4,7 @@ import { is, isObject, isStringNonEmpty, + oneOf, optional, validateArrayItems, validateInstanceOf, @@ -60,7 +61,10 @@ export const isExternalLinkSpec = query: optional(isRegExp), queries: optional(validateArrayItems(isRegExp)), - context: optional(isExternalLinkContext), + context: + optional(oneOf( + isExternalLinkContext, + validateArrayItems(isExternalLinkContext))), }), platform: isStringNonEmpty, @@ -130,6 +134,21 @@ export const externalLinkSpec = [ // Special handling for artist links + { + match: { + domain: 'patreon.com', + context: 'artist', + }, + + platform: 'patreon', + + normal: 'handle', + compact: 'handle', + icon: 'globe', + + handle: /([^/]*)\/?$/, + }, + { match: { context: 'artist', @@ -210,7 +229,7 @@ export const externalLinkSpec = [ // Generic domains, sorted alphabetically (by string) { - match: {domains: ['bc.s3m.us', 'music.solatrux.com']}, + match: {domains: ['bc.s3m.us', 'music.solatrus.com']}, platform: 'bandcamp', @@ -220,7 +239,7 @@ export const externalLinkSpec = [ }, { - match: {domain: 'bandcamp.com'}, + match: {domain: '.bandcamp.com'}, platform: 'bandcamp', @@ -232,53 +251,56 @@ export const externalLinkSpec = [ { match: {domain: 'deviantart.com'}, - platform: 'deviantart', - icon: 'deviantart', }, - { - match: {domain: 'instagram.com'}, - - platform: 'instagram', - - icon: 'instagram', - }, - { match: {domain: 'homestuck.com'}, - platform: 'homestuck', - icon: 'globe', }, { match: {domain: 'hsmusic.wiki'}, - platform: 'local', - icon: 'globe', }, + { + match: {domain: 'instagram.com'}, + platform: 'instagram', + icon: 'instagram', + }, + { match: {domains: ['types.pl']}, platform: 'mastodon', + normal: 'domain', compact: 'domain', icon: 'mastodon', }, { match: {domain: 'newgrounds.com'}, - platform: 'newgrounds', - icon: 'newgrounds', }, + { + match: {domain: 'patreon.com'}, + platform: 'patreon', + icon: 'globe', + }, + + { + match: {domain: 'poetryfoundation.org'}, + platform: 'poetryFoundation', + icon: 'globe', + }, + { match: {domain: 'soundcloud.com'}, @@ -291,7 +313,13 @@ export const externalLinkSpec = [ }, { - match: {domain: 'tumblr.com'}, + match: {domain: 'spotify.com'}, + platform: 'spotify', + icon: 'globe', + }, + + { + match: {domain: '.tumblr.com'}, platform: 'tumblr', @@ -316,10 +344,14 @@ export const externalLinkSpec = [ }, { - match: {domains: ['youtube.com', 'youtu.be']}, + match: {domain: 'wikipedia.org'}, + platform: 'wikipedia', + icon: 'misc', + }, + { + match: {domains: ['youtube.com', 'youtu.be']}, platform: 'youtube', - icon: 'youtube', }, ]; @@ -355,6 +387,7 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { return false; }) .filter(({match}) => { + if (Array.isArray(match.context)) return match.context.includes(context); if (match.context) return context === match.context; return true; }) -- cgit 1.3.0-6-gf8a5 From 25956ed1bbc7b894a0921530a2d7cbb892cb56c5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:14:53 -0400 Subject: test: linkExternal: update snapshot and loosely tidy test --- .../test/snapshot/linkExternal.js.test.cjs | 65 ++++++++++++++---- test/snapshot/linkExternal.js | 78 +++++++++++----------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs index cd6dca76..dd5493c0 100644 --- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs @@ -5,7 +5,41 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > basic domain matches 1`] = ` +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: compact 1`] = ` +youtu.be +youtube.com +youtube.com +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: normal 1`] = ` +YouTube (full album) +YouTube (full album) +YouTube +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: platform 1`] = ` +YouTube +YouTube +YouTube +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: compact 1`] = ` +homestuck +plazmataz +aeritus +@awkwarddoesart +deviantart.com +en.wikipedia.org +poetryfoundation.org +instagram.com +patreon.com +open.spotify.com +buzinkai.newgrounds.com +music.solatrus.com +types.pl +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: normal 1`] = ` Bandcamp SoundCloud Tumblr @@ -17,23 +51,26 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > basic d Patreon Spotify Newgrounds -` - -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom domains for common platforms 1`] = ` -music.solatrus.com +Bandcamp (music.solatrus.com) Mastodon (types.pl) ` -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom matches - album 1`] = ` -YouTube (full album) -YouTube (full album) -YouTube (playlist) -` - -exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > missing domain (arbitrary local path) 1`] = ` -Wiki Archive (local upload) +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: platform 1`] = ` +Bandcamp +SoundCloud +Tumblr +Twitter +DeviantArt +Wikipedia +Poetry Foundation +Instagram +Patreon +Spotify +Newgrounds +Bandcamp +Mastodon ` exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > unknown domain (arbitrary world wide web path) 1`] = ` -snoo.ping.as +External ` diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js index 3e8aee0d..f9877aad 100644 --- a/test/snapshot/linkExternal.js +++ b/test/snapshot/linkExternal.js @@ -4,51 +4,49 @@ import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => { await evaluate.load(); - evaluate.snapshot('missing domain (arbitrary local path)', { - name: 'linkExternal', - args: ['/foo/bar/baz.mp3'] - }); - evaluate.snapshot('unknown domain (arbitrary world wide web path)', { name: 'linkExternal', args: ['https://snoo.ping.as/usual/i/see/'], }); - evaluate.snapshot('basic domain matches', { - name: 'linkExternal', - multiple: [ - {args: ['https://homestuck.bandcamp.com/']}, - {args: ['https://soundcloud.com/plazmataz']}, - {args: ['https://aeritus.tumblr.com/']}, - {args: ['https://twitter.com/awkwarddoesart']}, - {args: ['https://www.deviantart.com/chesswanderlust-sama']}, - {args: ['https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)']}, - {args: ['https://www.poetryfoundation.org/poets/christina-rossetti']}, - {args: ['https://www.instagram.com/levc_egm/']}, - {args: ['https://www.patreon.com/CecilyRenns']}, - {args: ['https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3']}, - {args: ['https://buzinkai.newgrounds.com/']}, - ], - }); + const urlsToArgs = urls => + urls.map(url => ({args: [url]})); - evaluate.snapshot('custom matches - album', { - name: 'linkExternal', - multiple: [ - {args: ['https://youtu.be/abc']}, - {args: ['https://youtube.com/watch?v=abc']}, - {args: ['https://youtube.com/Playlist?list=kweh']}, - ], - slots: { - mode: 'album', - }, - }); + const quickSnapshot = (message, urls, slots) => + evaluate.snapshot(message, { + name: 'linkExternal', + slots, + multiple: urlsToArgs(urls), + }); - evaluate.snapshot('custom domains for common platforms', { - name: 'linkExternal', - multiple: [ - // Just one domain of each platform is OK here - {args: ['https://music.solatrus.com/']}, - {args: ['https://types.pl/']}, - ], - }); + const quickSnapshotAllStyles = (context, urls) => { + for (const style of ['platform', 'normal', 'compact']) { + const message = `context: ${context}, style: ${style}`; + quickSnapshot(message, urls, {context, style}); + } + }; + + quickSnapshotAllStyles('generic', [ + 'https://homestuck.bandcamp.com/', + 'https://soundcloud.com/plazmataz', + 'https://aeritus.tumblr.com/', + 'https://twitter.com/awkwarddoesart', + 'https://www.deviantart.com/chesswanderlust-sama', + 'https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)', + 'https://www.poetryfoundation.org/poets/christina-rossetti', + 'https://www.instagram.com/levc_egm/', + 'https://www.patreon.com/CecilyRenns', + 'https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3', + 'https://buzinkai.newgrounds.com/', + + // Just one custom domain of each platform is OK here + 'https://music.solatrus.com/', + 'https://types.pl/', + ]); + + quickSnapshotAllStyles('album', [ + 'https://youtu.be/abc', + 'https://youtube.com/watch?v=abc', + 'https://youtube.com/Playlist?list=kweh', + ]); }); -- cgit 1.3.0-6-gf8a5 From 1c58d918e8126c4423608e9504a7f0fcbce2a64e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:17:40 -0400 Subject: test: linkExternal: add flash snapshots --- .../test/snapshot/linkExternal.js.test.cjs | 27 ++++++++++++++++++++++ test/snapshot/linkExternal.js | 9 ++++++++ 2 files changed, 36 insertions(+) diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs index dd5493c0..bc022e4c 100644 --- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs +++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs @@ -23,6 +23,33 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context YouTube ` +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: compact 1`] = ` +bgreco.net +homestuck.com +homestuck.com +youtube.com +youtu.be +some.external.site +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: normal 1`] = ` +bgreco.net (high quality audio) +Homestuck (page 1234) +Homestuck (secret page) +YouTube (on any device) +YouTube (on any device) +External (some.external.site) +` + +exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: platform 1`] = ` +bgreco.net +Homestuck +Homestuck +YouTube +YouTube +External +` + exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: compact 1`] = ` homestuck plazmataz diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js index f9877aad..434372a9 100644 --- a/test/snapshot/linkExternal.js +++ b/test/snapshot/linkExternal.js @@ -49,4 +49,13 @@ testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => { 'https://youtube.com/watch?v=abc', 'https://youtube.com/Playlist?list=kweh', ]); + + quickSnapshotAllStyles('flash', [ + 'https://www.bgreco.net/hsflash/002238.html', + 'https://homestuck.com/story/1234', + 'https://homestuck.com/story/pony', + 'https://www.youtube.com/watch?v=wKgOp3Kg2wI', + 'https://youtu.be/IOcvkkklWmY', + 'https://some.external.site/foo/bar/', + ]); }); -- cgit 1.3.0-6-gf8a5 From 45fba07af02d4f161cce494b683918bc76453b82 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 14:39:06 -0400 Subject: content: linkExternal: default to 'normal' style --- src/content/dependencies/generateArtistInfoPage.js | 7 +++++-- src/content/dependencies/generateFlashInfoPage.js | 7 ++----- src/content/dependencies/linkExternal.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index ac9209a7..be9f9b86 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -163,8 +163,11 @@ export default { language.$('releaseInfo.visitOn', { links: language.formatDisjunctionList( - sec.visit.externalLinks - .map(link => link.slot('context', 'artist'))), + sec.visit.externalLinks.map(link => + link.slots({ + context: 'artist', + mode: 'platform', + }))), })), sec.artworks?.artistGalleryLink && diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 919996a2..c60f9696 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -132,11 +132,8 @@ export default { language.$('releaseInfo.playOn', { links: language.formatDisjunctionList( - relations.externalLinks.map(link => - link.slots({ - context: 'flash', - style: 'normal', - }))), + relations.externalLinks + .map(link => link.slot('context', 'flash'))), })), sec.featuredTracks && [ diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 4941e48a..0a079614 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -11,7 +11,7 @@ export default { // differentiate between a function that returns a validator (the usual // syntax) and a function that is itself a validator. validate: () => isExternalLinkStyle, - default: 'platform', + default: 'normal', }, context: { -- cgit 1.3.0-6-gf8a5