diff --git a/tests/tools/optel/oversight/link-facet.test.js b/tests/tools/optel/oversight/link-facet.test.js
index 98bc2063..4d2f3976 100644
--- a/tests/tools/optel/oversight/link-facet.test.js
+++ b/tests/tools/optel/oversight/link-facet.test.js
@@ -1,6 +1,13 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
-import { labelURLParts } from '../../../../tools/optel/oversight/elements/link-facet.js';
+import {
+ appendThumbnail,
+ getFaviconUrl,
+ getOgImageUrl,
+ is404CheckpointActive,
+ isThumbnailUrl,
+ labelURLParts,
+} from '../../../../tools/optel/oversight/elements/link-facet.js';
describe('optel/oversight: labelURLParts', () => {
it('renders parts for a https url', () => {
@@ -39,3 +46,82 @@ describe('optel/oversight: labelURLParts', () => {
);
});
});
+
+describe('optel/oversight: thumbnail helpers', () => {
+ class MockImage {
+ constructor() {
+ this.onload = null;
+ this.onerror = null;
+ this.srcValue = '';
+ MockImage.instances.push(this);
+ }
+
+ set src(value) {
+ this.srcValue = value;
+ }
+
+ get src() {
+ return this.srcValue;
+ }
+ }
+
+ MockImage.instances = [];
+
+ const createContainer = () => ({ children: [], prepend(node) { this.children.unshift(node); } });
+
+ it('detects thumbnail-eligible urls', () => {
+ assert.equal(isThumbnailUrl('https://example.com/path'), true);
+ assert.equal(isThumbnailUrl('http://example.com/path'), true);
+ assert.equal(isThumbnailUrl('android-app://com.example/app'), true);
+ assert.equal(isThumbnailUrl('/products/foo'), false);
+ });
+
+ it('builds og image proxy urls', () => {
+ const url = getOgImageUrl('https://example.com/page');
+ assert.match(url, /^https:\/\/www\.aem\.live\/tools\/rum\/_ogimage\?/);
+ assert.match(url, /proxyurl=https%3A%2F%2Fexample\.com%2Fpage/);
+ });
+
+ it('builds favicon fallback urls', () => {
+ const url = getFaviconUrl('https://example.com/page');
+ assert.match(url, /^https:\/\/www\.google\.com\/s2\/favicons\?/);
+ assert.match(url, /domain=https%3A%2F%2Fexample\.com%2Fpage/);
+ });
+
+ it('skips thumbnails when the 404 checkpoint is active', () => {
+ assert.equal(is404CheckpointActive({ href: 'https://tools.aem.live/tools/optel/oversight/explorer.html?checkpoint=404' }), true);
+ assert.equal(is404CheckpointActive({ href: 'https://tools.aem.live/tools/optel/oversight/explorer.html?checkpoint=enter&checkpoint=404' }), true);
+ assert.equal(is404CheckpointActive({ href: 'https://tools.aem.live/tools/optel/oversight/explorer.html?checkpoint=enter' }), false);
+ });
+
+ it('appends an image only after a successful probe', () => {
+ MockImage.instances = [];
+ const container = createContainer();
+ appendThumbnail(container, 'https://example.com/page', { ImageCtor: MockImage });
+ assert.equal(container.children.length, 0);
+ MockImage.instances[0].onload();
+ assert.equal(container.children.length, 1);
+ assert.equal(container.children[0].tagName, 'IMG');
+ assert.equal(container.children[0].alt, '');
+ assert.equal(container.children[0].src, getOgImageUrl('https://example.com/page'));
+ });
+
+ it('falls back to favicon when og image probe fails', () => {
+ MockImage.instances = [];
+ const container = createContainer();
+ appendThumbnail(container, 'https://example.com/page', { favicon: true, ImageCtor: MockImage });
+ MockImage.instances[0].onerror();
+ MockImage.instances[1].onload();
+ assert.equal(container.children.length, 1);
+ assert.equal(container.children[0].className, 'favicon');
+ assert.equal(container.children[0].src, getFaviconUrl('https://example.com/page'));
+ });
+
+ it('does not append an image when og image probe fails without favicon fallback', () => {
+ MockImage.instances = [];
+ const container = createContainer();
+ appendThumbnail(container, 'https://example.com/page', { ImageCtor: MockImage });
+ MockImage.instances[0].onerror();
+ assert.equal(container.children.length, 0);
+ });
+});
diff --git a/tools/optel/oversight/elements/link-facet.js b/tools/optel/oversight/elements/link-facet.js
index decce461..dd3f0d14 100644
--- a/tools/optel/oversight/elements/link-facet.js
+++ b/tools/optel/oversight/elements/link-facet.js
@@ -27,6 +27,56 @@ export function labelURLParts(url, prefix, solo = false) {
}
}
+export function isThumbnailUrl(labelText) {
+ return labelText.startsWith('http://')
+ || labelText.startsWith('https://')
+ || labelText.startsWith('android-app://');
+}
+
+export function is404CheckpointActive(location = typeof window !== 'undefined' ? window.location : null) {
+ if (!location?.href) return false;
+ return new URL(location.href).searchParams.getAll('checkpoint').includes('404');
+}
+
+export function getOgImageUrl(labelText) {
+ const u = new URL('https://www.aem.live/tools/rum/_ogimage');
+ u.searchParams.set('proxyurl', labelText);
+ return u.href;
+}
+
+export function getFaviconUrl(labelText) {
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(labelText)}&sz=256`;
+}
+
+export function appendThumbnail(container, labelText, { favicon = false, ImageCtor = Image } = {}) {
+ const ogUrl = getOgImageUrl(labelText);
+ const probe = new ImageCtor();
+ probe.onload = () => {
+ const img = document.createElement('img');
+ img.loading = 'lazy';
+ img.src = ogUrl;
+ img.title = labelText;
+ img.alt = '';
+ container.prepend(img);
+ };
+ probe.onerror = () => {
+ if (!favicon) return;
+ const favUrl = getFaviconUrl(labelText);
+ const favProbe = new ImageCtor();
+ favProbe.onload = () => {
+ const img = document.createElement('img');
+ img.loading = 'lazy';
+ img.src = favUrl;
+ img.title = labelText;
+ img.alt = '';
+ img.classList.add('favicon');
+ container.prepend(img);
+ };
+ favProbe.src = favUrl;
+ };
+ probe.src = ogUrl;
+}
+
/**
* A custom HTML element to display a list of facets with links.
*
@@ -34,10 +84,24 @@ export function labelURLParts(url, prefix, solo = false) {
*
*/
export default class LinkFacet extends ListFacet {
+ createValueSpan(entry, prefix, solo = false) {
+ const valuespan = super.createValueSpan(entry, prefix, solo);
+ this.maybeLoadThumbnail(valuespan, entry.value);
+ return valuespan;
+ }
+
+ maybeLoadThumbnail(container, labelText) {
+ if (this.getAttribute('thumbnail') !== 'true') return;
+ if (is404CheckpointActive()) return;
+ if (!isThumbnailUrl(labelText)) return;
+ appendThumbnail(container, labelText, {
+ favicon: this.getAttribute('favicon') === 'true',
+ });
+ }
+
// eslint-disable-next-line class-methods-use-this
createLabelHTML(labelText, prefix, solo = false) {
const thumbnailAtt = this.getAttribute('thumbnail') === 'true';
- const faviconAtt = this.getAttribute('favicon') === 'true';
const isCensored = labelText.includes('...')
|| labelText.includes('') || labelText.includes('%3Cnumber%3E')
|| labelText.includes('') || labelText.includes('%3Chex%3E')
@@ -46,19 +110,8 @@ export default class LinkFacet extends ListFacet {
if (isCensored) {
return labelURLParts(labelText, prefix, solo);
}
- if (thumbnailAtt && labelText.startsWith('https://')) {
- const u = new URL('https://www.aem.live/tools/rum/_ogimage');
- u.searchParams.set('proxyurl', labelText);
- return `
-
- ${labelURLParts(labelText, prefix, solo)}`;
- }
- if (thumbnailAtt && (labelText.startsWith('http://') || labelText.startsWith('https://') || labelText.startsWith('android-app://'))) {
- const u = new URL('https://www.aem.live/tools/rum/_ogimage');
- u.searchParams.set('proxyurl', labelText);
- return `
-
- ${labelURLParts(labelText, prefix, solo)}`;
+ if (thumbnailAtt && isThumbnailUrl(labelText)) {
+ return `${labelURLParts(labelText, prefix, solo)}`;
}
if (labelText.startsWith('https://') || labelText.startsWith('http://')) {
return `${escapeHTML(labelText)}`;