feat(anime): show extension and repo icons in extensions panel

- Add extension-icons module (buildIconIndex, isSafeIconUrl, repoFaviconUrl, iconMonogram) with tests
- Render each row's icon with a monogram fallback while loading or missing
- Installed extensions borrow their icon from the catalogue; repo rows use the index host's favicon
- Restrict icon loads to https URLs
- Thread iconUrl through AvailableExtension and the runtime, document the feature
This commit is contained in:
2026-08-06 22:02:38 -07:00
parent 4c6bfaa22e
commit 47d31e9628
7 changed files with 190 additions and 1 deletions
+8 -1
View File
@@ -58,6 +58,12 @@ Extensions your repositories offer but you do not have appear under
**Available**, each with **Install**. Repositories are stored in config under **Available**, each with **Install**. Repositories are stored in config under
`anime.repos`, so you can also manage them there and keep them in a dotfile. `anime.repos`, so you can also manage them there and keep them in a dotfile.
Every row carries the extension's icon, as the repository publishes it, so a
site is recognisable before you read the name. A repository row shows its host's
favicon instead. Icons are the only part of a row that is fetched from the
network, and a row whose icon is missing falls back to the first letter of its
name rather than an empty box.
A repository index lists every language it knows about, which is far more than A repository index lists every language it knows about, which is far more than
any one person reads, so the **Available** list has a language chip row above any one person reads, so the **Available** list has a language chip row above
it. Pick one or more languages to narrow it, or **All** to clear the filter; it. Pick one or more languages to narrow it, or **All** to clear the filter;
@@ -72,7 +78,8 @@ The Extensions tab opens with an **Installed** section listing everything in the
extensions directory, with the sources each one provides and a **Remove** extensions directory, with the sources each one provides and a **Remove**
button. It is built from the directory rather than from a repository, so an button. It is built from the directory rather than from a repository, so an
extension you dropped in by hand — or one whose repository you have since extension you dropped in by hand — or one whose repository you have since
removed — is still listed and still removable. removed — is still listed and still removable. An installed extension borrows
its icon from the catalogue, so one no repository carries shows its monogram.
**Update** appears next to an extension a configured repository still carries; **Update** appears next to an extension a configured repository still carries;
it downloads the current version over the existing APK. it downloads the current version over the existing APK.
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons';
test('buildIconIndex maps packages to their icon, skipping empty URLs', () => {
const index = buildIconIndex([
{ pkg: 'a.b.c', iconUrl: 'https://repo.example/icon/a.b.c.png' },
{ pkg: 'd.e.f', iconUrl: '' },
]);
assert.equal(index.get('a.b.c'), 'https://repo.example/icon/a.b.c.png');
assert.equal(index.has('d.e.f'), false);
});
test('isSafeIconUrl accepts https only', () => {
assert.equal(isSafeIconUrl('https://repo.example/icon/x.png'), true);
assert.equal(isSafeIconUrl('http://repo.example/icon/x.png'), false);
assert.equal(isSafeIconUrl('javascript:alert(1)'), false);
assert.equal(isSafeIconUrl(null), false);
assert.equal(isSafeIconUrl(undefined), false);
});
test('repoFaviconUrl points at the index host, https only', () => {
assert.equal(
repoFaviconUrl(' https://raw.githubusercontent.com/u/r/main/index.min.json '),
'https://raw.githubusercontent.com/favicon.ico',
);
assert.equal(repoFaviconUrl('http://repo.example/index.json'), null);
assert.equal(repoFaviconUrl('not a url'), null);
});
test('iconMonogram takes the first letter or digit, uppercased', () => {
assert.equal(iconMonogram('AllAnime'), 'A');
assert.equal(iconMonogram(' gogoanime'), 'G');
assert.equal(iconMonogram('» Foo'), 'F');
assert.equal(iconMonogram('9anime'), '9');
assert.equal(iconMonogram('アニメ'), 'ア');
});
test('iconMonogram falls back to a placeholder for a nameless row', () => {
assert.equal(iconMonogram(''), '?');
assert.equal(iconMonogram('---'), '?');
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Icons for the extension rows.
*
* An Aniyomi repository publishes each extension's icon — the streaming site's
* own favicon — next to its APK, so the repository index is the only place an
* icon can come from. Installed extensions are listed from disk and carry no
* icon of their own; they borrow the one their package has in the catalogue,
* which means an extension whose repository was removed, or one dropped in by
* hand, falls back to a monogram.
*/
interface IconSource {
pkg: string;
iconUrl: string;
}
/** Package name to icon URL, for looking up an installed extension's icon. */
export function buildIconIndex(extensions: IconSource[]): Map<string, string> {
const index = new Map<string, string>();
for (const extension of extensions) {
if (typeof extension.iconUrl === 'string' && extension.iconUrl.length > 0) {
index.set(extension.pkg, extension.iconUrl);
}
}
return index;
}
/** Only https icons are loaded; a repo index is content the user pointed us at. */
export function isSafeIconUrl(url: string | null | undefined): url is string {
return typeof url === 'string' && /^https:\/\/\S+$/.test(url);
}
/**
* The favicon of the host serving a repository index.
*
* A repository publishes no icon for itself, so the host's own favicon stands
* in. Nothing depends on it: a host that serves none simply leaves the row on
* its monogram.
*/
export function repoFaviconUrl(indexUrl: string): string | null {
try {
const url = new URL(indexUrl.trim());
if (url.protocol !== 'https:') return null;
return `https://${url.host}/favicon.ico`;
} catch {
return null;
}
}
/**
* The letter shown while an icon loads, or in place of one that never does.
*
* Leading punctuation and whitespace are skipped, and a name that carries no
* letter or digit at all still gets a placeholder rather than an empty box.
*/
export function iconMonogram(name: string): string {
for (const char of name.trim()) {
if (/[\p{L}\p{N}]/u.test(char)) return char.toUpperCase();
}
return '?';
}
+35
View File
@@ -1,4 +1,5 @@
import { describe, el } from './dom'; import { describe, el } from './dom';
import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons';
import { describeInstalled } from './format'; import { describeInstalled } from './format';
import { import {
collectLanguages, collectLanguages,
@@ -39,14 +40,40 @@ interface RowAction {
interface RowOptions { interface RowOptions {
name: string; name: string;
sub: string; sub: string;
/** Repository-published icon for the row, when there is one. */
iconUrl?: string | null;
tags?: Array<{ text: string; className: string }>; tags?: Array<{ text: string; className: string }>;
actions?: RowAction[]; actions?: RowAction[];
isError?: boolean; isError?: boolean;
} }
/**
* The row's avatar: the extension's icon, with the name's first letter behind
* it. Repositories do not always publish an icon for every package, so the
* monogram shows until the image loads and stays put if it never does.
*/
function extensionIcon(name: string, iconUrl: string | null | undefined): HTMLSpanElement {
const badge = document.createElement('span');
badge.className = 'ext-icon';
badge.setAttribute('aria-hidden', 'true');
badge.textContent = iconMonogram(name);
if (!isSafeIconUrl(iconUrl)) return badge;
const image = document.createElement('img');
image.className = 'ext-icon-img';
image.loading = 'lazy';
image.alt = '';
image.addEventListener('load', () => badge.classList.add('has-icon'));
image.addEventListener('error', () => image.remove());
image.src = iconUrl;
badge.append(image);
return badge;
}
function extensionRow(options: RowOptions): HTMLDivElement { function extensionRow(options: RowOptions): HTMLDivElement {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = options.isError ? 'ext-row is-error' : 'ext-row'; row.className = options.isError ? 'ext-row is-error' : 'ext-row';
row.append(extensionIcon(options.name, options.iconUrl));
const main = document.createElement('div'); const main = document.createElement('div');
main.className = 'ext-main'; main.className = 'ext-main';
@@ -106,6 +133,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
// What the last refresh found, kept so toggling a language chip re-renders // What the last refresh found, kept so toggling a language chip re-renders
// the list without re-fetching every repository index. // the list without re-fetching every repository index.
let installable: AvailableExtension[] = []; let installable: AvailableExtension[] = [];
/** Package to icon URL, so installed rows can borrow the catalogue's icon. */
let iconsByPkg = new Map<string, string>();
let repoFailures: Array<{ name: string; error: string }> = []; let repoFailures: Array<{ name: string; error: string }> = [];
let hasRepos = false; let hasRepos = false;
/** Selected language codes; empty means "All". */ /** Selected language codes; empty means "All". */
@@ -168,6 +197,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
return extensionRow({ return extensionRow({
name: view.name, name: view.name,
sub: view.error ?? describeInstalled(view), sub: view.error ?? describeInstalled(view),
iconUrl: iconsByPkg.get(view.pkg) ?? null,
isError: view.error !== null, isError: view.error !== null,
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }], tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
actions, actions,
@@ -182,6 +212,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
extensionRow({ extensionRow({
name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''), name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
sub: repoUrl, sub: repoUrl,
iconUrl: repoFaviconUrl(repoUrl),
actions: [ actions: [
{ {
label: 'Remove', label: 'Remove',
@@ -258,6 +289,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
extensionRow({ extensionRow({
name: extension.name, name: extension.name,
sub: `${languageLabel(extension.lang)} · v${extension.version}`, sub: `${languageLabel(extension.lang)} · v${extension.version}`,
iconUrl: extension.iconUrl,
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [], tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
actions: [ actions: [
{ {
@@ -311,6 +343,9 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
} }
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg)); const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
// The catalogue is the only source of icons, so an installed extension can
// only show one while a repository still carries its package.
iconsByPkg = buildIconIndex(available.extensions);
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir); renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
// Installed extensions have their own section; leaving them here too would // Installed extensions have their own section; leaving them here too would
// list every one of them twice. // list every one of them twice.
+41
View File
@@ -211,6 +211,47 @@
border-color: rgba(237, 135, 150, 0.45); border-color: rgba(237, 135, 150, 0.45);
} }
/*
* Avatar for an extension row. The monogram is the box's own text, so it shows
* while the icon loads and stays put when a repository publishes none; a loaded
* icon covers it.
*/
.ext-icon {
position: relative;
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--ctp-surface0);
font-size: 13px;
font-weight: 600;
color: var(--faint);
overflow: hidden;
}
/* A loaded icon replaces the monogram; transparent PNGs must not show it. */
.ext-icon.has-icon {
border-color: transparent;
background: transparent;
color: transparent;
}
.ext-icon-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
}
.ext-icon.has-icon .ext-icon-img {
opacity: 1;
}
.ext-main { .ext-main {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -389,6 +389,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
version: extension.version, version: extension.version,
nsfw: extension.nsfw, nsfw: extension.nsfw,
repoUrl: extension.repoUrl, repoUrl: extension.repoUrl,
iconUrl: extension.iconUrl,
sourceNames: extension.sourceNames, sourceNames: extension.sourceNames,
installed: installedPkgs.has(extension.pkg), installed: installedPkgs.has(extension.pkg),
})), })),
+2
View File
@@ -156,6 +156,8 @@ export interface AvailableExtension {
version: string; version: string;
nsfw: boolean; nsfw: boolean;
repoUrl: string; repoUrl: string;
/** Where the repository publishes the extension's icon; may 404. */
iconUrl: string;
sourceNames: string[]; sourceNames: string[];
installed: boolean; installed: boolean;
} }